Repository: zapek/Xeres Branch: master Commit: 866a13a92fc3 Files: 1421 Total size: 5.3 MB Directory structure: gitextract_qxi8ntma/ ├── .agents/ │ └── skills/ │ ├── archunit-rules/ │ │ └── SKILL.md │ ├── crypto/ │ │ └── SKILL.md │ ├── dto-mappers/ │ │ └── SKILL.md │ ├── flyway-migrations/ │ │ └── SKILL.md │ ├── gradle-build/ │ │ └── SKILL.md │ ├── java-conventions/ │ │ └── SKILL.md │ ├── javafx-patterns/ │ │ └── SKILL.md │ ├── junit-testing/ │ │ └── SKILL.md │ ├── spring-boot-patterns/ │ │ └── SKILL.md │ └── ui-testing/ │ └── SKILL.md ├── .aiignore ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ └── feature_request.yaml │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── analysis.yml │ ├── build-docker.yml │ ├── build-installer.yml │ ├── dependencies.yaml │ └── qodana_code_quality.yml ├── .gitignore ├── .run/ │ └── All Tests.run.xml ├── AGENTS.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SandBox.wsb ├── app/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/ │ │ │ └── xeres/ │ │ │ └── app/ │ │ │ ├── XeresApplication.java │ │ │ ├── api/ │ │ │ │ ├── DefaultHandler.java │ │ │ │ ├── controller/ │ │ │ │ │ ├── board/ │ │ │ │ │ │ └── BoardController.java │ │ │ │ │ ├── channel/ │ │ │ │ │ │ └── ChannelController.java │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── ChatController.java │ │ │ │ │ │ ├── ChatMessageController.java │ │ │ │ │ │ ├── doc-files/ │ │ │ │ │ │ │ └── websocket.puml │ │ │ │ │ │ └── package-info.java │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── ConfigController.java │ │ │ │ │ │ └── package-info.java │ │ │ │ │ ├── connection/ │ │ │ │ │ │ └── ConnectionController.java │ │ │ │ │ ├── contact/ │ │ │ │ │ │ └── ContactController.java │ │ │ │ │ ├── file/ │ │ │ │ │ │ └── FileController.java │ │ │ │ │ ├── forum/ │ │ │ │ │ │ └── ForumController.java │ │ │ │ │ ├── geoip/ │ │ │ │ │ │ └── GeoIpController.java │ │ │ │ │ ├── identity/ │ │ │ │ │ │ └── IdentityController.java │ │ │ │ │ ├── location/ │ │ │ │ │ │ └── LocationController.java │ │ │ │ │ ├── notification/ │ │ │ │ │ │ └── NotificationController.java │ │ │ │ │ ├── profile/ │ │ │ │ │ │ └── ProfileController.java │ │ │ │ │ ├── settings/ │ │ │ │ │ │ └── SettingsController.java │ │ │ │ │ ├── share/ │ │ │ │ │ │ └── ShareController.java │ │ │ │ │ ├── statistics/ │ │ │ │ │ │ ├── StatisticsController.java │ │ │ │ │ │ └── StatisticsMapper.java │ │ │ │ │ └── voip/ │ │ │ │ │ └── VoipMessageController.java │ │ │ │ ├── converter/ │ │ │ │ │ └── BufferedImageConverter.java │ │ │ │ ├── exception/ │ │ │ │ │ ├── InternalServerErrorException.java │ │ │ │ │ └── UnprocessableEntityException.java │ │ │ │ └── package-info.java │ │ │ ├── application/ │ │ │ │ ├── SingleInstanceRun.java │ │ │ │ ├── Startup.java │ │ │ │ ├── autostart/ │ │ │ │ │ ├── AutoStart.java │ │ │ │ │ ├── AutoStarter.java │ │ │ │ │ └── autostarter/ │ │ │ │ │ ├── AutoStarterGeneric.java │ │ │ │ │ └── AutoStarterWindows.java │ │ │ │ ├── environment/ │ │ │ │ │ ├── Cloud.java │ │ │ │ │ ├── CommandArgument.java │ │ │ │ │ ├── DefaultProperties.java │ │ │ │ │ ├── HostVariable.java │ │ │ │ │ └── LocalPortFinder.java │ │ │ │ └── events/ │ │ │ │ ├── DhtNodeFoundEvent.java │ │ │ │ ├── IpChangedEvent.java │ │ │ │ ├── LocationReadyEvent.java │ │ │ │ ├── NetworkReadyEvent.java │ │ │ │ ├── PeerConnectedEvent.java │ │ │ │ ├── PeerDisconnectedEvent.java │ │ │ │ ├── SettingsChangedEvent.java │ │ │ │ ├── UpnpEvent.java │ │ │ │ └── package-info.java │ │ │ ├── configuration/ │ │ │ │ ├── AsynchronousEventsConfiguration.java │ │ │ │ ├── AutoStartConfiguration.java │ │ │ │ ├── CacheDirConfiguration.java │ │ │ │ ├── CustomCsrfChannelInterceptor.java │ │ │ │ ├── DataDirConfiguration.java │ │ │ │ ├── DataSourceConfiguration.java │ │ │ │ ├── EnumMappingConfiguration.java │ │ │ │ ├── GeoIpConfiguration.java │ │ │ │ ├── IdleTimeConfiguration.java │ │ │ │ ├── SchedulerConfiguration.java │ │ │ │ ├── SelfCertificateConfiguration.java │ │ │ │ ├── WebConfiguration.java │ │ │ │ ├── WebSecurityConfiguration.java │ │ │ │ ├── WebServerConfiguration.java │ │ │ │ ├── WebSocketConfiguration.java │ │ │ │ ├── WebSocketLoggingConfiguration.java │ │ │ │ ├── WebSocketMessageBrokerConfiguration.java │ │ │ │ └── WebSocketSecurityConfiguration.java │ │ │ ├── crypto/ │ │ │ │ ├── aead/ │ │ │ │ │ └── AEAD.java │ │ │ │ ├── aes/ │ │ │ │ │ └── AES.java │ │ │ │ ├── dh/ │ │ │ │ │ └── DiffieHellman.java │ │ │ │ ├── ec/ │ │ │ │ │ └── Ed25519.java │ │ │ │ ├── hash/ │ │ │ │ │ ├── AbstractMessageDigest.java │ │ │ │ │ ├── chat/ │ │ │ │ │ │ └── ChatChallenge.java │ │ │ │ │ ├── sha1/ │ │ │ │ │ │ └── Sha1MessageDigest.java │ │ │ │ │ └── sha256/ │ │ │ │ │ └── Sha256MessageDigest.java │ │ │ │ ├── hmac/ │ │ │ │ │ ├── AbstractHMac.java │ │ │ │ │ ├── sha1/ │ │ │ │ │ │ └── Sha1HMac.java │ │ │ │ │ └── sha256/ │ │ │ │ │ └── Sha256HMac.java │ │ │ │ ├── pgp/ │ │ │ │ │ ├── PGP.java │ │ │ │ │ ├── PGPSigner.java │ │ │ │ │ └── package-info.java │ │ │ │ ├── rsa/ │ │ │ │ │ └── RSA.java │ │ │ │ ├── rscrypto/ │ │ │ │ │ ├── RsCrypto.java │ │ │ │ │ └── doc-files/ │ │ │ │ │ └── format.puml │ │ │ │ ├── rsid/ │ │ │ │ │ ├── RSCertificate.java │ │ │ │ │ ├── RSId.java │ │ │ │ │ ├── RSIdArmor.java │ │ │ │ │ ├── RSIdBuilder.java │ │ │ │ │ ├── RSIdCrc.java │ │ │ │ │ ├── RSSerialVersion.java │ │ │ │ │ └── ShortInvite.java │ │ │ │ └── x509/ │ │ │ │ └── X509.java │ │ │ ├── database/ │ │ │ │ ├── DatabaseSession.java │ │ │ │ ├── DatabaseSessionManager.java │ │ │ │ ├── converter/ │ │ │ │ │ ├── AvailabilityConverter.java │ │ │ │ │ ├── EnumConverter.java │ │ │ │ │ ├── EnumSetConverter.java │ │ │ │ │ ├── FileTypeConverter.java │ │ │ │ │ ├── GxsCircleTypeConverter.java │ │ │ │ │ ├── GxsPrivacyFlagsConverter.java │ │ │ │ │ ├── GxsSignatureFlagsConverter.java │ │ │ │ │ ├── IdentityTypeConverter.java │ │ │ │ │ ├── NetModeConverter.java │ │ │ │ │ ├── PeerAddressTypeConverter.java │ │ │ │ │ ├── SecurityKeyFlagsConverter.java │ │ │ │ │ ├── SignatureTypeConverter.java │ │ │ │ │ ├── TrustConverter.java │ │ │ │ │ └── VoteTypeConverter.java │ │ │ │ ├── model/ │ │ │ │ │ ├── board/ │ │ │ │ │ │ └── BoardMapper.java │ │ │ │ │ ├── channel/ │ │ │ │ │ │ └── ChannelMapper.java │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── ChatBacklog.java │ │ │ │ │ │ ├── ChatMapper.java │ │ │ │ │ │ ├── ChatRoom.java │ │ │ │ │ │ ├── ChatRoomBacklog.java │ │ │ │ │ │ └── DistantChatBacklog.java │ │ │ │ │ ├── connection/ │ │ │ │ │ │ ├── Connection.java │ │ │ │ │ │ └── ConnectionMapper.java │ │ │ │ │ ├── file/ │ │ │ │ │ │ ├── File.java │ │ │ │ │ │ └── FileDownload.java │ │ │ │ │ ├── forum/ │ │ │ │ │ │ ├── ForumMapper.java │ │ │ │ │ │ └── ForumMessageItemSummary.java │ │ │ │ │ ├── gxs/ │ │ │ │ │ │ ├── GxsCircleType.java │ │ │ │ │ │ ├── GxsClientUpdate.java │ │ │ │ │ │ ├── GxsConstants.java │ │ │ │ │ │ ├── GxsGroupItem.java │ │ │ │ │ │ ├── GxsMessageItem.java │ │ │ │ │ │ ├── GxsMetaAndData.java │ │ │ │ │ │ ├── GxsPrivacyFlags.java │ │ │ │ │ │ ├── GxsServiceSetting.java │ │ │ │ │ │ └── GxsSignatureFlags.java │ │ │ │ │ ├── identity/ │ │ │ │ │ │ └── IdentityMapper.java │ │ │ │ │ ├── location/ │ │ │ │ │ │ ├── Location.java │ │ │ │ │ │ └── LocationMapper.java │ │ │ │ │ ├── profile/ │ │ │ │ │ │ ├── Profile.java │ │ │ │ │ │ └── ProfileMapper.java │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── Settings.java │ │ │ │ │ │ └── SettingsMapper.java │ │ │ │ │ └── share/ │ │ │ │ │ ├── Share.java │ │ │ │ │ └── ShareMapper.java │ │ │ │ └── repository/ │ │ │ │ ├── ChatBacklogRepository.java │ │ │ │ ├── ChatRoomBacklogRepository.java │ │ │ │ ├── ChatRoomRepository.java │ │ │ │ ├── DistantChatBacklogRepository.java │ │ │ │ ├── FileDownloadRepository.java │ │ │ │ ├── FileRepository.java │ │ │ │ ├── GxsBoardGroupRepository.java │ │ │ │ ├── GxsBoardMessageRepository.java │ │ │ │ ├── GxsChannelGroupRepository.java │ │ │ │ ├── GxsChannelMessageRepository.java │ │ │ │ ├── GxsClientUpdateRepository.java │ │ │ │ ├── GxsCommentMessageRepository.java │ │ │ │ ├── GxsForumGroupRepository.java │ │ │ │ ├── GxsForumMessageRepository.java │ │ │ │ ├── GxsGroupItemRepository.java │ │ │ │ ├── GxsIdentityRepository.java │ │ │ │ ├── GxsMessageItemRepository.java │ │ │ │ ├── GxsServiceSettingRepository.java │ │ │ │ ├── GxsVoteMessageRepository.java │ │ │ │ ├── LocationRepository.java │ │ │ │ ├── ProfileRepository.java │ │ │ │ ├── SettingsRepository.java │ │ │ │ └── ShareRepository.java │ │ │ ├── job/ │ │ │ │ ├── DhtFinderJob.java │ │ │ │ ├── FileIndexingJob.java │ │ │ │ ├── IdleDetectionJob.java │ │ │ │ ├── JobUtils.java │ │ │ │ └── PeerConnectionJob.java │ │ │ ├── net/ │ │ │ │ ├── bdisc/ │ │ │ │ │ ├── BroadcastDiscoveryService.java │ │ │ │ │ ├── ProtocolVersion.java │ │ │ │ │ ├── UdpDiscoveryPeer.java │ │ │ │ │ └── UdpDiscoveryProtocol.java │ │ │ │ ├── dht/ │ │ │ │ │ ├── DHTSpringLog.java │ │ │ │ │ ├── DhtService.java │ │ │ │ │ ├── NodeId.java │ │ │ │ │ └── package-info.java │ │ │ │ ├── external/ │ │ │ │ │ └── ExternalIpResolver.java │ │ │ │ ├── peer/ │ │ │ │ │ ├── ConnectionType.java │ │ │ │ │ ├── DefaultItemFuture.java │ │ │ │ │ ├── ItemFuture.java │ │ │ │ │ ├── PeerAttribute.java │ │ │ │ │ ├── PeerConnection.java │ │ │ │ │ ├── PeerConnectionManager.java │ │ │ │ │ ├── bootstrap/ │ │ │ │ │ │ ├── PeerClient.java │ │ │ │ │ │ ├── PeerI2pClient.java │ │ │ │ │ │ ├── PeerInitializer.java │ │ │ │ │ │ ├── PeerServer.java │ │ │ │ │ │ ├── PeerTcpClient.java │ │ │ │ │ │ ├── PeerTcpServer.java │ │ │ │ │ │ └── PeerTorClient.java │ │ │ │ │ ├── packet/ │ │ │ │ │ │ ├── MultiPacket.java │ │ │ │ │ │ ├── Packet.java │ │ │ │ │ │ ├── SimplePacket.java │ │ │ │ │ │ └── package-info.java │ │ │ │ │ ├── pipeline/ │ │ │ │ │ │ ├── IdleEventHandler.java │ │ │ │ │ │ ├── ItemDecoder.java │ │ │ │ │ │ ├── ItemEncoder.java │ │ │ │ │ │ ├── MultiPacketEncoder.java │ │ │ │ │ │ ├── PacketDecoder.java │ │ │ │ │ │ ├── PeerHandler.java │ │ │ │ │ │ ├── SimplePacketEncoder.java │ │ │ │ │ │ └── package-info.java │ │ │ │ │ └── ssl/ │ │ │ │ │ └── SSL.java │ │ │ │ ├── protocol/ │ │ │ │ │ ├── DomainNameSocketAddress.java │ │ │ │ │ └── PeerAddress.java │ │ │ │ ├── upnp/ │ │ │ │ │ ├── ControlPoint.java │ │ │ │ │ ├── Device.java │ │ │ │ │ ├── DeviceSpecs.java │ │ │ │ │ ├── HttpuHeader.java │ │ │ │ │ ├── PortMapping.java │ │ │ │ │ ├── Protocol.java │ │ │ │ │ ├── Soap.java │ │ │ │ │ ├── UPNPService.java │ │ │ │ │ └── package-info.java │ │ │ │ └── util/ │ │ │ │ └── NetworkMode.java │ │ │ ├── package-info.java │ │ │ ├── properties/ │ │ │ │ ├── DatabaseProperties.java │ │ │ │ └── NetworkProperties.java │ │ │ ├── service/ │ │ │ │ ├── BoardMessageService.java │ │ │ │ ├── CapabilityService.java │ │ │ │ ├── ChannelMessageService.java │ │ │ │ ├── ContactService.java │ │ │ │ ├── ForumMessageService.java │ │ │ │ ├── GeoIpService.java │ │ │ │ ├── IdentityService.java │ │ │ │ ├── InfoService.java │ │ │ │ ├── LocationService.java │ │ │ │ ├── MessageService.java │ │ │ │ ├── NetworkService.java │ │ │ │ ├── PeerService.java │ │ │ │ ├── ProfileService.java │ │ │ │ ├── QrCodeService.java │ │ │ │ ├── ResourceCreationState.java │ │ │ │ ├── SettingsService.java │ │ │ │ ├── UiBridgeService.java │ │ │ │ ├── UnHtmlService.java │ │ │ │ ├── UpgradeService.java │ │ │ │ ├── audio/ │ │ │ │ │ └── AudioService.java │ │ │ │ ├── backup/ │ │ │ │ │ ├── BackupService.java │ │ │ │ │ ├── Export.java │ │ │ │ │ ├── Group.java │ │ │ │ │ ├── Identity.java │ │ │ │ │ ├── Local.java │ │ │ │ │ ├── Location.java │ │ │ │ │ ├── LocationIdentifierXmlAdapter.java │ │ │ │ │ ├── PgpId.java │ │ │ │ │ ├── Profile.java │ │ │ │ │ ├── RSIdXmlAdapter.java │ │ │ │ │ ├── Root.java │ │ │ │ │ └── SslId.java │ │ │ │ ├── file/ │ │ │ │ │ ├── FileService.java │ │ │ │ │ ├── HashBloomFilter.java │ │ │ │ │ └── TrackingFileVisitor.java │ │ │ │ ├── identicon/ │ │ │ │ │ └── IdenticonService.java │ │ │ │ ├── notification/ │ │ │ │ │ ├── NotificationService.java │ │ │ │ │ ├── availability/ │ │ │ │ │ │ └── AvailabilityNotificationService.java │ │ │ │ │ ├── board/ │ │ │ │ │ │ └── BoardNotificationService.java │ │ │ │ │ ├── channel/ │ │ │ │ │ │ └── ChannelNotificationService.java │ │ │ │ │ ├── contact/ │ │ │ │ │ │ └── ContactNotificationService.java │ │ │ │ │ ├── file/ │ │ │ │ │ │ ├── FileNotificationService.java │ │ │ │ │ │ ├── FileSearchNotificationService.java │ │ │ │ │ │ └── FileTrendNotificationService.java │ │ │ │ │ ├── forum/ │ │ │ │ │ │ └── ForumNotificationService.java │ │ │ │ │ └── status/ │ │ │ │ │ └── StatusNotificationService.java │ │ │ │ ├── script/ │ │ │ │ │ ├── Console.java │ │ │ │ │ ├── ScriptEvent.java │ │ │ │ │ └── ScriptService.java │ │ │ │ └── shell/ │ │ │ │ ├── History.java │ │ │ │ └── ShellService.java │ │ │ ├── util/ │ │ │ │ ├── DevUtils.java │ │ │ │ ├── GxsUtils.java │ │ │ │ ├── XmlUtils.java │ │ │ │ └── expression/ │ │ │ │ ├── CompoundExpression.java │ │ │ │ ├── DateExpression.java │ │ │ │ ├── Expression.java │ │ │ │ ├── ExpressionMapper.java │ │ │ │ ├── ExpressionType.java │ │ │ │ ├── ExtensionExpression.java │ │ │ │ ├── HashExpression.java │ │ │ │ ├── NameExpression.java │ │ │ │ ├── PathExpression.java │ │ │ │ ├── PopularityExpression.java │ │ │ │ ├── RelationalExpression.java │ │ │ │ ├── SizeExpression.java │ │ │ │ ├── SizeMbExpression.java │ │ │ │ └── StringExpression.java │ │ │ └── xrs/ │ │ │ ├── common/ │ │ │ │ ├── CommentMessageItem.java │ │ │ │ ├── FileData.java │ │ │ │ ├── FileItem.java │ │ │ │ ├── FileSet.java │ │ │ │ ├── SecurityKey.java │ │ │ │ ├── Signature.java │ │ │ │ └── VoteMessageItem.java │ │ │ ├── item/ │ │ │ │ ├── Item.java │ │ │ │ ├── ItemHeader.java │ │ │ │ ├── ItemPriority.java │ │ │ │ ├── ItemUtils.java │ │ │ │ └── RawItem.java │ │ │ ├── serialization/ │ │ │ │ ├── AnnotationSerializer.java │ │ │ │ ├── ArraySerializer.java │ │ │ │ ├── BigIntegerSerializer.java │ │ │ │ ├── BooleanSerializer.java │ │ │ │ ├── ByteArraySerializer.java │ │ │ │ ├── ByteSerializer.java │ │ │ │ ├── DoubleSerializer.java │ │ │ │ ├── EnumSerializer.java │ │ │ │ ├── EnumSetSerializer.java │ │ │ │ ├── FieldSize.java │ │ │ │ ├── FloatSerializer.java │ │ │ │ ├── GxsMetaAndDataResult.java │ │ │ │ ├── GxsMetaAndDataSerializer.java │ │ │ │ ├── IdentifierSerializer.java │ │ │ │ ├── IntSerializer.java │ │ │ │ ├── ListSerializer.java │ │ │ │ ├── LongSerializer.java │ │ │ │ ├── MapSerializer.java │ │ │ │ ├── RsClassSerializedReversed.java │ │ │ │ ├── RsSerializable.java │ │ │ │ ├── RsSerializableSerializer.java │ │ │ │ ├── RsSerialized.java │ │ │ │ ├── SerializationFlags.java │ │ │ │ ├── Serializer.java │ │ │ │ ├── SerializerSizeCache.java │ │ │ │ ├── ShortSerializer.java │ │ │ │ ├── StringSerializer.java │ │ │ │ ├── TlvAddressSerializer.java │ │ │ │ ├── TlvBinarySerializer.java │ │ │ │ ├── TlvFileDataSerializer.java │ │ │ │ ├── TlvFileItemSerializer.java │ │ │ │ ├── TlvFileSetSerializer.java │ │ │ │ ├── TlvImageSerializer.java │ │ │ │ ├── TlvSecurityKeySerializer.java │ │ │ │ ├── TlvSecurityKeySetSerializer.java │ │ │ │ ├── TlvSerializer.java │ │ │ │ ├── TlvSetSerializer.java │ │ │ │ ├── TlvSignatureSerializer.java │ │ │ │ ├── TlvSignatureSetSerializer.java │ │ │ │ ├── TlvStringSerializer.java │ │ │ │ ├── TlvStringSetRefSerializer.java │ │ │ │ ├── TlvType.java │ │ │ │ ├── TlvUint32Serializer.java │ │ │ │ ├── TlvUint64Serializer.java │ │ │ │ └── TlvUtils.java │ │ │ └── service/ │ │ │ ├── DefaultItem.java │ │ │ ├── RsService.java │ │ │ ├── RsServiceInitPriority.java │ │ │ ├── RsServiceMaster.java │ │ │ ├── RsServiceRegistry.java │ │ │ ├── RsServiceSlave.java │ │ │ ├── bandwidth/ │ │ │ │ ├── BandwidthRsService.java │ │ │ │ ├── BandwidthUtils.java │ │ │ │ └── item/ │ │ │ │ └── BandwidthAllowedItem.java │ │ │ ├── board/ │ │ │ │ ├── BoardRsService.java │ │ │ │ └── item/ │ │ │ │ ├── BoardGroupItem.java │ │ │ │ └── BoardMessageItem.java │ │ │ ├── channel/ │ │ │ │ ├── ChannelRsService.java │ │ │ │ └── item/ │ │ │ │ ├── ChannelGroupItem.java │ │ │ │ └── ChannelMessageItem.java │ │ │ ├── chat/ │ │ │ │ ├── ChatBacklogService.java │ │ │ │ ├── ChatFlags.java │ │ │ │ ├── ChatRoom.java │ │ │ │ ├── ChatRoomService.java │ │ │ │ ├── ChatRsService.java │ │ │ │ ├── DistantLocation.java │ │ │ │ ├── MessageCache.java │ │ │ │ ├── RoomFlags.java │ │ │ │ └── item/ │ │ │ │ ├── ChatAvatarItem.java │ │ │ │ ├── ChatMessageItem.java │ │ │ │ ├── ChatRoomBounce.java │ │ │ │ ├── ChatRoomConfigItem.java │ │ │ │ ├── ChatRoomConnectChallengeItem.java │ │ │ │ ├── ChatRoomEvent.java │ │ │ │ ├── ChatRoomEventItem.java │ │ │ │ ├── ChatRoomInviteItem.java │ │ │ │ ├── ChatRoomInviteOldItem.java │ │ │ │ ├── ChatRoomListItem.java │ │ │ │ ├── ChatRoomListRequestItem.java │ │ │ │ ├── ChatRoomMessageItem.java │ │ │ │ ├── ChatRoomUnsubscribeItem.java │ │ │ │ ├── ChatStatusItem.java │ │ │ │ ├── PrivateChatMessageConfigItem.java │ │ │ │ ├── PrivateOutgoingMapItem.java │ │ │ │ ├── SubscribedChatRoomConfigItem.java │ │ │ │ └── VisibleChatRoomInfo.java │ │ │ ├── discovery/ │ │ │ │ ├── DiscoveryRsService.java │ │ │ │ └── item/ │ │ │ │ ├── DiscoveryContactItem.java │ │ │ │ ├── DiscoveryIdentityListItem.java │ │ │ │ ├── DiscoveryPgpKeyItem.java │ │ │ │ └── DiscoveryPgpListItem.java │ │ │ ├── filetransfer/ │ │ │ │ ├── Action.java │ │ │ │ ├── ActionAddPeer.java │ │ │ │ ├── ActionDownload.java │ │ │ │ ├── ActionGetDownloadsProgress.java │ │ │ │ ├── ActionGetUploadsProgress.java │ │ │ │ ├── ActionReceiveChunkMap.java │ │ │ │ ├── ActionReceiveChunkMapRequest.java │ │ │ │ ├── ActionReceiveData.java │ │ │ │ ├── ActionReceiveDataRequest.java │ │ │ │ ├── ActionReceiveSingleChunkCrc.java │ │ │ │ ├── ActionReceiveSingleChunkCrcRequest.java │ │ │ │ ├── ActionRemoveDownload.java │ │ │ │ ├── ActionRemovePeer.java │ │ │ │ ├── Chunk.java │ │ │ │ ├── ChunkDistributor.java │ │ │ │ ├── ChunkMapUtils.java │ │ │ │ ├── ChunkReceiver.java │ │ │ │ ├── FileDownload.java │ │ │ │ ├── FileLeecher.java │ │ │ │ ├── FilePeer.java │ │ │ │ ├── FileProvider.java │ │ │ │ ├── FileSeeder.java │ │ │ │ ├── FileTransferAgent.java │ │ │ │ ├── FileTransferEncryptionKey.java │ │ │ │ ├── FileTransferManager.java │ │ │ │ ├── FileTransferRsService.java │ │ │ │ ├── FileTransferStrategy.java │ │ │ │ ├── FileUpload.java │ │ │ │ ├── SliceSender.java │ │ │ │ ├── doc-files/ │ │ │ │ │ └── filetransfer.puml │ │ │ │ └── item/ │ │ │ │ ├── FileTransferChunkMapItem.java │ │ │ │ ├── FileTransferChunkMapRequestItem.java │ │ │ │ ├── FileTransferDataItem.java │ │ │ │ ├── FileTransferDataRequestItem.java │ │ │ │ ├── FileTransferSingleChunkCrcItem.java │ │ │ │ ├── FileTransferSingleChunkCrcRequestItem.java │ │ │ │ ├── TurtleChunkCrcItem.java │ │ │ │ ├── TurtleChunkCrcRequestItem.java │ │ │ │ ├── TurtleFileDataItem.java │ │ │ │ ├── TurtleFileMapItem.java │ │ │ │ ├── TurtleFileMapRequestItem.java │ │ │ │ └── TurtleFileRequestItem.java │ │ │ ├── forum/ │ │ │ │ ├── ForumRsService.java │ │ │ │ └── item/ │ │ │ │ ├── ForumGroupItem.java │ │ │ │ └── ForumMessageItem.java │ │ │ ├── gxs/ │ │ │ │ ├── GxsAuthentication.java │ │ │ │ ├── GxsHelperService.java │ │ │ │ ├── GxsRsService.java │ │ │ │ ├── GxsTransactionManager.java │ │ │ │ ├── Transaction.java │ │ │ │ ├── doc-files/ │ │ │ │ │ ├── transaction.puml │ │ │ │ │ └── transfer.puml │ │ │ │ └── item/ │ │ │ │ ├── DynamicServiceType.java │ │ │ │ ├── GxsExchange.java │ │ │ │ ├── GxsSyncGroupItem.java │ │ │ │ ├── GxsSyncGroupRequestItem.java │ │ │ │ ├── GxsSyncGroupStatsItem.java │ │ │ │ ├── GxsSyncMessageItem.java │ │ │ │ ├── GxsSyncMessageRequestItem.java │ │ │ │ ├── GxsSyncNotifyItem.java │ │ │ │ ├── GxsTransactionItem.java │ │ │ │ ├── GxsTransferGroupItem.java │ │ │ │ ├── GxsTransferMessageItem.java │ │ │ │ ├── RequestType.java │ │ │ │ └── TransactionFlags.java │ │ │ ├── gxstunnel/ │ │ │ │ ├── DestinationHash.java │ │ │ │ ├── GxsTunnelRsClient.java │ │ │ │ ├── GxsTunnelRsService.java │ │ │ │ ├── GxsTunnelStatus.java │ │ │ │ ├── TunnelDhInfo.java │ │ │ │ ├── TunnelPeerInfo.java │ │ │ │ ├── VirtualLocation.java │ │ │ │ └── item/ │ │ │ │ ├── GxsTunnelDataAckItem.java │ │ │ │ ├── GxsTunnelDataItem.java │ │ │ │ ├── GxsTunnelDhPublicKeyItem.java │ │ │ │ ├── GxsTunnelItem.java │ │ │ │ └── GxsTunnelStatusItem.java │ │ │ ├── heartbeat/ │ │ │ │ ├── HeartbeatRsService.java │ │ │ │ └── item/ │ │ │ │ └── HeartbeatItem.java │ │ │ ├── identity/ │ │ │ │ ├── IdentityManager.java │ │ │ │ ├── IdentityReputation.java │ │ │ │ ├── IdentityRsService.java │ │ │ │ ├── ValidationResult.java │ │ │ │ ├── ValidationState.java │ │ │ │ └── item/ │ │ │ │ └── IdentityGroupItem.java │ │ │ ├── rtt/ │ │ │ │ ├── RttRsService.java │ │ │ │ └── item/ │ │ │ │ ├── RttPingItem.java │ │ │ │ └── RttPongItem.java │ │ │ ├── serviceinfo/ │ │ │ │ ├── ServiceInfoRsService.java │ │ │ │ └── item/ │ │ │ │ ├── ServiceInfo.java │ │ │ │ └── ServiceListItem.java │ │ │ ├── sliceprobe/ │ │ │ │ ├── SliceProbeRsService.java │ │ │ │ └── item/ │ │ │ │ └── SliceProbeItem.java │ │ │ ├── status/ │ │ │ │ ├── ChatStatus.java │ │ │ │ ├── GetIdleTime.java │ │ │ │ ├── IdleChecker.java │ │ │ │ ├── StatusRsService.java │ │ │ │ ├── idletimer/ │ │ │ │ │ ├── GetIdleTimeGeneric.java │ │ │ │ │ ├── GetIdleTimeLinux.java │ │ │ │ │ ├── GetIdleTimeMac.java │ │ │ │ │ └── GetIdleTimeWindows.java │ │ │ │ └── item/ │ │ │ │ └── StatusItem.java │ │ │ ├── turtle/ │ │ │ │ ├── HashInfo.java │ │ │ │ ├── SearchRequest.java │ │ │ │ ├── Tunnel.java │ │ │ │ ├── TunnelProbability.java │ │ │ │ ├── TunnelRequest.java │ │ │ │ ├── TurtleRouter.java │ │ │ │ ├── TurtleRsClient.java │ │ │ │ ├── TurtleRsService.java │ │ │ │ ├── TurtleStatistics.java │ │ │ │ ├── VirtualLocation.java │ │ │ │ ├── doc-files/ │ │ │ │ │ └── search.puml │ │ │ │ └── item/ │ │ │ │ ├── TunnelDirection.java │ │ │ │ ├── TurtleFileInfo.java │ │ │ │ ├── TurtleFileSearchRequestItem.java │ │ │ │ ├── TurtleFileSearchResultItem.java │ │ │ │ ├── TurtleGenericDataItem.java │ │ │ │ ├── TurtleGenericFastDataItem.java │ │ │ │ ├── TurtleGenericSearchRequestItem.java │ │ │ │ ├── TurtleGenericSearchResultItem.java │ │ │ │ ├── TurtleGenericTunnelItem.java │ │ │ │ ├── TurtleRegExpSearchRequestItem.java │ │ │ │ ├── TurtleSearchRequestItem.java │ │ │ │ ├── TurtleSearchResultItem.java │ │ │ │ ├── TurtleStringSearchRequestItem.java │ │ │ │ ├── TurtleTunnelRequestItem.java │ │ │ │ └── TurtleTunnelResultItem.java │ │ │ └── voip/ │ │ │ ├── LockBasedSingleEntrySupplier.java │ │ │ ├── VoipRsService.java │ │ │ └── item/ │ │ │ ├── VoipDataItem.java │ │ │ ├── VoipPingItem.java │ │ │ ├── VoipPongItem.java │ │ │ └── VoipProtocolItem.java │ │ ├── javadoc/ │ │ │ └── overview.html │ │ └── resources/ │ │ ├── GeoLite2-Country.mmdb │ │ ├── LICENSE │ │ ├── META-INF/ │ │ │ └── additional-spring-configuration-metadata.json │ │ ├── application-cloud.properties │ │ ├── application-dev.properties │ │ ├── application.properties │ │ ├── banner.txt │ │ ├── bdboot.txt │ │ ├── db/ │ │ │ └── migration/ │ │ │ ├── V00_0_10_202407122208__AlterFileDownloadCompleted.sql │ │ │ ├── V00_0_11_202408021538__AddEncryptedHashes.sql │ │ │ ├── V00_0_12_202408021849__AddEncryptedHashIndex.sql │ │ │ ├── V00_0_13_202408121618__AddLocationToFileDownload.sql │ │ │ ├── V00_0_14_202408221303__AddAvailabilityToLocation.sql │ │ │ ├── V00_0_15_202409220053__AddChatBacklog.sql │ │ │ ├── V00_0_16_202410061715__AddProfileValidation.sql │ │ │ ├── V00_0_17_202410112205__AddProfileCreation.sql │ │ │ ├── V00_0_18_202410201950__AddLocationVersion.sql │ │ │ ├── V00_0_19_202411171309__AddExtendedFingerprint.sql │ │ │ ├── V00_0_1_202001232214__InitDb.sql │ │ │ ├── V00_0_20_202411212150__AlterShareLastScanned.sql │ │ │ ├── V00_0_21_202412142109__AddRemoteOptions.sql │ │ │ ├── V00_0_22_202412211327__AddRemotePort.sql │ │ │ ├── V00_0_23_202412242306__AddChatRoomLocations.sql │ │ │ ├── V00_0_24_202502252128__AddDistantChatBacklog.sql │ │ │ ├── V00_0_25_202504051643__AcceptNullNamedLocations.sql │ │ │ ├── V00_0_26_202504152033__AdjustBacklogMessageSizes.sql │ │ │ ├── V00_0_27_202511240013__AddBoards.sql │ │ │ ├── V00_0_28_202511281815__AddChannels.sql │ │ │ ├── V00_0_29_202512212323__FixGxsSizeLimits.sql │ │ │ ├── V00_0_2_202312151830__AddIncomingDirectory.sql │ │ │ ├── V00_0_30_202602161830__ImproveGxsGroupsAndMessage.sql │ │ │ ├── V00_0_31_202602121929__AddLastActivity.sql │ │ │ ├── V00_0_32_202603092327__AddIndices.sql │ │ │ ├── V00_0_33_202604260021__FixVotes.sql │ │ │ ├── V00_0_3_202401151840__AddSharesAndFiles.sql │ │ │ ├── V00_0_4_202402211850__AlterTimestampPrecision.sql │ │ │ ├── V00_0_5_202405122038__AddSizeToFiles.sql │ │ │ ├── V00_0_6_202405242209__AddNewFileEnumTypes.sql │ │ │ ├── V00_0_7_202406181840__AddFileDownload.sql │ │ │ ├── V00_0_8_202406191850__AddRemotePassword.sql │ │ │ └── V00_0_9_202406201855__AddSettingsVersion.sql │ │ ├── public/ │ │ │ └── index.html │ │ └── public.asc │ └── test/ │ ├── java/ │ │ └── io/ │ │ └── xeres/ │ │ ├── app/ │ │ │ ├── ApiTest.java │ │ │ ├── AppCodingRulesTest.java │ │ │ ├── api/ │ │ │ │ └── controller/ │ │ │ │ ├── AbstractControllerTest.java │ │ │ │ ├── PathConfigTest.java │ │ │ │ ├── board/ │ │ │ │ │ └── BoardControllerTest.java │ │ │ │ ├── channel/ │ │ │ │ │ └── ChannelControllerTest.java │ │ │ │ ├── chat/ │ │ │ │ │ ├── ChatControllerTest.java │ │ │ │ │ └── ChatMessageControllerTest.java │ │ │ │ ├── config/ │ │ │ │ │ └── ConfigControllerTest.java │ │ │ │ ├── connection/ │ │ │ │ │ └── ConnectionControllerTest.java │ │ │ │ ├── contact/ │ │ │ │ │ └── ContactControllerTest.java │ │ │ │ ├── file/ │ │ │ │ │ └── FileControllerTest.java │ │ │ │ ├── forum/ │ │ │ │ │ └── ForumControllerTest.java │ │ │ │ ├── geoip/ │ │ │ │ │ └── GeoIpControllerTest.java │ │ │ │ ├── identity/ │ │ │ │ │ └── IdentityControllerTest.java │ │ │ │ ├── location/ │ │ │ │ │ └── LocationControllerTest.java │ │ │ │ ├── notification/ │ │ │ │ │ └── NotificationControllerTest.java │ │ │ │ ├── profile/ │ │ │ │ │ └── ProfileControllerTest.java │ │ │ │ ├── settings/ │ │ │ │ │ └── SettingsControllerTest.java │ │ │ │ ├── share/ │ │ │ │ │ └── ShareControllerTest.java │ │ │ │ ├── statistics/ │ │ │ │ │ └── StatisticsControllerTest.java │ │ │ │ └── voip/ │ │ │ │ └── VoipMessageControllerTest.java │ │ │ ├── application/ │ │ │ │ ├── SingleInstanceRunTest.java │ │ │ │ ├── autostart/ │ │ │ │ │ ├── AutoStartTest.java │ │ │ │ │ └── autostarter/ │ │ │ │ │ └── AutoStarterGenericTest.java │ │ │ │ └── environment/ │ │ │ │ └── DefaultPropertiesTest.java │ │ │ ├── configuration/ │ │ │ │ └── DataDirConfigurationTest.java │ │ │ ├── crypto/ │ │ │ │ ├── aead/ │ │ │ │ │ └── AEADTest.java │ │ │ │ ├── aes/ │ │ │ │ │ └── AESTest.java │ │ │ │ ├── chatcipher/ │ │ │ │ │ └── ChatChallengeTest.java │ │ │ │ ├── dh/ │ │ │ │ │ └── DiffieHellmanTest.java │ │ │ │ ├── ec/ │ │ │ │ │ └── Ed25519Test.java │ │ │ │ ├── hmac/ │ │ │ │ │ ├── sha1/ │ │ │ │ │ │ └── Sha1HMacTest.java │ │ │ │ │ └── sha256/ │ │ │ │ │ └── Sha256HMacTest.java │ │ │ │ ├── pgp/ │ │ │ │ │ └── PGPTest.java │ │ │ │ ├── rsa/ │ │ │ │ │ └── RSATest.java │ │ │ │ ├── rscrypto/ │ │ │ │ │ └── RsCryptoTest.java │ │ │ │ ├── rsid/ │ │ │ │ │ ├── RSCertificateTest.java │ │ │ │ │ ├── RSIdArmorTest.java │ │ │ │ │ ├── RSIdCrcTest.java │ │ │ │ │ ├── RSIdFakes.java │ │ │ │ │ ├── RSSerialVersionTest.java │ │ │ │ │ └── RSShortInviteTest.java │ │ │ │ └── x509/ │ │ │ │ └── X509Test.java │ │ │ ├── database/ │ │ │ │ ├── model/ │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── ChatMapperTest.java │ │ │ │ │ │ └── ChatRoomFakes.java │ │ │ │ │ ├── connection/ │ │ │ │ │ │ ├── ConnectionFakes.java │ │ │ │ │ │ ├── ConnectionMapperTest.java │ │ │ │ │ │ └── ConnectionTest.java │ │ │ │ │ ├── file/ │ │ │ │ │ │ └── FileFakes.java │ │ │ │ │ ├── gxs/ │ │ │ │ │ │ ├── BoardGroupItemFakes.java │ │ │ │ │ │ ├── BoardMessageItemFakes.java │ │ │ │ │ │ ├── ChannelGroupItemFakes.java │ │ │ │ │ │ ├── ChannelMessageItemFakes.java │ │ │ │ │ │ ├── ForumGroupItemFakes.java │ │ │ │ │ │ ├── ForumMessageItemFakes.java │ │ │ │ │ │ ├── ForumMessageItemSummaryFake.java │ │ │ │ │ │ ├── GxsCircleTypeTest.java │ │ │ │ │ │ ├── GxsClientUpdateFakes.java │ │ │ │ │ │ ├── GxsPrivacyFlagsTest.java │ │ │ │ │ │ ├── GxsServiceSettingFakes.java │ │ │ │ │ │ ├── GxsSignatureFlagsTest.java │ │ │ │ │ │ └── IdentityGroupItemFakes.java │ │ │ │ │ ├── identity/ │ │ │ │ │ │ ├── IdentityFakes.java │ │ │ │ │ │ └── IdentityMapperTest.java │ │ │ │ │ ├── location/ │ │ │ │ │ │ ├── LocationFakes.java │ │ │ │ │ │ └── LocationMapperTest.java │ │ │ │ │ ├── profile/ │ │ │ │ │ │ ├── ProfileFakes.java │ │ │ │ │ │ └── ProfileMapperTest.java │ │ │ │ │ ├── settings/ │ │ │ │ │ │ └── SettingsFakes.java │ │ │ │ │ └── share/ │ │ │ │ │ └── ShareFakes.java │ │ │ │ └── repository/ │ │ │ │ ├── ChatRoomRepositoryTest.java │ │ │ │ ├── FileRepositoryTest.java │ │ │ │ ├── GxsClientUpdateRepositoryTest.java │ │ │ │ ├── GxsIdentityRepositoryTest.java │ │ │ │ ├── GxsServiceSettingRepositoryTest.java │ │ │ │ ├── LocationRepositoryTest.java │ │ │ │ ├── ProfileRepositoryTest.java │ │ │ │ └── SettingsRepositoryTest.java │ │ │ ├── environment/ │ │ │ │ ├── CloudTest.java │ │ │ │ ├── CommandArgumentTest.java │ │ │ │ └── HostVariableTest.java │ │ │ ├── job/ │ │ │ │ ├── IdleDetectionJobTest.java │ │ │ │ └── PeerConnectionJobTest.java │ │ │ ├── net/ │ │ │ │ ├── bdisc/ │ │ │ │ │ ├── BroadcastDiscoveryServiceTest.java │ │ │ │ │ └── UdpDiscoveryProtocolTest.java │ │ │ │ ├── dht/ │ │ │ │ │ └── NodeIdTest.java │ │ │ │ ├── peer/ │ │ │ │ │ ├── AbstractPipelineTest.java │ │ │ │ │ ├── ChannelFake.java │ │ │ │ │ ├── ChannelHandlerContextFake.java │ │ │ │ │ ├── PacketDecoderPipelineTest.java │ │ │ │ │ ├── PacketEncoderPipelineTest.java │ │ │ │ │ ├── PeerAttributeTest.java │ │ │ │ │ ├── PeerConnectionFakes.java │ │ │ │ │ ├── PeerConnectionManagerTest.java │ │ │ │ │ ├── RawItemDecoderPipelineTest.java │ │ │ │ │ ├── packet/ │ │ │ │ │ │ ├── MultiPacketBuilder.java │ │ │ │ │ │ ├── PacketTest.java │ │ │ │ │ │ └── SimplePacketBuilder.java │ │ │ │ │ └── ssl/ │ │ │ │ │ └── SSLTest.java │ │ │ │ ├── protocol/ │ │ │ │ │ ├── PeerAddressTest.java │ │ │ │ │ ├── i2p/ │ │ │ │ │ │ └── I2pAddressTest.java │ │ │ │ │ └── tor/ │ │ │ │ │ └── OnionAddressTest.java │ │ │ │ ├── upnp/ │ │ │ │ │ ├── ControlPointTest.java │ │ │ │ │ ├── DeviceTest.java │ │ │ │ │ ├── PortMappingTest.java │ │ │ │ │ ├── SoapTest.java │ │ │ │ │ └── UPNPServiceTest.java │ │ │ │ └── util/ │ │ │ │ └── NetworkModeTest.java │ │ │ ├── service/ │ │ │ │ ├── CapabilityServiceTest.java │ │ │ │ ├── ContactServiceTest.java │ │ │ │ ├── ForumMessageServiceTest.java │ │ │ │ ├── GeoIpServiceTest.java │ │ │ │ ├── LocationServiceTest.java │ │ │ │ ├── ProfileServiceTest.java │ │ │ │ ├── QrCodeServiceTest.java │ │ │ │ ├── ServiceRulesTest.java │ │ │ │ ├── SettingsServiceTest.java │ │ │ │ ├── UnHtmlServiceTest.java │ │ │ │ ├── file/ │ │ │ │ │ └── FileServiceTest.java │ │ │ │ └── shell/ │ │ │ │ ├── HistoryTest.java │ │ │ │ └── ShellServiceTest.java │ │ │ ├── util/ │ │ │ │ ├── OsUtilsTest.java │ │ │ │ └── expression/ │ │ │ │ ├── ExpressionCriteriaTest.java │ │ │ │ ├── ExpressionMapperTest.java │ │ │ │ └── ExpressionTest.java │ │ │ └── xrs/ │ │ │ ├── common/ │ │ │ │ └── SecurityKeyTest.java │ │ │ ├── item/ │ │ │ │ ├── ItemHeaderTest.java │ │ │ │ ├── ItemPriorityTest.java │ │ │ │ └── ItemTest.java │ │ │ ├── serialization/ │ │ │ │ ├── SerialAll.java │ │ │ │ ├── SerialEnum.java │ │ │ │ ├── SerialList.java │ │ │ │ ├── SerialMap.java │ │ │ │ ├── SerializerTest.java │ │ │ │ ├── TlvImageSerializerTest.java │ │ │ │ └── TlvUtilsTest.java │ │ │ └── service/ │ │ │ ├── RsServiceInitPriorityTest.java │ │ │ ├── RsServiceRulesTest.java │ │ │ ├── bandwidth/ │ │ │ │ └── BandwidthUtilsTest.java │ │ │ ├── chat/ │ │ │ │ ├── ChatFlagsTest.java │ │ │ │ ├── ChatRoomEventTest.java │ │ │ │ ├── ChatRoomServiceTest.java │ │ │ │ ├── ChatRsServiceTest.java │ │ │ │ └── RoomFlagsTest.java │ │ │ ├── discovery/ │ │ │ │ ├── DiscoveryPgpListItemTest.java │ │ │ │ └── DiscoveryRsServiceTest.java │ │ │ ├── filetransfer/ │ │ │ │ ├── ChunkDistributorTest.java │ │ │ │ ├── ChunkMapUtilsTest.java │ │ │ │ ├── ChunkTest.java │ │ │ │ ├── FileDownloadTest.java │ │ │ │ ├── FileTransferAgentTest.java │ │ │ │ └── FileUploadTest.java │ │ │ ├── gxs/ │ │ │ │ ├── GxsRequestTypeTest.java │ │ │ │ ├── GxsSignatureTest.java │ │ │ │ ├── TransactionFlagsTest.java │ │ │ │ ├── TransactionTest.java │ │ │ │ └── item/ │ │ │ │ └── GxsSyncMessageRequestItemTest.java │ │ │ ├── gxstunnel/ │ │ │ │ └── TunnelPeerInfoTest.java │ │ │ ├── heartbeat/ │ │ │ │ └── HeartbeatTest.java │ │ │ ├── identity/ │ │ │ │ ├── IdentityManagerTest.java │ │ │ │ └── IdentityRsServiceTest.java │ │ │ ├── rtt/ │ │ │ │ └── RttRsServiceTest.java │ │ │ ├── status/ │ │ │ │ ├── IdleCheckerTest.java │ │ │ │ ├── StatusRsServiceTest.java │ │ │ │ └── StatusTest.java │ │ │ └── turtle/ │ │ │ ├── HashBloomFilterTest.java │ │ │ └── TurtleRsServiceTest.java │ │ └── testutils/ │ │ ├── FakeHttpServer.java │ │ └── ResourceUtils.java │ └── resources/ │ ├── application-default.properties │ └── upnp/ │ └── routers/ │ └── RT-AC87U.xml ├── build.gradle ├── common/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/ │ │ │ └── xeres/ │ │ │ └── common/ │ │ │ ├── AppName.java │ │ │ ├── Features.java │ │ │ ├── annotation/ │ │ │ │ └── RsDeprecated.java │ │ │ ├── condition/ │ │ │ │ ├── OnLinuxCondition.java │ │ │ │ ├── OnMacCondition.java │ │ │ │ └── OnWindowsCondition.java │ │ │ ├── dto/ │ │ │ │ ├── board/ │ │ │ │ │ ├── BoardGroupDTO.java │ │ │ │ │ └── BoardMessageDTO.java │ │ │ │ ├── channel/ │ │ │ │ │ ├── ChannelFileDTO.java │ │ │ │ │ ├── ChannelGroupDTO.java │ │ │ │ │ └── ChannelMessageDTO.java │ │ │ │ ├── chat/ │ │ │ │ │ ├── ChatBacklogDTO.java │ │ │ │ │ ├── ChatIdentityDTO.java │ │ │ │ │ ├── ChatRoomBacklogDTO.java │ │ │ │ │ ├── ChatRoomContextDTO.java │ │ │ │ │ ├── ChatRoomDTO.java │ │ │ │ │ └── ChatRoomsDTO.java │ │ │ │ ├── connection/ │ │ │ │ │ └── ConnectionDTO.java │ │ │ │ ├── forum/ │ │ │ │ │ ├── ForumGroupDTO.java │ │ │ │ │ └── ForumMessageDTO.java │ │ │ │ ├── identity/ │ │ │ │ │ ├── IdentityConstants.java │ │ │ │ │ └── IdentityDTO.java │ │ │ │ ├── location/ │ │ │ │ │ ├── LocationConstants.java │ │ │ │ │ └── LocationDTO.java │ │ │ │ ├── profile/ │ │ │ │ │ ├── ProfileConstants.java │ │ │ │ │ └── ProfileDTO.java │ │ │ │ ├── settings/ │ │ │ │ │ └── SettingsDTO.java │ │ │ │ └── share/ │ │ │ │ ├── ShareConstants.java │ │ │ │ └── ShareDTO.java │ │ │ ├── events/ │ │ │ │ ├── ConnectWebSocketsEvent.java │ │ │ │ ├── StartupEvent.java │ │ │ │ └── SynchronousEvent.java │ │ │ ├── file/ │ │ │ │ └── FileType.java │ │ │ ├── geoip/ │ │ │ │ └── Country.java │ │ │ ├── gxs/ │ │ │ │ └── GxsGroupConstants.java │ │ │ ├── i18n/ │ │ │ │ ├── I18nEnum.java │ │ │ │ └── I18nUtils.java │ │ │ ├── id/ │ │ │ │ ├── GxsId.java │ │ │ │ ├── Id.java │ │ │ │ ├── Identifier.java │ │ │ │ ├── LocationIdentifier.java │ │ │ │ ├── MsgId.java │ │ │ │ ├── ProfileFingerprint.java │ │ │ │ └── Sha1Sum.java │ │ │ ├── identity/ │ │ │ │ └── Type.java │ │ │ ├── location/ │ │ │ │ └── Availability.java │ │ │ ├── message/ │ │ │ │ ├── MessageHeaders.java │ │ │ │ ├── MessagePath.java │ │ │ │ ├── MessageType.java │ │ │ │ ├── MessagingConfiguration.java │ │ │ │ ├── chat/ │ │ │ │ │ ├── ChatAvatar.java │ │ │ │ │ ├── ChatBacklog.java │ │ │ │ │ ├── ChatConstants.java │ │ │ │ │ ├── ChatMessage.java │ │ │ │ │ ├── ChatRoomBacklog.java │ │ │ │ │ ├── ChatRoomContext.java │ │ │ │ │ ├── ChatRoomInfo.java │ │ │ │ │ ├── ChatRoomInviteEvent.java │ │ │ │ │ ├── ChatRoomLists.java │ │ │ │ │ ├── ChatRoomMessage.java │ │ │ │ │ ├── ChatRoomTimeoutEvent.java │ │ │ │ │ ├── ChatRoomUser.java │ │ │ │ │ ├── ChatRoomUserEvent.java │ │ │ │ │ └── RoomType.java │ │ │ │ └── voip/ │ │ │ │ ├── VoipAction.java │ │ │ │ └── VoipMessage.java │ │ │ ├── mui/ │ │ │ │ ├── MUI.java │ │ │ │ ├── MUIScrollBar.java │ │ │ │ ├── Shell.java │ │ │ │ ├── ShellAction.java │ │ │ │ └── ShellResult.java │ │ │ ├── pgp/ │ │ │ │ └── Trust.java │ │ │ ├── properties/ │ │ │ │ └── StartupProperties.java │ │ │ ├── protocol/ │ │ │ │ ├── HostPort.java │ │ │ │ ├── NetMode.java │ │ │ │ ├── dns/ │ │ │ │ │ ├── DNS.java │ │ │ │ │ ├── DnsRequest.java │ │ │ │ │ └── DnsResponse.java │ │ │ │ ├── i2p/ │ │ │ │ │ └── I2pAddress.java │ │ │ │ ├── ip/ │ │ │ │ │ ├── IP.java │ │ │ │ │ └── package-info.java │ │ │ │ ├── tor/ │ │ │ │ │ ├── OnionAddress.java │ │ │ │ │ └── package-info.java │ │ │ │ └── xrs/ │ │ │ │ └── RsServiceType.java │ │ │ ├── rest/ │ │ │ │ ├── PathConfig.java │ │ │ │ ├── board/ │ │ │ │ │ └── UpdateBoardMessageReadRequest.java │ │ │ │ ├── channel/ │ │ │ │ │ └── UpdateChannelMessageReadRequest.java │ │ │ │ ├── chat/ │ │ │ │ │ ├── ChatRoomVisibility.java │ │ │ │ │ ├── CreateChatRoomRequest.java │ │ │ │ │ ├── DistantChatRequest.java │ │ │ │ │ └── InviteToChatRoomRequest.java │ │ │ │ ├── config/ │ │ │ │ │ ├── Capabilities.java │ │ │ │ │ ├── HostnameResponse.java │ │ │ │ │ ├── ImportRsFriendsResponse.java │ │ │ │ │ ├── IpAddressResponse.java │ │ │ │ │ ├── OwnIdentityRequest.java │ │ │ │ │ ├── OwnLocationRequest.java │ │ │ │ │ ├── OwnProfileRequest.java │ │ │ │ │ ├── UsernameResponse.java │ │ │ │ │ └── VerifyUpdateRequest.java │ │ │ │ ├── connection/ │ │ │ │ │ └── ConnectionRequest.java │ │ │ │ ├── contact/ │ │ │ │ │ └── Contact.java │ │ │ │ ├── file/ │ │ │ │ │ ├── AddDownloadRequest.java │ │ │ │ │ ├── FileDownloadRequest.java │ │ │ │ │ ├── FileProgress.java │ │ │ │ │ ├── FileSearchRequest.java │ │ │ │ │ └── FileSearchResponse.java │ │ │ │ ├── forum/ │ │ │ │ │ ├── CreateForumMessageRequest.java │ │ │ │ │ ├── CreateOrUpdateForumGroupRequest.java │ │ │ │ │ ├── ForumPostRequest.java │ │ │ │ │ └── UpdateForumMessageReadRequest.java │ │ │ │ ├── geoip/ │ │ │ │ │ └── CountryResponse.java │ │ │ │ ├── location/ │ │ │ │ │ └── RSIdResponse.java │ │ │ │ ├── notification/ │ │ │ │ │ ├── Notification.java │ │ │ │ │ ├── availability/ │ │ │ │ │ │ ├── AvailabilityChange.java │ │ │ │ │ │ └── AvailabilityNotification.java │ │ │ │ │ ├── board/ │ │ │ │ │ │ ├── AddOrUpdateBoardGroups.java │ │ │ │ │ │ ├── AddOrUpdateBoardMessages.java │ │ │ │ │ │ ├── BoardNotification.java │ │ │ │ │ │ ├── SetBoardGroupMessagesReadState.java │ │ │ │ │ │ └── SetBoardMessageReadState.java │ │ │ │ │ ├── channel/ │ │ │ │ │ │ ├── AddOrUpdateChannelGroups.java │ │ │ │ │ │ ├── AddOrUpdateChannelMessages.java │ │ │ │ │ │ ├── ChannelNotification.java │ │ │ │ │ │ ├── SetChannelGroupMessagesReadState.java │ │ │ │ │ │ └── SetChannelMessageReadState.java │ │ │ │ │ ├── contact/ │ │ │ │ │ │ ├── AddOrUpdateContacts.java │ │ │ │ │ │ ├── ContactNotification.java │ │ │ │ │ │ └── RemoveContacts.java │ │ │ │ │ ├── file/ │ │ │ │ │ │ ├── FileNotification.java │ │ │ │ │ │ ├── FileNotificationAction.java │ │ │ │ │ │ ├── FileSearchNotification.java │ │ │ │ │ │ └── FileTrendNotification.java │ │ │ │ │ ├── forum/ │ │ │ │ │ │ ├── AddOrUpdateForumGroups.java │ │ │ │ │ │ ├── AddOrUpdateForumMessages.java │ │ │ │ │ │ ├── ForumNotification.java │ │ │ │ │ │ ├── SetForumGroupMessagesReadState.java │ │ │ │ │ │ └── SetForumMessageReadState.java │ │ │ │ │ └── status/ │ │ │ │ │ ├── DhtInfo.java │ │ │ │ │ ├── DhtStatus.java │ │ │ │ │ ├── NatStatus.java │ │ │ │ │ └── StatusNotification.java │ │ │ │ ├── profile/ │ │ │ │ │ ├── ProfileKeyAttributes.java │ │ │ │ │ └── RsIdRequest.java │ │ │ │ ├── share/ │ │ │ │ │ ├── TemporaryShareRequest.java │ │ │ │ │ ├── TemporaryShareResponse.java │ │ │ │ │ └── UpdateShareRequest.java │ │ │ │ └── statistics/ │ │ │ │ ├── DataCounterPeer.java │ │ │ │ ├── DataCounterStatisticsResponse.java │ │ │ │ ├── RttPeer.java │ │ │ │ ├── RttStatisticsResponse.java │ │ │ │ └── TurtleStatisticsResponse.java │ │ │ ├── rsid/ │ │ │ │ └── Type.java │ │ │ ├── tray/ │ │ │ │ └── TrayNotificationType.java │ │ │ └── util/ │ │ │ ├── ByteUnitUtils.java │ │ │ ├── DebugUtils.java │ │ │ ├── ExecutorUtils.java │ │ │ ├── FileNameUtils.java │ │ │ ├── NoSuppressedRunnable.java │ │ │ ├── OsUtils.java │ │ │ ├── RemoteUtils.java │ │ │ ├── SecureRandomUtils.java │ │ │ ├── ThreadUtils.java │ │ │ └── image/ │ │ │ ├── ImageUtils.java │ │ │ ├── JpegUtils.java │ │ │ └── PngUtils.java │ │ ├── javadoc/ │ │ │ └── overview.html │ │ └── resources/ │ │ └── i18n/ │ │ ├── messages.properties │ │ ├── messages_es.properties │ │ ├── messages_fr.properties │ │ ├── messages_ru.properties │ │ └── messages_zh.properties │ ├── test/ │ │ └── java/ │ │ └── io/ │ │ └── xeres/ │ │ └── common/ │ │ ├── AppNameTest.java │ │ ├── CommonCodingRulesTest.java │ │ ├── file/ │ │ │ └── FileTypeTest.java │ │ ├── id/ │ │ │ └── IdTest.java │ │ ├── identity/ │ │ │ └── TypeTest.java │ │ ├── pgp/ │ │ │ └── TrustTest.java │ │ ├── protocol/ │ │ │ ├── HostPortTest.java │ │ │ ├── NetModeTest.java │ │ │ ├── dns/ │ │ │ │ └── DNSTest.java │ │ │ └── ip/ │ │ │ └── IPTest.java │ │ ├── rest/ │ │ │ └── notification/ │ │ │ └── StatusNotificationTest.java │ │ └── util/ │ │ ├── ByteUnitUtilsTest.java │ │ ├── FileNameUtilsTest.java │ │ ├── SecureRandomUtilsTest.java │ │ └── image/ │ │ └── ImageUtilsTest.java │ └── testFixtures/ │ └── java/ │ └── io/ │ └── xeres/ │ ├── common/ │ │ └── dto/ │ │ ├── chat/ │ │ │ ├── ChatIdentityDTOFakes.java │ │ │ ├── ChatRoomContextDTOFakes.java │ │ │ ├── ChatRoomDTOFakes.java │ │ │ └── ChatRoomsDTOFakes.java │ │ ├── connection/ │ │ │ └── ConnectionDTOFakes.java │ │ ├── identity/ │ │ │ └── IdentityDTOFakes.java │ │ ├── location/ │ │ │ └── LocationDTOFakes.java │ │ ├── profile/ │ │ │ └── ProfileDTOFakes.java │ │ ├── settings/ │ │ │ └── SettingsDTOFakes.java │ │ └── share/ │ │ └── ShareDTOFakes.java │ └── testutils/ │ ├── BooleanFakes.java │ ├── EnumFakes.java │ ├── IdFakes.java │ ├── Sha1SumFakes.java │ ├── StringFakes.java │ ├── TestUtils.java │ └── TimeFakes.java ├── docker-compose.yml ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── icon.icns ├── qodana.yaml ├── scripts/ │ ├── api/ │ │ └── user.js │ ├── bot/ │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── bot.py │ │ └── requirements.txt │ └── helper/ │ └── i18n_find_dupe.py ├── settings.gradle ├── transifex.yml └── ui/ ├── build.gradle └── src/ ├── main/ │ ├── java/ │ │ └── io/ │ │ └── xeres/ │ │ └── ui/ │ │ ├── JavaFxApplication.java │ │ ├── PrimaryStageInitializer.java │ │ ├── UiStarter.java │ │ ├── client/ │ │ │ ├── BoardClient.java │ │ │ ├── ChannelClient.java │ │ │ ├── ChatClient.java │ │ │ ├── ConfigClient.java │ │ │ ├── ConnectionClient.java │ │ │ ├── ContactClient.java │ │ │ ├── FileClient.java │ │ │ ├── ForumClient.java │ │ │ ├── GeneralClient.java │ │ │ ├── GeoIpClient.java │ │ │ ├── GxsGroupClient.java │ │ │ ├── GxsMessageClient.java │ │ │ ├── IdentityClient.java │ │ │ ├── LocationClient.java │ │ │ ├── NotificationClient.java │ │ │ ├── PaginatedResponse.java │ │ │ ├── ProfileClient.java │ │ │ ├── SettingsClient.java │ │ │ ├── ShareClient.java │ │ │ ├── StatisticsClient.java │ │ │ ├── message/ │ │ │ │ ├── BroadcastChatFrameHandler.java │ │ │ │ ├── ChatRoomFrameHandler.java │ │ │ │ ├── DistantChatFrameHandler.java │ │ │ │ ├── MessageClient.java │ │ │ │ ├── PendingSubscription.java │ │ │ │ ├── PrivateChatFrameHandler.java │ │ │ │ ├── SessionHandler.java │ │ │ │ └── VoipFrameHandler.java │ │ │ ├── preview/ │ │ │ │ ├── OEmbedResponse.java │ │ │ │ ├── PreviewClient.java │ │ │ │ ├── PreviewResponse.java │ │ │ │ └── SizeLimitingCollector.java │ │ │ └── update/ │ │ │ ├── ReleaseAsset.java │ │ │ ├── ReleaseResponse.java │ │ │ ├── UpdateClient.java │ │ │ └── UpdateProgress.java │ │ ├── configuration/ │ │ │ ├── I18nConfiguration.java │ │ │ └── WebClientConfiguration.java │ │ ├── controller/ │ │ │ ├── Controller.java │ │ │ ├── MainWindowController.java │ │ │ ├── TabActivation.java │ │ │ ├── WindowController.java │ │ │ ├── about/ │ │ │ │ └── AboutWindowController.java │ │ │ ├── account/ │ │ │ │ └── AccountCreationWindowController.java │ │ │ ├── board/ │ │ │ │ ├── BoardGroupCell.java │ │ │ │ ├── BoardGroupWindowController.java │ │ │ │ ├── BoardMessageCell.java │ │ │ │ ├── BoardMessageWindowController.java │ │ │ │ └── BoardViewController.java │ │ │ ├── channel/ │ │ │ │ ├── ChannelFileSizeCell.java │ │ │ │ ├── ChannelGroupCell.java │ │ │ │ ├── ChannelGroupWindowController.java │ │ │ │ ├── ChannelMessageCell.java │ │ │ │ ├── ChannelMessageRow.java │ │ │ │ ├── ChannelMessageWindowController.java │ │ │ │ └── ChannelViewController.java │ │ │ ├── chat/ │ │ │ │ ├── ChatListCell.java │ │ │ │ ├── ChatListDragSelection.java │ │ │ │ ├── ChatListView.java │ │ │ │ ├── ChatListViewContextMenu.java │ │ │ │ ├── ChatRoomCell.java │ │ │ │ ├── ChatRoomCreationWindowController.java │ │ │ │ ├── ChatRoomInfoController.java │ │ │ │ ├── ChatRoomInvitationWindowController.java │ │ │ │ ├── ChatRoomUser.java │ │ │ │ ├── ChatUserCell.java │ │ │ │ ├── ChatViewController.java │ │ │ │ ├── PeerHolder.java │ │ │ │ └── RoomHolder.java │ │ │ ├── common/ │ │ │ │ ├── GxsGroup.java │ │ │ │ ├── GxsGroupCellCount.java │ │ │ │ ├── GxsGroupTreeTableAction.java │ │ │ │ ├── GxsGroupTreeTableView.java │ │ │ │ └── GxsMessage.java │ │ │ ├── contact/ │ │ │ │ ├── AvailabilityCellStatus.java │ │ │ │ ├── AvailabilityCellUtil.java │ │ │ │ ├── AvailabilityTreeCellStatus.java │ │ │ │ ├── ContactCellName.java │ │ │ │ ├── ContactFilter.java │ │ │ │ ├── ContactViewController.java │ │ │ │ └── LocationRow.java │ │ │ ├── debug/ │ │ │ │ └── DebugRequesterWindowController.java │ │ │ ├── file/ │ │ │ │ ├── FileAddDownloadViewWindowController.java │ │ │ │ ├── FileDownloadViewController.java │ │ │ │ ├── FileMainController.java │ │ │ │ ├── FileProgressDisplay.java │ │ │ │ ├── FileProgressSizeCell.java │ │ │ │ ├── FileResult.java │ │ │ │ ├── FileResultNameCell.java │ │ │ │ ├── FileResultSizeCell.java │ │ │ │ ├── FileResultView.java │ │ │ │ ├── FileSearchViewController.java │ │ │ │ ├── FileTrendViewController.java │ │ │ │ ├── FileUploadViewController.java │ │ │ │ ├── TimeCell.java │ │ │ │ └── TrendResult.java │ │ │ ├── forum/ │ │ │ │ ├── DateCell.java │ │ │ │ ├── ForumCell.java │ │ │ │ ├── ForumCellAuthor.java │ │ │ │ ├── ForumEditorWindowController.java │ │ │ │ ├── ForumGroupWindowController.java │ │ │ │ ├── ForumMessageCell.java │ │ │ │ ├── ForumViewController.java │ │ │ │ └── MessageVersion.java │ │ │ ├── help/ │ │ │ │ ├── HelpWindowController.java │ │ │ │ ├── IndexCell.java │ │ │ │ └── Navigator.java │ │ │ ├── id/ │ │ │ │ ├── AddRsIdWindowController.java │ │ │ │ ├── AddressCell.java │ │ │ │ ├── AddressConverter.java │ │ │ │ ├── AddressCountry.java │ │ │ │ └── FlagUtils.java │ │ │ ├── messaging/ │ │ │ │ ├── BroadcastWindowController.java │ │ │ │ ├── Destination.java │ │ │ │ └── MessagingWindowController.java │ │ │ ├── qrcode/ │ │ │ │ ├── CameraWindowController.java │ │ │ │ ├── QrCodeWindowController.java │ │ │ │ └── QrPrintController.java │ │ │ ├── settings/ │ │ │ │ ├── SettingsCell.java │ │ │ │ ├── SettingsController.java │ │ │ │ ├── SettingsGeneralController.java │ │ │ │ ├── SettingsGroup.java │ │ │ │ ├── SettingsNetworksController.java │ │ │ │ ├── SettingsNotificationController.java │ │ │ │ ├── SettingsRemoteController.java │ │ │ │ ├── SettingsSoundController.java │ │ │ │ ├── SettingsTransferController.java │ │ │ │ ├── SettingsWindowController.java │ │ │ │ └── ThemeCell.java │ │ │ ├── share/ │ │ │ │ ├── ShareWindowController.java │ │ │ │ └── TrustConverter.java │ │ │ ├── statistics/ │ │ │ │ ├── StatisticsDataCounterController.java │ │ │ │ ├── StatisticsMainWindowController.java │ │ │ │ ├── StatisticsRttController.java │ │ │ │ └── StatisticsTurtleController.java │ │ │ └── voip/ │ │ │ ├── TimeCounter.java │ │ │ └── VoipWindowController.java │ │ ├── custom/ │ │ │ ├── DelayedAction.java │ │ │ ├── DelayedTooltip.java │ │ │ ├── DisclosedHyperlink.java │ │ │ ├── EditorView.java │ │ │ ├── ImageSelectorView.java │ │ │ ├── InfoView.java │ │ │ ├── InputArea.java │ │ │ ├── InputAreaGroup.java │ │ │ ├── NullSelectionModel.java │ │ │ ├── ProgressPane.java │ │ │ ├── ReadOnlyTextField.java │ │ │ ├── ResizeableImageView.java │ │ │ ├── StickerView.java │ │ │ ├── TypingNotificationView.java │ │ │ ├── WaveDotsView.java │ │ │ ├── alias/ │ │ │ │ ├── AliasCell.java │ │ │ │ ├── AliasView.java │ │ │ │ └── PopupAlias.java │ │ │ ├── asyncimage/ │ │ │ │ ├── AsyncImageView.java │ │ │ │ ├── ContactImageView.java │ │ │ │ ├── ImageCache.java │ │ │ │ └── PlaceholderImageView.java │ │ │ ├── event/ │ │ │ │ ├── FileSelectedEvent.java │ │ │ │ ├── ImageSelectedEvent.java │ │ │ │ └── StickerSelectedEvent.java │ │ │ └── led/ │ │ │ ├── LedControl.java │ │ │ ├── LedSkin.java │ │ │ └── LedStatus.java │ │ ├── event/ │ │ │ ├── OpenUriEvent.java │ │ │ ├── StageReadyEvent.java │ │ │ └── UnreadEvent.java │ │ ├── model/ │ │ │ ├── board/ │ │ │ │ ├── BoardGroup.java │ │ │ │ ├── BoardMapper.java │ │ │ │ └── BoardMessage.java │ │ │ ├── channel/ │ │ │ │ ├── ChannelFile.java │ │ │ │ ├── ChannelGroup.java │ │ │ │ ├── ChannelMapper.java │ │ │ │ └── ChannelMessage.java │ │ │ ├── chat/ │ │ │ │ └── ChatMapper.java │ │ │ ├── connection/ │ │ │ │ ├── Connection.java │ │ │ │ └── ConnectionMapper.java │ │ │ ├── forum/ │ │ │ │ ├── ForumGroup.java │ │ │ │ ├── ForumMapper.java │ │ │ │ └── ForumMessage.java │ │ │ ├── identity/ │ │ │ │ ├── Identity.java │ │ │ │ └── IdentityMapper.java │ │ │ ├── location/ │ │ │ │ ├── Location.java │ │ │ │ └── LocationMapper.java │ │ │ ├── profile/ │ │ │ │ ├── Profile.java │ │ │ │ └── ProfileMapper.java │ │ │ ├── settings/ │ │ │ │ ├── Settings.java │ │ │ │ └── SettingsMapper.java │ │ │ └── share/ │ │ │ ├── Share.java │ │ │ └── ShareMapper.java │ │ ├── properties/ │ │ │ └── UiClientProperties.java │ │ └── support/ │ │ ├── ImageCacheService.java │ │ ├── chat/ │ │ │ ├── AliasEntry.java │ │ │ ├── ChatAction.java │ │ │ ├── ChatCommand.java │ │ │ ├── ChatLine.java │ │ │ ├── ChatParser.java │ │ │ ├── ColorGenerator.java │ │ │ └── NicknameCompleter.java │ │ ├── clipboard/ │ │ │ ├── ClipboardUtils.java │ │ │ └── ImageSelection.java │ │ ├── contact/ │ │ │ └── ContactUtils.java │ │ ├── contentline/ │ │ │ ├── Content.java │ │ │ ├── ContentCode.java │ │ │ ├── ContentEmoji.java │ │ │ ├── ContentEmphasis.java │ │ │ ├── ContentHeader.java │ │ │ ├── ContentHorizontalRule.java │ │ │ ├── ContentImage.java │ │ │ ├── ContentStrikethrough.java │ │ │ ├── ContentText.java │ │ │ ├── ContentUri.java │ │ │ └── ContentUriPreview.java │ │ ├── contextmenu/ │ │ │ └── XContextMenu.java │ │ ├── emoji/ │ │ │ ├── EmojiService.java │ │ │ └── RsEmojiAlias.java │ │ ├── loader/ │ │ │ ├── FetchMode.java │ │ │ ├── FetchRequest.java │ │ │ ├── InfiniteScrollable.java │ │ │ ├── InfiniteTreeListView.java │ │ │ ├── InfiniteVirtualizedScrollPane.java │ │ │ ├── OnDemandLoader.java │ │ │ └── OnDemandLoaderAction.java │ │ ├── markdown/ │ │ │ ├── AltTextVisitor.java │ │ │ ├── ContentRenderer.java │ │ │ ├── ContentVisitor.java │ │ │ ├── MarkdownService.java │ │ │ └── UriAction.java │ │ ├── notification/ │ │ │ └── NotificationSettings.java │ │ ├── oembed/ │ │ │ ├── OEmbedProvider.java │ │ │ └── OEmbedService.java │ │ ├── preference/ │ │ │ └── PreferenceUtils.java │ │ ├── sound/ │ │ │ ├── SoundPlayerService.java │ │ │ └── SoundSettings.java │ │ ├── splash/ │ │ │ └── SplashService.java │ │ ├── theme/ │ │ │ ├── AppTheme.java │ │ │ └── AppThemeManager.java │ │ ├── tray/ │ │ │ └── TrayService.java │ │ ├── unread/ │ │ │ └── UnreadService.java │ │ ├── updater/ │ │ │ ├── UpdateService.java │ │ │ ├── Version.java │ │ │ ├── VersionCheckTask.java │ │ │ └── VersionChecker.java │ │ ├── uri/ │ │ │ ├── AbstractUriFactory.java │ │ │ ├── BoardUri.java │ │ │ ├── BoardUriFactory.java │ │ │ ├── CertificateUri.java │ │ │ ├── CertificateUriFactory.java │ │ │ ├── ChannelUri.java │ │ │ ├── ChannelUriFactory.java │ │ │ ├── ChatRoomUri.java │ │ │ ├── ChatRoomUriFactory.java │ │ │ ├── CollectionUri.java │ │ │ ├── CollectionUriFactory.java │ │ │ ├── ExternalUri.java │ │ │ ├── ExternalUriFactory.java │ │ │ ├── FileUri.java │ │ │ ├── FileUriFactory.java │ │ │ ├── ForumUri.java │ │ │ ├── ForumUriFactory.java │ │ │ ├── IdentityUri.java │ │ │ ├── IdentityUriFactory.java │ │ │ ├── MessageUri.java │ │ │ ├── MessageUriFactory.java │ │ │ ├── ProfileUri.java │ │ │ ├── ProfileUriFactory.java │ │ │ ├── SearchUri.java │ │ │ ├── SearchUriFactory.java │ │ │ ├── Uri.java │ │ │ ├── UriFactory.java │ │ │ └── UriService.java │ │ ├── util/ │ │ │ ├── ChooserUtils.java │ │ │ ├── ClientUtils.java │ │ │ ├── DateUtils.java │ │ │ ├── ImageViewUtils.java │ │ │ ├── PublicKeyUtils.java │ │ │ ├── Range.java │ │ │ ├── SmileyUtils.java │ │ │ ├── TextFieldUtils.java │ │ │ ├── TextFlowDragSelection.java │ │ │ ├── TextFlowUtils.java │ │ │ ├── TextInputControlUtils.java │ │ │ ├── TextSelectRange.java │ │ │ ├── TooltipUtils.java │ │ │ ├── UiUtils.java │ │ │ └── UriUtils.java │ │ └── window/ │ │ ├── UiNativeWindow.java │ │ ├── WindowBorder.java │ │ ├── WindowManager.java │ │ └── WindowResizer.java │ ├── javadoc/ │ │ └── overview.html │ └── resources/ │ ├── help/ │ │ ├── en/ │ │ │ ├── 00.Index.md │ │ │ ├── 01.Quick Setup.md │ │ │ ├── 02.Network.md │ │ │ ├── 03.Markdown.md │ │ │ ├── 04.Emojis.md │ │ │ ├── 05.Startup arguments.md │ │ │ └── 06.Links.md │ │ ├── es/ │ │ │ ├── 00.Index.md │ │ │ ├── 01.Configuración rápida.md │ │ │ ├── 02.Red.md │ │ │ ├── 04.Emojis.md │ │ │ ├── 05.Argumentos de inicio.md │ │ │ └── 06.Enlaces.md │ │ ├── fr/ │ │ │ ├── 00.Index.md │ │ │ ├── 01.Configuration rapide.md │ │ │ ├── 02.Réseau.md │ │ │ ├── 04.Emojis.md │ │ │ ├── 05.Arguments de démarrage.md │ │ │ └── 06.Liens.md │ │ ├── ru/ │ │ │ ├── 00.Index.md │ │ │ ├── 01.Быстрая настройка.md │ │ │ ├── 02.Сеть.md │ │ │ ├── 04.Эмодзи.md │ │ │ ├── 05.Аргументы запуска.md │ │ │ └── 06.Ссылки.md │ │ └── zh/ │ │ ├── 00.Index.md │ │ ├── 01.快速设置.md │ │ ├── 02.网络.md │ │ ├── 04.表情符号.md │ │ ├── 05.启动参数.md │ │ └── 06.链接 │ ├── oembed-providers.json │ ├── retroshare-emojis.json │ └── view/ │ ├── about/ │ │ └── about.fxml │ ├── account/ │ │ └── account_creation.fxml │ ├── board/ │ │ ├── board_group_view.fxml │ │ ├── board_message_view.fxml │ │ ├── board_view.fxml │ │ └── message_cell.fxml │ ├── channel/ │ │ ├── channel_group_view.fxml │ │ ├── channel_message_view.fxml │ │ ├── channel_view.fxml │ │ └── message_cell.fxml │ ├── chat/ │ │ ├── chat_roominfo.fxml │ │ ├── chat_view.fxml │ │ ├── chatroom_create.fxml │ │ └── chatroom_invite.fxml │ ├── contact/ │ │ └── contact_view.fxml │ ├── custom/ │ │ ├── alias_view.fxml │ │ ├── editor_view.fxml │ │ ├── file_results_view.fxml │ │ ├── gxs_group_tree_table_view.fxml │ │ ├── image_selector_view.fxml │ │ ├── info_view.fxml │ │ ├── input_area_group.fxml │ │ ├── sticker_view.fxml │ │ ├── typing_notification_view.fxml │ │ └── wave_dots_view.fxml │ ├── debug/ │ │ └── debug_requester_view.fxml │ ├── default.css │ ├── file/ │ │ ├── add_download.fxml │ │ ├── download.fxml │ │ ├── main.fxml │ │ ├── search.fxml │ │ ├── share.fxml │ │ ├── trend.fxml │ │ └── upload.fxml │ ├── forum/ │ │ ├── forum_editor_view.fxml │ │ ├── forum_group_view.fxml │ │ └── forum_view.fxml │ ├── help/ │ │ └── help.fxml │ ├── id/ │ │ └── rsid_add.fxml │ ├── linux.css │ ├── mac.css │ ├── main.fxml │ ├── messaging/ │ │ ├── broadcast.fxml │ │ └── messaging.fxml │ ├── printer.css │ ├── qrcode/ │ │ ├── camera.fxml │ │ ├── qrcode.fxml │ │ └── qrprint.fxml │ ├── settings/ │ │ ├── settings.fxml │ │ ├── settings_general.fxml │ │ ├── settings_networks.fxml │ │ ├── settings_notifications.fxml │ │ ├── settings_remote.fxml │ │ ├── settings_sound.fxml │ │ └── settings_transfer.fxml │ ├── statistics/ │ │ ├── datacounter.fxml │ │ ├── main.fxml │ │ ├── rtt.fxml │ │ └── turtle.fxml │ ├── voip/ │ │ └── voip.fxml │ └── windows.css └── test/ └── java/ └── io/ └── xeres/ └── ui/ ├── FXTest.java ├── UiCodingRulesTest.java ├── client/ │ └── PaginatedResponseTest.java ├── controller/ │ ├── about/ │ │ └── AboutWindowControllerTest.java │ ├── account/ │ │ └── AccountCreationWindowControllerTest.java │ ├── chat/ │ │ ├── ChatRoomCreationWindowControllerTest.java │ │ ├── ChatRoomInvitationWindowControllerTest.java │ │ └── ChatViewControllerTest.java │ ├── contact/ │ │ └── ContactViewControllerTest.java │ ├── help/ │ │ ├── HelpWindowControllerTest.java │ │ └── NavigatorTest.java │ ├── id/ │ │ └── AddRsIdWindowControllerTest.java │ ├── messaging/ │ │ ├── BroadcastWindowControllerTest.java │ │ └── MessagingWindowControllerTest.java │ ├── qrcode/ │ │ └── QrCodeWindowControllerTest.java │ └── share/ │ └── ShareWindowControllerTest.java ├── custom/ │ ├── AsyncImageViewTest.java │ └── EditorViewTest.java ├── model/ │ ├── chat/ │ │ └── ChatMapperTest.java │ ├── connection/ │ │ └── ConnectionMapperTest.java │ ├── identity/ │ │ └── IdentityMapperTest.java │ ├── location/ │ │ └── LocationMapperTest.java │ ├── profile/ │ │ └── ProfileMapperTest.java │ ├── settings/ │ │ └── SettingsMapperTest.java │ └── share/ │ └── ShareMapperTest.java └── support/ ├── chat/ │ ├── ChatActionTest.java │ ├── ChatParserTest.java │ ├── ColorGeneratorTest.java │ └── NicknameCompleterTest.java ├── emoji/ │ └── EmojiServiceTest.java ├── markdown/ │ └── MarkdownServiceTest.java ├── uri/ │ ├── BoardUriFactoryTest.java │ ├── CertificateUriFactoryTest.java │ ├── FileUriFactoryTest.java │ └── UriFactoryUtils.java └── util/ ├── ImageViewUtilsTest.java ├── RangeTest.java ├── SmileyUtilsTest.java ├── TextInputControlUtilsTest.java ├── UiUtilsTest.java └── UriUtilsTest.java ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agents/skills/archunit-rules/SKILL.md ================================================ --- name: archunit-rules description: ArchUnit architecture rules enforced in Xeres including common module rules (logging, utility classes), app module rules (no field injection, RsService naming), and UI module rules (WindowController naming). --- # ArchUnit Rules for Xeres Architecture rules are enforced via ArchUnit tests in `common/src/test/` and `common/src/testFixtures/`. ## Running Rules ```bash ./gradlew test --tests "*CodingRulesTest" ``` ## Common Module Rules (`CommonCodingRulesTest`) ### Logging - No `java.util.logging` allowed - Use SLF4J only ### Logger Declaration ```java private static final Logger log; // Correct Logger logger; // Wrong ``` ### Utility Classes ```java public final class FooUtils { // Correct private FooUtils() { throw new UnsupportedOperationException("Utility class"); } } ``` ### Identifier Classes Must have `public static final int LENGTH`: ```java public class ProfileIdentifier extends Identifier { public static final int LENGTH = 32; } ``` ## App Module Rules (`AppCodingRulesTest`) ### No Field Injection ```java // Allowed private final ProfileService profileService; public Service(ProfileService profileService) { ...} // Forbidden @Autowired private ProfileService profileService; ``` ### RsService Naming Service subclasses must end with `RsService`: ```java public class AvatarRsService extends RsService { } // Correct public class AvatarService extends RsService { } // Wrong ``` ### JPA Entities Must have public or protected no-arg constructor: ```java @Entity public class Profile { protected Profile() { } // Required } ``` ### Item Classes - Public no-arg constructor - `clone()` method implemented - Meaningful `toString()` method ### No UI Access `app` module cannot access `ui` module packages: ```java noClasses(). that(). resideInPackage("io.xeres.app..") . should(). accessClassesThat(). resideInPackage("io.xeres.ui..") ``` ## UI Module Rules (`UiCodingRulesTest`) ### WindowController Naming ```java public class SettingsWindowController { } // Correct public class SettingsController { } // Wrong ``` ### No FileChooser Initial Directory Use `ChooserUtils` instead: ```java // Forbidden fileChooser.setInitialDirectory(path); // Use instead ChooserUtils. setInitialDirectory(fileChooser, path); ``` ### No Field Injection Same rule as app module. ================================================ FILE: .agents/skills/crypto/SKILL.md ================================================ --- name: crypto description: Cryptography patterns for Xeres including PGP operations, key generation, and hash functions with best practices. --- # Cryptography Patterns for Xeres ## JCE/JCA and BouncyCastle Usage Xeres uses JCE/JCA and BouncyCastle for cryptographic operations. Always use the registered providers. ## Common Patterns ### OpenPGP Operations ```java import org.bouncycastle.openpgp.*; PGPSecretKeyRingCollection secretKeys = ... PGPPublicKeyRingCollection publicKeys = ... // Encrypt PGPEncryptedDataGenerator encryptedDataGenerator = new PGPEncryptedDataGenerator( new JcePGPDataEncryptorBuilder(PGPEncryptedData.CAST5) .setWithIntegrityPacket(true) .setSecureRandom(new SecureRandom()) .useInsecureRandom() // Only for testing ); // Decrypt PGPPrivateKey privateKey = secretKeys.getSecretKey(keyId) .extractPrivateKey(new JcePBESecretKeyDecryptorBuilder() .setProvider("BC") .build(passphrase.toCharArray())); ``` ### Key Generation ```java import org.bouncycastle.bcpg.*; import org.bouncycastle.openpgp.*; var keyRingGenerator = new PGPKeyRingGenerator( V3PGPSignature.POSITIVE_CERTIFICATION, new PGPSignatureSubpacketGenerator(), algorithm, encryptionKey, creationTime, "User ID", symmetricKeyEncryption, hashedGen, unhashedGen, new SecureRandom(), "BC" ); ``` ### Hash Functions ```java import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.MessageDigest; Security.addProvider(new BouncyCastleProvider()); MessageDigest digest = MessageDigest.getInstance("SHA-256", "BC"); byte[] hash = digest.digest(data); ``` ## Best Practices 1. Use the `SecureRandomUtils` class for all random operations 2. Prefer JCA/JCE, otherwise use BouncyCastle 3. Use constant-time comparisons for secrets 4. Clear sensitive data from memory when done 5. Use appropriate key sizes (RSA 2048+) ## Identifier Classes Cryptographic identifiers extend `Identifier`: ```java public class RsPkIdentifier extends Identifier { public static final int LENGTH = 16; } ``` ================================================ FILE: .agents/skills/dto-mappers/SKILL.md ================================================ --- name: dto-mappers description: DTO and mapper patterns for Xeres using Java records, canonical constructors with validation, and static mapper utility classes. --- # DTO and Mapper Patterns for Xeres ## DTOs as Records (Java 21+) Use Java records for immutable DTOs: ```java public record ProfileDTO( long id, @NotNull @Size String name, String pgpIdentifier, Instant created, byte[] pgpFingerprint, byte[] pgpPublicKeyData, boolean accepted, Trust trust, @JsonInclude(NON_EMPTY) List locations ) { public ProfileDTO { if (locations == null) locations = new ArrayList<>(); } } ``` ## Canonical Constructor with Validation ```java public record ProfileDTO(...) { public ProfileDTO { Objects.requireNonNull(name, "Name must not be null"); if (locations == null) { locations = new ArrayList<>(); } locations = List.copyOf(locations); // Make immutable } } ``` ## Mapper Pattern Static utility class with mapping methods: ```java public final class ProfileMapper { private ProfileMapper() { throw new UnsupportedOperationException("Utility class"); } public static ProfileDTO toDTO(Profile profile) { if (profile == null) { return null; } return new ProfileDTO( profile.getId(), profile.getName(), // ... other fields ); } public static Profile toEntity(ProfileDTO dto) { if (dto == null) { return null; } var profile = new Profile(); profile.setId(dto.id()); profile.setName(dto.name()); // ... other fields return profile; } } ``` ## Usage ```java // Entity to DTO ProfileDTO dto = ProfileMapper.toDTO(profile); // DTO to Entity Profile profile = ProfileMapper.toEntity(dto); // List mapping List dtos = profiles.stream() .map(ProfileMapper::toDTO) .toList(); ``` ## JsonInclude for Optional Fields ```java @JsonInclude(NON_EMPTY) // Don't serialize null or empty collections List locations ``` ## Validation Annotations Use Bean Validation on DTO fields: ```java @NotNull @Size(min = 1, max = 255) String name @Email String email @Min(0) @Max(100) int percentage ``` ## Collection Handling Always handle null collections in constructor: ```java public ProfileDTO { if (locations == null) { locations = new ArrayList<>(); } } ``` ================================================ FILE: .agents/skills/flyway-migrations/SKILL.md ================================================ --- name: flyway-migrations description: Flyway SQL migration patterns for Xeres including naming conventions, H2 database patterns, enum types, foreign keys, and best practices. --- # Flyway Migration Patterns for Xeres ## Migration Location `app/src/main/resources/db/migration/` ## Naming Convention `V____.sql` Examples: - `V00_0_1_202001232214__InitDb.sql` - `V00_0_32_202603092327__AddIndices.sql` ## Table Creation Pattern ```sql CREATE TABLE profile ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(255) NOT NULL, pgp_id BIGINT, created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT profile_pkey PRIMARY KEY (id) ); CREATE INDEX idx_profile_name ON profile(name); ``` ## Enum Types ```sql CREATE TYPE trust_level AS ENUM ('UNKNOWN', 'NEVER', 'MARGINAL', 'FULLY', 'ULTIMATE'); ALTER TABLE profile ADD COLUMN trust trust_level NOT NULL DEFAULT 'UNKNOWN'; ``` ## Foreign Keys ```sql ALTER TABLE contact ADD CONSTRAINT fk_contact_profile FOREIGN KEY (profile_id) REFERENCES profile(id) ON DELETE CASCADE; ``` ## Best Practices 1. **Separate index creation** from table creation 2. **Name all constraints** explicitly for easier debugging 3. **Use `BIGINT` for IDs** with `GENERATED BY DEFAULT AS IDENTITY` 4. **Include `NOT NULL`** constraints where appropriate 5. **Default values** for optional columns 6. **One concern per migration** when possible 7. **Timestamp precision** must always be of TIMESTAMP(9) ## Rolling Back Add downward migrations with `V__.sql` (no timestamp): ```sql -- V00_0_33__AddLocations.sql ALTER TABLE location DROP COLUMN IF EXISTS last_seen; ``` ## Testing Migrations Flyway runs automatically on application startup with H2 database. ================================================ FILE: .agents/skills/gradle-build/SKILL.md ================================================ --- name: gradle-build description: Gradle build configuration for Xeres including build commands, version management, module structure, and key plugins. --- # Gradle Build for Xeres ## Project Structure Multi-module Gradle project: ``` Xeres/ ├── app/ - Spring Boot application ├── ui/ - JavaFX desktop UI ├── common/ - Shared code ├── build.gradle - Root configuration └── settings.gradle ``` ## Build Commands ```bash # Run the application ./gradlew bootRun # Build without tests ./gradlew build -x test # Run tests ./gradlew test # Run UI tests specifically ./gradlew :ui:test # Package application (MSI on Windows, .deb on Linux) ./gradlew :app:jpackage # Create portable zip ./gradlew :app:jpackage -Pjpackage.portable=true # Build Docker image ./gradlew :app:bootBuildImage # Clean build ./gradlew clean ``` ## Version Management Versions are defined in root `build.gradle` ext block: ```groovy ext { set('version.java', 25) set('version.spring-boot', '4.0.5') // etc. } ``` Never modify version numbers directly. Update in root build.gradle. ## Module Dependencies ``` app → common ui → common app ✗→ ui (forbidden by archunit) ``` ## Key Plugins - `java` - Java compilation - `application` - Runnable application - `org.springframework.boot` - Spring Boot - `io.github.goooler.java` - BOM management - `jacoco` - Code coverage - `org.openjfx.javafxplugin` - JavaFX ## Subproject Configuration Subprojects inherit common configuration from root build.gradle. Module-specific settings go in `app/build.gradle`, `ui/build.gradle`, etc. ## Running Application ```bash # Development mode with hot reload ./gradlew bootRun # With specific JVM args ./gradlew bootRun -PjvmArgs="-Xmx512m" ``` ================================================ FILE: .agents/skills/java-conventions/SKILL.md ================================================ --- name: java-conventions description: Code style, naming conventions, license headers, and patterns for Xeres Java project. Covers Allman braces, utility classes, package structure, and field injection rules. --- # Java Conventions for Xeres ## Code Style - **Brace Style**: Allman (braces on next line) - **Indentation**: tabs only - **Max line length**: 320 characters ## License Header Every source file must include the GPL v3 header: ```java /* * Copyright (c) [year-range] by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation... */ ``` ## Version The version of Java used in Java 25. ## Utility Classes - Must be `final` class - Private no-arg constructor that throws `UnsupportedOperationException` - No instance fields ```java public final class FooUtils { private ProfileMapper() { throw new UnsupportedOperationException("Utility class"); } public static void doSomething(int count) { ...} } ``` ## Naming Conventions | Type | Pattern | |---------------------|------------------------------------------------| | Services | `*Service.java` | | Controllers | `*Controller.java` or `*WindowController.java` | | REST Controllers | `*Controller.java` in `api/controller/` | | Client classes | `*Client.java` | | Utility classes | `*Utils.java` | | Fakes/Test fixtures | `*Fakes.java` | | Mappers | `*Mapper.java` (static utility class) | ## Package Structure `io.xeres..` | Module | Packages | |--------|---------------------------------------------------------------------------------------------| | app | `api`, `application`, `configuration`, `crypto`, `database`, `job`, `net`, `service`, `xrs` | | ui | `client`, `controller`, `custom`, `event`, `model`, `support` | | common | `dto`, `events`, `id`, `message`, `rest`, `protocol` | ## Logging - Use SLF4J (not `java.util.logging`) - Logger declaration: `private static final Logger log = LoggerFactory.getLogger(ClassName.class);` - Logging sensitive data is fine with the `debug` facility ## Field Injection Field injection is prohibited. Use constructor injection instead: ```java // Bad @Autowired private ProfileService profileService; // Good private final ProfileService profileService; public ContactService(ProfileService profileService) { this.profileService = profileService; } ``` ================================================ FILE: .agents/skills/javafx-patterns/SKILL.md ================================================ --- name: javafx-patterns description: JavaFX patterns for Xeres including controller structure with FXML views, WindowController lifecycle, WindowManager usage, and JavaFX-Spring integration. --- # JavaFX Patterns for Xeres ## Controller Structure Controllers are Spring components with FXML views: ```java @Component @FxmlView(value = "/view/contact/contact_view.fxml") public class ContactViewController implements Controller { @FXML private TreeTableView contactTreeTableView; @Override public void initialize() { ...} } ``` ## Window Controllers For windows/dialogs, implement `WindowController` interface: ```java @Component @FxmlView(value = "/view/settings/settings_window.fxml") public class SettingsWindowController implements WindowController { @Override public void onShowing() { ...} @Override public void onShown() { ...} @Override public void onClose() { ...} } ``` Naming convention: `*WindowController` ## Window Management Use `WindowManager` for window lifecycle. ## JavaFX-App Integration ```java public class JavaFxApplication extends Application { private ConfigurableApplicationContext springContext; @Override public void init() { springContext = new SpringApplicationBuilder() .sources(springApplicationClass) .headless(false) .initializers(initializers()) .run(getParameters().getRaw().toArray(new String[0])); } } ``` ## File Choosers Never call `FileChooser.setInitialDirectory()` directly. Use `ChooserUtils`: ```java // Bad fileChooser.setInitialDirectory(someDirectory); // Good ChooserUtils. setInitialDirectory(fileChooser, someDirectory); ``` ## FXML Location Convention ``` ui/src/main/resources/view//_view.fxml ui/src/main/resources/view//_window.fxml (for dialogs) ``` ## Binding Patterns Use JavaFX properties for observable data: ```java private final StringProperty nameProperty = new SimpleStringProperty(); private final ObjectProperty selectedProfile = new SimpleObjectProperty<>(); ``` ## UI Event Handling Dispatch events through Spring's `ApplicationEventPublisher`: ```java public record ContactSelectedEvent(Contact contact) { } ``` ================================================ FILE: .agents/skills/junit-testing/SKILL.md ================================================ --- name: junit-testing description: JUnit 6 testing patterns for Xeres including Mockito with constructor injection, test fixtures via *Fakes.java classes, and AssertJ assertions. --- # JUnit Testing Patterns for Xeres ## Test Structure - Location: `src/test/java/` mirrors main source structure - Naming: `*Test.java` suffix - Framework: JUnit 6 with Jupiter ## Mockito Extension Pattern ```java @ExtendWith(MockitoExtension.class) class ContactServiceTest { @Mock private ProfileService profileService; @InjectMocks private ContactService contactService; @Test void getContacts_ShouldReturnCombinedList() { when(profileService.getProfiles()).thenReturn(List.of()); var result = contactService.getContacts(); assertTrue(result.isEmpty()); } } ``` ## Key Points - Use constructor injection (Mockito injects via `@InjectMocks`) - `@Mock` creates a mock, `@Spy` creates a partial mock - `when(...).thenReturn(...)` for stubbing - `verify(...).method()` for interaction testing - Prefer JUnit assertions: `assertTrue(...)` but it's also possible to use assertJ for more complex cases ## Test Fixtures Use `*Fakes.java` in `common/src/testFixtures/java/io/xeres/`: ```java public final class ProfileFakes { private ProfileFakes() { throw new UnsupportedOperationException("Utility class"); } public static Profile createProfile() { return createProfile(1L, "Test Profile"); } public static Profile createProfile(long id, String name) { var profile = new Profile(); profile.setId(id); profile.setName(name); return profile; } public static Profile createOwnProfile() { var profile = createProfile(); profile.setOwn(true); return profile; } } ``` ## Assertion Examples ```java import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.*; assertNotNull(result); assertEquals("Test",result.getName); assertThat(list). hasSize(2). contains(profile1, profile2); assertThatThrownBy(() ->service. save(null)) . isInstanceOf(IllegalArgumentException .class); ``` ## Exception Testing ```java @Test void save_WithNullProfile_ShouldThrow() { assertThatThrownBy(() -> contactService.save(null)) .isInstanceOf(NullPointerException.class) .hasMessage("Profile must not be null"); } ``` ## See Also - `ui-testing` skill for JavaFX controller testing - `archunit-rules` skill for testing architecture rules ================================================ FILE: .agents/skills/spring-boot-patterns/SKILL.md ================================================ --- name: spring-boot-patterns description: Spring Boot patterns for Xeres including constructor injection, @Transactional boundaries, REST controllers with OpenAPI annotations, and reactive WebClient usage in the UI module. --- # Spring Boot Patterns for Xeres ## Application Entry Point ```java @SpringBootApplication(scanBasePackageClasses = { io.xeres.app.XeresApplication.class, io.xeres.ui.UiStarter.class }) public class XeresApplication ``` ## Service Patterns ### Constructor Injection Always use constructor injection. Dependencies are `final`. ```java @Service public class ContactService { private final ProfileService profileService; private final LocationService locationService; public ContactService(ProfileService profileService, LocationService locationService) { this.profileService = profileService; this.locationService = locationService; } } ``` ### Transactional Boundaries ```java @Transactional(readOnly = true) public List getContacts() { ...} @Transactional public void saveContact(Contact contact) { ...} ``` ### Circular Dependencies Use `@Lazy` annotation, but avoid them if possible: ```java public ContactService(@Lazy ProfileService profileService) { ...} ``` ## REST Controllers ### OpenAPI Annotations ```java @RestController @Tag(name = "Profiles", description = "Profile management") public class ProfileController { @GetMapping("/{id}") @Operation(summary = "Get profile by ID") @ApiResponse(responseCode = "200", description = "Profile found") public ResponseEntity getProfile(@PathVariable long id) { ...} } ``` ### Exception Handling Use custom exceptions with appropriate HTTP status codes. ## Reactive WebClient Clients (UI Module) ```java @Component public class ProfileClient { private WebClient webClient; @EventListener public void init(StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + PROFILES_PATH) .build(); } public Mono findById(long id) { return webClient.get() .uri("/{id}", id) .retrieve() .bodyToMono(Profile.class); } } ``` ## Configuration Classes Use `@Configuration` for feature-specific beans. Keep `@Bean` methods small and focused. ## Testing Services See `junit-testing` skill for testing patterns with mocks. ================================================ FILE: .agents/skills/ui-testing/SKILL.md ================================================ --- name: ui-testing description: TestFX patterns for JavaFX controller testing in Xeres including FXML loading with controller factories, mocking reactive clients, and user interaction testing. --- # UI Testing Patterns for Xeres ## TestFX Setup UI tests use TestFX with both `ApplicationExtension` and `MockitoExtension`: ```java @ExtendWith({ApplicationExtension.class, MockitoExtension.class}) class ContactViewControllerTest { @Mock private ProfileClient profileClient; @InjectMocks private ContactViewController controller; @Test void testFxmlLoading() throws IOException { FXMLLoader loader = new FXMLLoader(getClass().getResource("/view/contact/contact_view.fxml")); loader.setControllerFactory(_ -> controller); Parent root = loader.load(); assertThat(root).isNotNull(); } } ``` ## FXTest Base Class For tests requiring JavaFX initialization, extend `FXTest`: ```java class SomeJavaFXTest extends FXTest { @Test void test javafx components() { // JavaFX is initialized } } ``` ## FXML Loading Pattern ```java @Test void initialize_ShouldLoadContacts() throws IOException { // Load FXML with controller factory FXMLLoader loader = new FXMLLoader( getClass().getResource("/view/contact/contact_view.fxml") ); loader.setControllerFactory(javaClass -> controller); // Initialize controller manually for unit-like tests controller.initialize(); // Verify initial state assertThat(controller.getContactTreeTableView()).isNotNull(); } ``` ## Testing User Interactions ```java @Test void clickButton_ShouldTriggerAction() { // Find button in loaded FXML var button = lookup("#saveButton").query(); // Click and verify clickOn(button); // Verify interaction with mock verify(profileClient).save(any(Profile.class)); } ``` ## Mocking Reactive Clients For WebClient-based clients returning `Mono`: ```java when(profileClient.findById(anyLong())) . thenReturn(Mono.just(testProfile)); ``` ## See Also - `junit-testing` skill for basic testing patterns - `javafx-patterns` skill for controller structure ================================================ FILE: .aiignore ================================================ /data*/ /.idea/ /.vscode/ /.gradle/ /.venv/ ./jpb/ /*/build/ /build/ /*/out/ /*/bin/ .xeres.lock /.jpb/persistence-units.xml .run/XeresApplication.run.xml /scripts/bot/config.json /scripts/bot/avatar.png /cache/ /.proxyai/ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = tab insert_final_newline = false max_line_length = 320 tab_width = 4 ij_continuation_indent_size = 8 ij_formatter_off_tag = @formatter:off ij_formatter_on_tag = @formatter:on ij_formatter_tags_enabled = false ij_smart_tabs = true ij_wrap_on_typing = false [*.css] indent_style = space ij_smart_tabs = false ij_css_align_closing_brace_with_properties = false ij_css_blank_lines_around_nested_selector = 1 ij_css_blank_lines_between_blocks = 1 ij_css_enforce_quotes_on_format = false ij_css_hex_color_long_format = false ij_css_hex_color_lower_case = false ij_css_hex_color_short_format = false ij_css_hex_color_upper_case = false ij_css_keep_blank_lines_in_code = 2 ij_css_keep_indents_on_empty_lines = false ij_css_keep_single_line_blocks = false ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow ij_css_space_after_colon = true ij_css_space_before_opening_brace = true ij_css_use_double_quotes = true [*.java] ij_java_align_consecutive_assignments = false ij_java_align_consecutive_variable_declarations = false ij_java_align_group_field_declarations = false ij_java_align_multiline_annotation_parameters = false ij_java_align_multiline_array_initializer_expression = false ij_java_align_multiline_assignment = false ij_java_align_multiline_binary_operation = false ij_java_align_multiline_chained_methods = false ij_java_align_multiline_extends_list = false ij_java_align_multiline_for = true ij_java_align_multiline_method_parentheses = false ij_java_align_multiline_parameters = true ij_java_align_multiline_parameters_in_calls = false ij_java_align_multiline_parenthesized_expression = false ij_java_align_multiline_resources = true ij_java_align_multiline_ternary_operation = false ij_java_align_multiline_text_blocks = false ij_java_align_multiline_throws_list = false ij_java_align_subsequent_simple_methods = false ij_java_align_throws_keyword = false ij_java_annotation_parameter_wrap = off ij_java_array_initializer_new_line_after_left_brace = false ij_java_array_initializer_right_brace_on_new_line = false ij_java_array_initializer_wrap = off ij_java_assert_statement_colon_on_next_line = false ij_java_assert_statement_wrap = off ij_java_assignment_wrap = off ij_java_binary_operation_sign_on_next_line = false ij_java_binary_operation_wrap = off ij_java_blank_lines_after_anonymous_class_header = 0 ij_java_blank_lines_after_class_header = 0 ij_java_blank_lines_after_imports = 1 ij_java_blank_lines_after_package = 1 ij_java_blank_lines_around_class = 1 ij_java_blank_lines_around_field = 0 ij_java_blank_lines_around_field_in_interface = 0 ij_java_blank_lines_around_initializer = 1 ij_java_blank_lines_around_method = 1 ij_java_blank_lines_around_method_in_interface = 1 ij_java_blank_lines_before_class_end = 0 ij_java_blank_lines_before_imports = 1 ij_java_blank_lines_before_method_body = 0 ij_java_blank_lines_before_package = 0 ij_java_block_brace_style = next_line ij_java_block_comment_at_first_column = true ij_java_call_parameters_new_line_after_left_paren = false ij_java_call_parameters_right_paren_on_new_line = false ij_java_call_parameters_wrap = off ij_java_case_statement_on_separate_line = true ij_java_catch_on_new_line = true ij_java_class_annotation_wrap = split_into_lines ij_java_class_brace_style = next_line ij_java_class_count_to_use_import_on_demand = 5 ij_java_class_names_in_javadoc = 1 ij_java_do_not_indent_top_level_class_members = false ij_java_do_not_wrap_after_single_annotation = false ij_java_do_while_brace_force = never ij_java_doc_add_blank_line_after_description = true ij_java_doc_add_blank_line_after_param_comments = false ij_java_doc_add_blank_line_after_return = false ij_java_doc_add_p_tag_on_empty_lines = true ij_java_doc_align_exception_comments = true ij_java_doc_align_param_comments = true ij_java_doc_do_not_wrap_if_one_line = false ij_java_doc_enable_formatting = true ij_java_doc_enable_leading_asterisks = true ij_java_doc_indent_on_continuation = false ij_java_doc_keep_empty_lines = true ij_java_doc_keep_empty_parameter_tag = true ij_java_doc_keep_empty_return_tag = true ij_java_doc_keep_empty_throws_tag = true ij_java_doc_keep_invalid_tags = true ij_java_doc_param_description_on_new_line = false ij_java_doc_preserve_line_breaks = false ij_java_doc_use_throws_not_exception_tag = true ij_java_else_on_new_line = true ij_java_entity_dd_suffix = EJB ij_java_entity_eb_suffix = Bean ij_java_entity_hi_suffix = Home ij_java_entity_lhi_prefix = Local ij_java_entity_lhi_suffix = Home ij_java_entity_li_prefix = Local ij_java_entity_pk_class = java.lang.String ij_java_entity_vo_suffix = VO ij_java_enum_constants_wrap = off ij_java_extends_keyword_wrap = off ij_java_extends_list_wrap = off ij_java_field_annotation_wrap = split_into_lines ij_java_finally_on_new_line = true ij_java_for_brace_force = never ij_java_for_statement_new_line_after_left_paren = false ij_java_for_statement_right_paren_on_new_line = false ij_java_for_statement_wrap = off ij_java_generate_final_locals = false ij_java_generate_final_parameters = false ij_java_if_brace_force = never ij_java_imports_layout = *,|,javax.**,java.**,|,$* ij_java_indent_case_from_switch = true ij_java_insert_inner_class_imports = false ij_java_insert_override_annotation = true ij_java_keep_blank_lines_before_right_brace = 2 ij_java_keep_blank_lines_between_package_declaration_and_header = 2 ij_java_keep_blank_lines_in_code = 2 ij_java_keep_blank_lines_in_declarations = 2 ij_java_keep_control_statement_in_one_line = true ij_java_keep_first_column_comment = true ij_java_keep_indents_on_empty_lines = false ij_java_keep_line_breaks = true ij_java_keep_multiple_expressions_in_one_line = false ij_java_keep_simple_blocks_in_one_line = false ij_java_keep_simple_classes_in_one_line = false ij_java_keep_simple_lambdas_in_one_line = false ij_java_keep_simple_methods_in_one_line = false ij_java_label_indent_absolute = false ij_java_label_indent_size = 0 ij_java_lambda_brace_style = end_of_line ij_java_layout_static_imports_separately = true ij_java_line_comment_add_space = false ij_java_line_comment_at_first_column = true ij_java_message_dd_suffix = EJB ij_java_message_eb_suffix = Bean ij_java_method_annotation_wrap = split_into_lines ij_java_method_brace_style = next_line ij_java_method_call_chain_wrap = off ij_java_method_parameters_new_line_after_left_paren = false ij_java_method_parameters_right_paren_on_new_line = false ij_java_method_parameters_wrap = off ij_java_modifier_list_wrap = false ij_java_names_count_to_use_import_on_demand = 3 ij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.* ij_java_parameter_annotation_wrap = off ij_java_parentheses_expression_new_line_after_left_paren = false ij_java_parentheses_expression_right_paren_on_new_line = false ij_java_place_assignment_sign_on_next_line = false ij_java_prefer_longer_names = true ij_java_prefer_parameters_wrap = false ij_java_repeat_synchronized = true ij_java_replace_instanceof_and_cast = false ij_java_replace_null_check = true ij_java_replace_sum_lambda_with_method_ref = true ij_java_resource_list_new_line_after_left_paren = false ij_java_resource_list_right_paren_on_new_line = false ij_java_resource_list_wrap = off ij_java_session_dd_suffix = EJB ij_java_session_eb_suffix = Bean ij_java_session_hi_suffix = Home ij_java_session_lhi_prefix = Local ij_java_session_lhi_suffix = Home ij_java_session_li_prefix = Local ij_java_session_si_suffix = Service ij_java_space_after_closing_angle_bracket_in_type_argument = false ij_java_space_after_colon = true ij_java_space_after_comma = true ij_java_space_after_comma_in_type_arguments = true ij_java_space_after_for_semicolon = true ij_java_space_after_quest = true ij_java_space_after_type_cast = true ij_java_space_before_annotation_array_initializer_left_brace = false ij_java_space_before_annotation_parameter_list = false ij_java_space_before_array_initializer_left_brace = false ij_java_space_before_catch_keyword = true ij_java_space_before_catch_left_brace = true ij_java_space_before_catch_parentheses = true ij_java_space_before_class_left_brace = true ij_java_space_before_colon = true ij_java_space_before_colon_in_foreach = true ij_java_space_before_comma = false ij_java_space_before_do_left_brace = true ij_java_space_before_else_keyword = true ij_java_space_before_else_left_brace = true ij_java_space_before_finally_keyword = true ij_java_space_before_finally_left_brace = true ij_java_space_before_for_left_brace = true ij_java_space_before_for_parentheses = true ij_java_space_before_for_semicolon = false ij_java_space_before_if_left_brace = true ij_java_space_before_if_parentheses = true ij_java_space_before_method_call_parentheses = false ij_java_space_before_method_left_brace = true ij_java_space_before_method_parentheses = false ij_java_space_before_opening_angle_bracket_in_type_parameter = false ij_java_space_before_quest = true ij_java_space_before_switch_left_brace = true ij_java_space_before_switch_parentheses = true ij_java_space_before_synchronized_left_brace = true ij_java_space_before_synchronized_parentheses = true ij_java_space_before_try_left_brace = true ij_java_space_before_try_parentheses = true ij_java_space_before_type_parameter_list = false ij_java_space_before_while_keyword = true ij_java_space_before_while_left_brace = true ij_java_space_before_while_parentheses = true ij_java_space_inside_one_line_enum_braces = false ij_java_space_within_empty_array_initializer_braces = false ij_java_space_within_empty_method_call_parentheses = false ij_java_space_within_empty_method_parentheses = false ij_java_spaces_around_additive_operators = true ij_java_spaces_around_assignment_operators = true ij_java_spaces_around_bitwise_operators = true ij_java_spaces_around_equality_operators = true ij_java_spaces_around_lambda_arrow = true ij_java_spaces_around_logical_operators = true ij_java_spaces_around_method_ref_dbl_colon = false ij_java_spaces_around_multiplicative_operators = true ij_java_spaces_around_relational_operators = true ij_java_spaces_around_shift_operators = true ij_java_spaces_around_type_bounds_in_type_parameters = true ij_java_spaces_around_unary_operator = false ij_java_spaces_within_angle_brackets = false ij_java_spaces_within_annotation_parentheses = false ij_java_spaces_within_array_initializer_braces = false ij_java_spaces_within_braces = false ij_java_spaces_within_brackets = false ij_java_spaces_within_cast_parentheses = false ij_java_spaces_within_catch_parentheses = false ij_java_spaces_within_for_parentheses = false ij_java_spaces_within_if_parentheses = false ij_java_spaces_within_method_call_parentheses = false ij_java_spaces_within_method_parentheses = false ij_java_spaces_within_parentheses = false ij_java_spaces_within_switch_parentheses = false ij_java_spaces_within_synchronized_parentheses = false ij_java_spaces_within_try_parentheses = false ij_java_spaces_within_while_parentheses = false ij_java_special_else_if_treatment = true ij_java_subclass_name_suffix = Impl ij_java_ternary_operation_signs_on_next_line = false ij_java_ternary_operation_wrap = off ij_java_test_name_suffix = Test ij_java_throws_keyword_wrap = off ij_java_throws_list_wrap = off ij_java_use_external_annotations = false ij_java_use_fq_class_names = false ij_java_use_relative_indents = false ij_java_use_single_class_imports = true ij_java_variable_annotation_wrap = off ij_java_visibility = public ij_java_while_brace_force = always ij_java_while_on_new_line = false ij_java_wrap_comments = false ij_java_wrap_first_method_in_call_chain = false ij_java_wrap_long_lines = false [.editorconfig] ij_editorconfig_align_group_field_declarations = false ij_editorconfig_space_after_colon = false ij_editorconfig_space_after_comma = true ij_editorconfig_space_before_colon = false ij_editorconfig_space_before_comma = false ij_editorconfig_spaces_around_assignment_operators = true [{*.gant,*.groovy,*.gradle,*.gdsl,*.gy}] indent_style = space ij_smart_tabs = false ij_groovy_align_group_field_declarations = false ij_groovy_align_multiline_array_initializer_expression = false ij_groovy_align_multiline_assignment = false ij_groovy_align_multiline_binary_operation = false ij_groovy_align_multiline_chained_methods = false ij_groovy_align_multiline_extends_list = false ij_groovy_align_multiline_for = true ij_groovy_align_multiline_method_parentheses = false ij_groovy_align_multiline_parameters = true ij_groovy_align_multiline_parameters_in_calls = false ij_groovy_align_multiline_resources = true ij_groovy_align_multiline_ternary_operation = false ij_groovy_align_multiline_throws_list = false ij_groovy_align_throws_keyword = false ij_groovy_array_initializer_new_line_after_left_brace = false ij_groovy_array_initializer_right_brace_on_new_line = false ij_groovy_array_initializer_wrap = off ij_groovy_assert_statement_wrap = off ij_groovy_assignment_wrap = off ij_groovy_binary_operation_wrap = off ij_groovy_blank_lines_after_class_header = 0 ij_groovy_blank_lines_after_imports = 1 ij_groovy_blank_lines_after_package = 1 ij_groovy_blank_lines_around_class = 1 ij_groovy_blank_lines_around_field = 0 ij_groovy_blank_lines_around_field_in_interface = 0 ij_groovy_blank_lines_around_method = 1 ij_groovy_blank_lines_around_method_in_interface = 1 ij_groovy_blank_lines_before_imports = 1 ij_groovy_blank_lines_before_method_body = 0 ij_groovy_blank_lines_before_package = 0 ij_groovy_block_brace_style = end_of_line ij_groovy_block_comment_at_first_column = true ij_groovy_call_parameters_new_line_after_left_paren = false ij_groovy_call_parameters_right_paren_on_new_line = false ij_groovy_call_parameters_wrap = off ij_groovy_catch_on_new_line = false ij_groovy_class_annotation_wrap = split_into_lines ij_groovy_class_brace_style = end_of_line ij_groovy_do_while_brace_force = never ij_groovy_else_on_new_line = false ij_groovy_enum_constants_wrap = off ij_groovy_extends_keyword_wrap = off ij_groovy_extends_list_wrap = off ij_groovy_field_annotation_wrap = split_into_lines ij_groovy_finally_on_new_line = false ij_groovy_for_brace_force = never ij_groovy_for_statement_new_line_after_left_paren = false ij_groovy_for_statement_right_paren_on_new_line = false ij_groovy_for_statement_wrap = off ij_groovy_if_brace_force = never ij_groovy_indent_case_from_switch = true ij_groovy_keep_blank_lines_before_right_brace = 2 ij_groovy_keep_blank_lines_in_code = 2 ij_groovy_keep_blank_lines_in_declarations = 2 ij_groovy_keep_control_statement_in_one_line = true ij_groovy_keep_first_column_comment = true ij_groovy_keep_indents_on_empty_lines = false ij_groovy_keep_line_breaks = true ij_groovy_keep_multiple_expressions_in_one_line = false ij_groovy_keep_simple_blocks_in_one_line = false ij_groovy_keep_simple_classes_in_one_line = true ij_groovy_keep_simple_lambdas_in_one_line = true ij_groovy_keep_simple_methods_in_one_line = true ij_groovy_label_indent_absolute = false ij_groovy_label_indent_size = 0 ij_groovy_lambda_brace_style = end_of_line ij_groovy_line_comment_add_space = false ij_groovy_line_comment_at_first_column = true ij_groovy_method_annotation_wrap = split_into_lines ij_groovy_method_brace_style = end_of_line ij_groovy_method_call_chain_wrap = off ij_groovy_method_parameters_new_line_after_left_paren = false ij_groovy_method_parameters_right_paren_on_new_line = false ij_groovy_method_parameters_wrap = off ij_groovy_modifier_list_wrap = false ij_groovy_parameter_annotation_wrap = off ij_groovy_parentheses_expression_new_line_after_left_paren = false ij_groovy_parentheses_expression_right_paren_on_new_line = false ij_groovy_prefer_parameters_wrap = false ij_groovy_resource_list_new_line_after_left_paren = false ij_groovy_resource_list_right_paren_on_new_line = false ij_groovy_resource_list_wrap = off ij_groovy_space_after_colon = true ij_groovy_space_after_comma = true ij_groovy_space_after_comma_in_type_arguments = true ij_groovy_space_after_for_semicolon = true ij_groovy_space_after_quest = true ij_groovy_space_after_type_cast = true ij_groovy_space_before_annotation_parameter_list = false ij_groovy_space_before_array_initializer_left_brace = false ij_groovy_space_before_catch_keyword = true ij_groovy_space_before_catch_left_brace = true ij_groovy_space_before_catch_parentheses = true ij_groovy_space_before_class_left_brace = true ij_groovy_space_before_colon = true ij_groovy_space_before_comma = false ij_groovy_space_before_do_left_brace = true ij_groovy_space_before_else_keyword = true ij_groovy_space_before_else_left_brace = true ij_groovy_space_before_finally_keyword = true ij_groovy_space_before_finally_left_brace = true ij_groovy_space_before_for_left_brace = true ij_groovy_space_before_for_parentheses = true ij_groovy_space_before_for_semicolon = false ij_groovy_space_before_if_left_brace = true ij_groovy_space_before_if_parentheses = true ij_groovy_space_before_method_call_parentheses = false ij_groovy_space_before_method_left_brace = true ij_groovy_space_before_method_parentheses = false ij_groovy_space_before_quest = true ij_groovy_space_before_switch_left_brace = true ij_groovy_space_before_switch_parentheses = true ij_groovy_space_before_synchronized_left_brace = true ij_groovy_space_before_synchronized_parentheses = true ij_groovy_space_before_try_left_brace = true ij_groovy_space_before_try_parentheses = true ij_groovy_space_before_while_keyword = true ij_groovy_space_before_while_left_brace = true ij_groovy_space_before_while_parentheses = true ij_groovy_space_within_empty_array_initializer_braces = false ij_groovy_space_within_empty_method_call_parentheses = false ij_groovy_spaces_around_additive_operators = true ij_groovy_spaces_around_assignment_operators = true ij_groovy_spaces_around_bitwise_operators = true ij_groovy_spaces_around_equality_operators = true ij_groovy_spaces_around_lambda_arrow = true ij_groovy_spaces_around_logical_operators = true ij_groovy_spaces_around_multiplicative_operators = true ij_groovy_spaces_around_relational_operators = true ij_groovy_spaces_around_shift_operators = true ij_groovy_spaces_within_annotation_parentheses = false ij_groovy_spaces_within_array_initializer_braces = false ij_groovy_spaces_within_braces = true ij_groovy_spaces_within_brackets = false ij_groovy_spaces_within_cast_parentheses = false ij_groovy_spaces_within_catch_parentheses = false ij_groovy_spaces_within_for_parentheses = false ij_groovy_spaces_within_if_parentheses = false ij_groovy_spaces_within_method_call_parentheses = false ij_groovy_spaces_within_method_parentheses = false ij_groovy_spaces_within_parentheses = false ij_groovy_spaces_within_switch_parentheses = false ij_groovy_spaces_within_synchronized_parentheses = false ij_groovy_spaces_within_try_parentheses = false ij_groovy_spaces_within_while_parentheses = false ij_groovy_special_else_if_treatment = true ij_groovy_ternary_operation_wrap = off ij_groovy_throws_keyword_wrap = off ij_groovy_throws_list_wrap = off ij_groovy_use_relative_indents = false ij_groovy_variable_annotation_wrap = off ij_groovy_while_brace_force = never ij_groovy_while_on_new_line = false ij_groovy_wrap_long_lines = false [{*.js,*.cjs}] ij_javascript_align_imports = false ij_javascript_align_multiline_array_initializer_expression = false ij_javascript_align_multiline_binary_operation = false ij_javascript_align_multiline_chained_methods = false ij_javascript_align_multiline_extends_list = false ij_javascript_align_multiline_for = true ij_javascript_align_multiline_parameters = true ij_javascript_align_multiline_parameters_in_calls = false ij_javascript_align_multiline_ternary_operation = false ij_javascript_align_object_properties = 0 ij_javascript_align_union_types = false ij_javascript_align_var_statements = 0 ij_javascript_array_initializer_new_line_after_left_brace = false ij_javascript_array_initializer_right_brace_on_new_line = false ij_javascript_array_initializer_wrap = off ij_javascript_assignment_wrap = off ij_javascript_binary_operation_sign_on_next_line = false ij_javascript_binary_operation_wrap = off ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**/*,@angular/material,@angular/material/typings/**,~/node_modules/**/*,@/node_modules/**/* ij_javascript_blank_lines_after_imports = 1 ij_javascript_blank_lines_around_class = 1 ij_javascript_blank_lines_around_field = 0 ij_javascript_blank_lines_around_function = 1 ij_javascript_blank_lines_around_method = 1 ij_javascript_block_brace_style = next_line ij_javascript_call_parameters_new_line_after_left_paren = false ij_javascript_call_parameters_right_paren_on_new_line = false ij_javascript_call_parameters_wrap = off ij_javascript_catch_on_new_line = true ij_javascript_chained_call_dot_on_new_line = true ij_javascript_class_brace_style = next_line ij_javascript_comma_on_new_line = false ij_javascript_do_while_brace_force = always ij_javascript_else_on_new_line = true ij_javascript_enforce_trailing_comma = keep ij_javascript_extends_keyword_wrap = off ij_javascript_extends_list_wrap = off ij_javascript_field_prefix = _ ij_javascript_file_name_style = relaxed ij_javascript_finally_on_new_line = true ij_javascript_for_brace_force = always ij_javascript_for_statement_new_line_after_left_paren = false ij_javascript_for_statement_right_paren_on_new_line = false ij_javascript_for_statement_wrap = off ij_javascript_force_quote_style = false ij_javascript_force_semicolon_style = false ij_javascript_function_expression_brace_style = next_line ij_javascript_if_brace_force = always ij_javascript_import_merge_members = global ij_javascript_import_prefer_absolute_path = global ij_javascript_import_sort_members = true ij_javascript_import_sort_module_name = false ij_javascript_import_use_node_resolution = true ij_javascript_imports_wrap = on_every_item ij_javascript_indent_case_from_switch = true ij_javascript_indent_chained_calls = true ij_javascript_indent_package_children = 0 ij_javascript_jsx_attribute_value = braces ij_javascript_keep_blank_lines_in_code = 2 ij_javascript_keep_first_column_comment = true ij_javascript_keep_indents_on_empty_lines = false ij_javascript_keep_line_breaks = true ij_javascript_keep_simple_blocks_in_one_line = false ij_javascript_keep_simple_methods_in_one_line = false ij_javascript_line_comment_add_space = true ij_javascript_line_comment_at_first_column = false ij_javascript_method_brace_style = next_line ij_javascript_method_call_chain_wrap = off ij_javascript_method_parameters_new_line_after_left_paren = false ij_javascript_method_parameters_right_paren_on_new_line = false ij_javascript_method_parameters_wrap = off ij_javascript_object_literal_wrap = on_every_item ij_javascript_parentheses_expression_new_line_after_left_paren = false ij_javascript_parentheses_expression_right_paren_on_new_line = false ij_javascript_place_assignment_sign_on_next_line = false ij_javascript_prefer_as_type_cast = false ij_javascript_prefer_parameters_wrap = false ij_javascript_reformat_c_style_comments = false ij_javascript_space_after_colon = true ij_javascript_space_after_comma = true ij_javascript_space_after_dots_in_rest_parameter = false ij_javascript_space_after_generator_mult = true ij_javascript_space_after_property_colon = true ij_javascript_space_after_quest = true ij_javascript_space_after_type_colon = true ij_javascript_space_after_unary_not = false ij_javascript_space_before_async_arrow_lparen = true ij_javascript_space_before_catch_keyword = true ij_javascript_space_before_catch_left_brace = true ij_javascript_space_before_catch_parentheses = true ij_javascript_space_before_class_lbrace = true ij_javascript_space_before_class_left_brace = true ij_javascript_space_before_colon = true ij_javascript_space_before_comma = false ij_javascript_space_before_do_left_brace = true ij_javascript_space_before_else_keyword = true ij_javascript_space_before_else_left_brace = true ij_javascript_space_before_finally_keyword = true ij_javascript_space_before_finally_left_brace = true ij_javascript_space_before_for_left_brace = true ij_javascript_space_before_for_parentheses = true ij_javascript_space_before_for_semicolon = false ij_javascript_space_before_function_left_parenth = true ij_javascript_space_before_generator_mult = false ij_javascript_space_before_if_left_brace = true ij_javascript_space_before_if_parentheses = true ij_javascript_space_before_method_call_parentheses = false ij_javascript_space_before_method_left_brace = true ij_javascript_space_before_method_parentheses = false ij_javascript_space_before_property_colon = false ij_javascript_space_before_quest = true ij_javascript_space_before_switch_left_brace = true ij_javascript_space_before_switch_parentheses = true ij_javascript_space_before_try_left_brace = true ij_javascript_space_before_type_colon = false ij_javascript_space_before_unary_not = false ij_javascript_space_before_while_keyword = true ij_javascript_space_before_while_left_brace = true ij_javascript_space_before_while_parentheses = true ij_javascript_spaces_around_additive_operators = true ij_javascript_spaces_around_arrow_function_operator = true ij_javascript_spaces_around_assignment_operators = true ij_javascript_spaces_around_bitwise_operators = true ij_javascript_spaces_around_equality_operators = true ij_javascript_spaces_around_logical_operators = true ij_javascript_spaces_around_multiplicative_operators = true ij_javascript_spaces_around_relational_operators = true ij_javascript_spaces_around_shift_operators = true ij_javascript_spaces_around_unary_operator = false ij_javascript_spaces_within_array_initializer_brackets = false ij_javascript_spaces_within_brackets = false ij_javascript_spaces_within_catch_parentheses = false ij_javascript_spaces_within_for_parentheses = false ij_javascript_spaces_within_if_parentheses = false ij_javascript_spaces_within_imports = false ij_javascript_spaces_within_interpolation_expressions = false ij_javascript_spaces_within_method_call_parentheses = false ij_javascript_spaces_within_method_parentheses = false ij_javascript_spaces_within_object_literal_braces = false ij_javascript_spaces_within_object_type_braces = true ij_javascript_spaces_within_parentheses = false ij_javascript_spaces_within_switch_parentheses = false ij_javascript_spaces_within_type_assertion = false ij_javascript_spaces_within_union_types = true ij_javascript_spaces_within_while_parentheses = false ij_javascript_special_else_if_treatment = true ij_javascript_ternary_operation_signs_on_next_line = false ij_javascript_ternary_operation_wrap = off ij_javascript_union_types_wrap = on_every_item ij_javascript_use_chained_calls_group_indents = false ij_javascript_use_double_quotes = true ij_javascript_use_path_mapping = always ij_javascript_use_public_modifier = false ij_javascript_use_semicolon_after_statement = true ij_javascript_var_declaration_wrap = normal ij_javascript_while_brace_force = always ij_javascript_while_on_new_line = true ij_javascript_wrap_comments = false [{*.ng,*.sht,*.html,*.shtm,*.shtml,*.htm}] indent_style = space ij_smart_tabs = false ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 ij_html_align_attributes = true ij_html_align_text = false ij_html_attribute_wrap = normal ij_html_block_comment_at_first_column = true ij_html_do_not_align_children_of_min_lines = 0 ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot ij_html_enforce_quotes = false ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var ij_html_keep_blank_lines = 2 ij_html_keep_indents_on_empty_lines = false ij_html_keep_line_breaks = true ij_html_keep_line_breaks_in_text = true ij_html_keep_whitespaces = false ij_html_keep_whitespaces_inside = span,pre,textarea ij_html_line_comment_at_first_column = true ij_html_new_line_after_last_attribute = never ij_html_new_line_before_first_attribute = never ij_html_quote_style = double ij_html_remove_new_line_before_tags = br ij_html_space_after_tag_name = false ij_html_space_around_equality_in_attribute = false ij_html_space_inside_empty_tag = false ij_html_text_wrap = normal [{*.yml,*.yaml}] ij_yaml_keep_indents_on_empty_lines = false ij_yaml_keep_line_breaks = true indent_style = space indent_size = 2 [{.eslintrc,.babelrc,composer.lock,.stylelintrc,jest.config,bowerrc,*.json,*.jsb3,*.jsb2}] indent_style = space ij_smart_tabs = false ij_json_keep_blank_lines_in_code = 0 ij_json_keep_indents_on_empty_lines = false ij_json_keep_line_breaks = true ij_json_space_after_colon = true ij_json_space_after_comma = true ij_json_space_before_colon = true ij_json_space_before_comma = false ij_json_spaces_within_braces = false ij_json_spaces_within_brackets = false ij_json_wrap_long_lines = false [{phpunit.xml.dist,*.xslt,*.xul,*.rng,*.xsl,*.xsd,*.ant,*.wadl,*.jhm,*.tld,*.fxml,*.jrxml,*.xml,*.jnlp,*.wsdl,*.wsdd,*.xjb}] indent_style = space ij_smart_tabs = false ij_xml_block_comment_at_first_column = true ij_xml_keep_indents_on_empty_lines = false ij_xml_line_comment_at_first_column = true ij_xml_space_inside_empty_tag = false [{spring.schemas,spring.handlers,*.properties}] ij_properties_align_group_field_declarations = false ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Bug report description: Something doesn't work properly and you want it fixed. labels: [ bug ] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report. - type: textarea id: what-happened attributes: label: What happened? description: Also tell us, what did you expect to happen. placeholder: Tell us what you see! validations: required: true - type: textarea id: how-to-reproduce attributes: label: How to reproduce description: List all steps as precisely as possible placeholder: | 1. Go to menu XYZ 2. Select option A 3. etc... validations: required: true - type: input id: version attributes: label: Version description: Which version of Xeres are you running? See Help/About menu. placeholder: ex. 0.2.0 validations: required: false - type: dropdown id: os attributes: label: Which OS are you running? options: - Windows - Linux - MacOS validations: required: true - type: textarea id: logs attributes: label: Relevant log output description: | Please paste any relevant log output here, if you have them. If you have an error requester, click the clipboard icon and paste the output here. Otherwise the logs location are: - Windows: %APPDATA%\Xeres\Logs\xeres.log - Linux: $HOME/.local/share/Xeres/Logs/xeres.log render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ name: Feature request description: You have some cool idea or suggestion for improvement. labels: [ feature ] body: - type: markdown attributes: value: | Thanks for your interest in improving Xeres. - type: textarea id: feature attributes: label: Description description: Your idea or improvement validations: required: true ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gradle" directory: "/" schedule: interval: "daily" ignore: - dependency-name: "org.flywaydb.flyway" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - package-ecosystem: "pip" directory: "/scripts/bot" schedule: interval: "daily" ================================================ FILE: .github/pull_request_template.md ================================================ Describe the change here Fixes #issue_number ================================================ FILE: .github/workflows/analysis.yml ================================================ name: "Analysis" on: push: branches: - master pull_request: branches: - master types: - opened - synchronize - reopened jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read # CodeQL contents: read # CodeQL security-events: write # CodeQL pull-requests: read # Sonarqube steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 # Disable shallow clone for sonarqube analysis - name: Check gradle wrapper uses: gradle/actions/wrapper-validation@v6 - name: Set up JDK uses: actions/setup-java@v5 with: java-version: 25.0.3 distribution: 'graalvm' - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: java queries: security-and-quality - name: Cache SonarCloud packages uses: actions/cache@v5 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Build, test and generate reports run: ./gradlew build test jacocoTestReport --no-build-cache --info # Disabling the build cache is needed for CodeQL (otherwise compilation output might not be generated) - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 - name: Perform Sonarqube Analysis env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: ./gradlew sonar --no-parallel --no-configuration-cache # The 2 flags make sonar work with Gradle 9. Go figure... ================================================ FILE: .github/workflows/build-docker.yml ================================================ # Docker image builder name: build-docker.yml on: workflow_dispatch: env: JAVA_VERSION: '25.0.3' JAVA_DISTRIBUTION: 'graalvm' jobs: build-docker-release: name: Build ${{ matrix.arch }} release runs-on: ${{ matrix.runner }} strategy: matrix: include: - arch: amd64 runner: ubuntu-24.04 - arch: arm64 runner: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Get version from git tag id: get_version run: echo "VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Validate Gradle uses: gradle/actions/wrapper-validation@v6 - name: Setup JDK uses: graalvm/setup-graalvm@v1 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Build Docker image run: ./gradlew bootBuildImage --imageName=${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-${{ matrix.arch }} - name: Log in to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Push Docker image run: | docker push ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-${{ matrix.arch }} create-manifest: needs: build-docker-release runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Get version from git tag id: get_version run: | git fetch --tags echo "VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Log in to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifest run: | docker manifest create ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }} \ ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-amd64 \ ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-arm64 docker manifest create ${{ secrets.DOCKERHUB_USERNAME }}/xeres:latest \ ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-amd64 \ ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-arm64 docker manifest push ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }} docker manifest push ${{ secrets.DOCKERHUB_USERNAME }}/xeres:latest ================================================ FILE: .github/workflows/build-installer.yml ================================================ # This is the installer builder. # # It's triggered automatically when a version tag is pushed. # # For manual builds, add automatic_release_tag: "Nightly", otherwise the release creation will fail. # The previous steps will work though so this can be used to check that it builds the installers. # fetch-depth has been set to 0 so that manual builds will work, otherwise it might not find the version tag # as it only fetches the latest commit history by default. # # When setting the java version, always use x.y.z even if there's more numbers, otherwise @setup-java will fail. # name: Installer build on: workflow_dispatch: push: tags: - "v*" env: JAVA_VERSION: '25.0.3' JAVA_DISTRIBUTION: 'graalvm' jobs: build-windows-installer-msi: name: Build windows installer runs-on: windows-2025 steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Validate Gradle uses: gradle/actions/wrapper-validation@v6 - name: Setup JDK uses: graalvm/setup-graalvm@v1 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Build run: .\gradlew.bat jpackage - name: Sign uses: dlemstra/code-sign-action@v1 with: certificate: '${{ secrets.CERTIFICATE }}' files: | ./app/build/distributions/Xeres-*.msi - name: Upload artifact uses: actions/upload-artifact@v7 with: path: ./app/build/distributions/Xeres-*.msi name: windows-installer-msi retention-days: 1 build-windows-installer-portable: name: Build windows portable installer runs-on: windows-2025 steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Validate Gradle uses: gradle/actions/wrapper-validation@v6 - name: Setup JDK uses: graalvm/setup-graalvm@v1 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Build run: .\gradlew.bat jpackage -P"jpackage.portable=true" - name: Upload artifact uses: actions/upload-artifact@v7 with: path: ./app/build/distributions/Xeres-*.zip name: windows-installer-portable retention-days: 1 build-macos-installer: name: Build ${{ matrix.macos-version }} installer runs-on: ${{ matrix.macos-version }} strategy: matrix: macos-version: [ macos-26 ] steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Validate Gradle uses: gradle/actions/wrapper-validation@v6 - name: Setup JDK uses: graalvm/setup-graalvm@v1 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Build run: ./gradlew jpackage - name: Rename .dmg file run: | # Extract filename DMG_FILE=$(find ./app/build/distributions -name "*.dmg" -type f | head -n 1) FILENAME=$(basename "$DMG_FILE" .dmg) # Split into components NAME_PART=$(echo "$FILENAME" | cut -d'-' -f1) # xeres VERSION_PART=$(echo "$FILENAME" | cut -d'-' -f2) # x.y.z # Create new filename NEW_FILENAME="${NAME_PART}-${VERSION_PART}-${{ matrix.macos-version }}.dmg" # Rename the file mv "./app/build/distributions/${FILENAME}.dmg" "./app/build/distributions/${NEW_FILENAME}" - name: Upload artifact uses: actions/upload-artifact@v7 with: path: ./app/build/distributions/Xeres-*.dmg name: macos-installer-${{ matrix.macos-version }} retention-days: 1 build-ubuntu-installer-deb: name: Build ${{ matrix.ubuntu-version }} installer runs-on: ${{ matrix.ubuntu-version }} strategy: matrix: ubuntu-version: [ ubuntu-22.04, ubuntu-22.04-arm, ubuntu-24.04, ubuntu-24.04-arm ] steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Validate Gradle uses: gradle/actions/wrapper-validation@v6 - name: Setup JDK uses: graalvm/setup-graalvm@v1 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Build run: ./gradlew jpackage - name: Rename .deb file run: | # Extract filename DEB_FILE=$(find ./app/build/distributions -name "*.deb" -type f | head -n 1) FILENAME=$(basename "$DEB_FILE" .deb) # Split into components NAME_PART=$(echo "$FILENAME" | cut -d'_' -f1) # xeres VERSION_PART=$(echo "$FILENAME" | cut -d'_' -f2) # x.y.z ARCH_PART=$(echo "$FILENAME" | cut -d'_' -f3-) # amd64 # Make sure we don't include the '-arm' ending as it's redundant UBUNTU_VERSION=$(echo "${{ matrix.ubuntu-version }}" | sed 's/-arm$//') # Create new filename NEW_FILENAME="${NAME_PART}_${VERSION_PART}_${UBUNTU_VERSION}_${ARCH_PART}.deb" # Rename the file mv "./app/build/distributions/${FILENAME}.deb" "./app/build/distributions/${NEW_FILENAME}" - name: Upload artifact uses: actions/upload-artifact@v7 with: path: ./app/build/distributions/xeres_*.deb name: ubuntu-installer-deb-${{ matrix.ubuntu-version }} retention-days: 1 create-release: name: Create release runs-on: ubuntu-latest needs: [ build-windows-installer-msi, build-windows-installer-portable, build-ubuntu-installer-deb, build-macos-installer ] steps: - name: Download windows installer uses: actions/download-artifact@v8 with: name: windows-installer-msi - name: Download windows portable installer uses: actions/download-artifact@v8 with: name: windows-installer-portable - name: Download ubuntu-22.04 installer uses: actions/download-artifact@v8 with: name: ubuntu-installer-deb-ubuntu-22.04 - name: Download ubuntu-24.04 installer uses: actions/download-artifact@v8 with: name: ubuntu-installer-deb-ubuntu-24.04 - name: Download ubuntu-22.04-arm installer uses: actions/download-artifact@v8 with: name: ubuntu-installer-deb-ubuntu-22.04-arm - name: Download ubuntu-24.04-arm installer uses: actions/download-artifact@v8 with: name: ubuntu-installer-deb-ubuntu-24.04-arm # ARM - name: Download macos-26 installer uses: actions/download-artifact@v8 with: name: macos-installer-macos-26 - name: Generate checksum uses: jmgilman/actions-generate-checksum@v1 with: patterns: | *.exe *.msi *.deb *.rpm *.dmg *.zip - name: Create Github release uses: marvinpinto/action-automatic-releases@v1.2.1 with: repo_token: "${{ secrets.GITHUB_TOKEN }}" prerelease: false draft: true files: | checksum.txt *.exe *.msi *.deb *.rpm *.dmg *.zip ================================================ FILE: .github/workflows/dependencies.yaml ================================================ name: "Gradle dependencies" on: push: branches: - master jobs: build: name: Dependencies runs-on: ubuntu-latest permissions: contents: write # Dependency Submission API steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 # A tag is needed for git version generation to work - name: Setup JDK uses: actions/setup-java@v5 with: java-version: 25.0.3 distribution: 'graalvm' - name: Generate and submit dependency graph uses: gradle/actions/dependency-submission@v6 ================================================ FILE: .github/workflows/qodana_code_quality.yml ================================================ #-------------------------------------------------------------------------------# # Discover additional configuration options in our documentation # # https://www.jetbrains.com/help/qodana/github.html # #-------------------------------------------------------------------------------# name: Qodana on: workflow_dispatch: pull_request: push: branches: - master jobs: qodana: runs-on: ubuntu-latest permissions: contents: write pull-requests: write checks: write steps: - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: 'Qodana Scan' uses: JetBrains/qodana-action@v2026.1.0 env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} with: # When pr-mode is set to true, Qodana analyzes only the files that have been changed pr-mode: false use-caches: true post-pr-comment: true use-annotations: true # Upload Qodana results (SARIF, other artifacts, logs) as an artifact to the job upload-result: false # quick-fixes available in Ultimate and Ultimate Plus plans push-fixes: 'none' ================================================ FILE: .gitignore ================================================ /data*/ /.idea/ /.vscode/ /.gradle/ /.venv/ ./jpb/ /*/build/ /build/ /*/out/ /*/bin/ .xeres.lock /.jpb/persistence-units.xml .run/XeresApplication.run.xml /scripts/bot/config.json /scripts/bot/avatar.png /cache/ /.proxyai/ /.project /*/.project ================================================ FILE: .run/All Tests.run.xml ================================================ true true false false ================================================ FILE: AGENTS.md ================================================ # Xeres Development Guidelines ## Project Overview Xeres is a Friend-to-Friend, decentralized, and secure communication application. It's a Gradle-based Java project with three subprojects: - **app**: Main Spring Boot application with business logic - **ui**: JavaFX desktop UI - **common**: Shared code used by both app and ui ## Build Commands ```bash # Run the application ./gradlew bootRun # Build without tests ./gradlew build -x test # Run tests ./gradlew test # Run tests with UI (if applicable) ./gradlew :ui:test # Package the application (creates MSI on Windows, AppImage on Linux) ./gradlew :app:jpackage # Create portable zip ./gradlew :app:jpackage -Pjpackage.portable=true # Clean build ./gradlew clean # Build Docker image ./gradlew :app:bootBuildImage ``` ## Architecture - Java 25 - Spring Boot 4.0.5 - JavaFX 26 (UI module) - JUnit 6 for testing - ArchUnit for architecture testing - Jacoco for code coverage - H2 database with Flyway migrations - JCA/JCE and BouncyCastle for cryptography ## Code Conventions - Follow existing code style (enforced by .editorconfig, Allman Style) - Use GPL v3 license header on new files - Branch naming: `feature/-description` or `bugfix/-description` - Package structure: `io.xeres..` ## Key Directories ``` app/src/main/java/io/xeres/app/ - Application entry point and services ui/src/main/java/io/xeres/ui/ - JavaFX controllers and views common/src/main/java/io/xeres/common/ - Shared models and utilities app/src/main/resources/db/migration/ - Flyway database migrations ``` ## Testing - Unit tests use JUnit 6 with Jupiter - UI tests use TestFX - Architecture rules are enforced via ArchUnit in `common/src/test/` and `common/src/testFixtures/` ## Dependencies - Never modify versions directly; update in `build.gradle` root version properties - Keep Spring Boot BOM and related dependencies in sync ## Skills For agents, there's a list of skills in `.agents/skills`. ## Source code file format When you create code, use UTF-8 and LF as end of lines, on all architectures. ================================================ FILE: CODE_OF_CONDUCT.md ================================================ No trolling. ================================================ FILE: CONTRIBUTING.md ================================================ # How to contribute Contributions are welcome! Please read the following in order to make it easier. ## Reporting a bug * use the [issue tracker](https://github.com/zapek/Xeres/issues) * make sure the bug is not already in the list, to avoid duplicates (a simple search should do) * if in doubt, feel free to [discuss it](https://github.com/zapek/Xeres/discussions) first ## Making a feature request * use the [issue tracker](https://github.com/zapek/Xeres/issues) * make sure the feature request is not already in the list, to avoid duplicates (a simple search should do) * if in doubt, feel free to [discuss it](https://github.com/zapek/Xeres/discussions) first ## Submitting changes * make sure you use the same formatting and style (there's an .editorconfig file that does it automatically) * create a branch using either `feature`/name if it's for adding a feature or `bugfix`/name for **simple** bugfixes (otherwise use `feature`). Use a meaningful name like `25-add-multiple-locations` (the first number being the number of the corresponding issue, if any) * write a meaningful commit message, [this link](https://chris.beams.io/posts/git-commit/) contains useful information on how to do it * create a [pull request](https://github.com/zapek/Xeres/pulls) * if in doubt, feel free to [discuss it](https://github.com/zapek/Xeres/discussions) first Thank you! ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ [![Main site](docs/logo.png)](https://xeres.io) [![GitHub release](https://img.shields.io/github/release/zapek/Xeres.svg?label=latest%20release)](https://github.com/zapek/Xeres/releases/latest) [![Downloads](https://img.shields.io/github/downloads/zapek/Xeres/total)](https://github.com/zapek/Xeres/releases/latest) [![License](https://img.shields.io/github/license/zapek/Xeres.svg?logo=gnu)](https://github.com/zapek/Xeres/blob/master/LICENSE) [![CodeQL](https://github.com/zapek/Xeres/actions/workflows/analysis.yml/badge.svg)](https://github.com/zapek/Xeres/actions/workflows/analysis.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=zapek_Xeres&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=zapek_Xeres) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9469/badge)](https://www.bestpractices.dev/projects/9469) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zapek/Xeres) [Xeres](https://xeres.io) is a Friend-to-Friend, decentralized and secure application for communication and sharing. ![Xeres Desktop](docs/screenshot-chat.jpg) [More screenshots](https://xeres.io/screenshots/) --- ## Features - 🤝 Peer-to-Peer ([Friend-to-Friend](https://en.wikipedia.org/wiki/Friend-to-friend)), fully decentralized - 🚫 No censorship - 💬 Chat directly with your friends or in chat rooms - 📢 Participate in forums and discuss any topic - 📺 Publish files and news in channels - 🖼️ Share pictures and links in boards - 📞 Make voice calls with your friends - 📂 Share and search files anonymously - 👋 Compatible with [Retroshare](https://retroshare.cc) 0.6.6 or higher - 🛠️ Hardware accelerated encryption - 🖥️ Modern looking GPU-accelerated desktop user interface with several themes including dark mode - 📶 Remote access, access your instance on the go (Android mobile client available [here](https://github.com/zapek/Xeres-Android)) - 📖 Free software ([GPL](https://www.gnu.org/licenses/quick-guide-gplv3.html)) - 😃 Available for Windows, Linux and macOS ## Releases [Latest release](https://github.com/zapek/Xeres/releases/latest) [Android mobile client](https://github.com/zapek/Xeres-Android) [Docker image](https://hub.docker.com/r/zapek/xeres) (for headless installations) ## Quick try Install Xeres then connect to a [ChatServer](https://retroshare.ch). ## Running from source [Install JDK 25](https://github.com/zapek/Xeres/wiki/Java) then type `./gradlew bootRun` ## Getting Help - [User Documentation & FAQ](https://xeres.io/docs/) - [Discussions & Forums](https://github.com/zapek/Xeres/discussions) - [Issues Reporting](https://github.com/zapek/Xeres/issues) ## Documentation - [Technical Documentation](https://github.com/zapek/Xeres/wiki) - [Roadmap](https://github.com/users/zapek/projects/4) ## Development - [Development Help](https://github.com/zapek/Xeres/wiki#development) - [Contributing](CONTRIBUTING.md) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Only the latest version is supported. If a security bug is found, a new version will be released as quickly as possible. If there's too much work remaining in the pipeline for the next version, a patch will be released (for example, 0.3.1 -> 0.3.2 while the next version will be 0.3.3). ## Reporting a Vulnerability Either [fill an issue on GitHub](https://github.com/zapek/Xeres/issues/new?assignees=zapek&labels=bug,security&template=bug_report.yaml) if you believe the issue can be public. If you prefer to keep it quiet, use the contact form [here](https://zapek.com/contact/). You should get a reply quickly (from a few hours for up to 48 hours). If the vulnerability is accepted, it will be patched as soon as possible and a new release will be made if it is present in the latest release. You will be credited in the changelog. If the vulnerability is declined, you'll get an explanation of why. ================================================ FILE: SandBox.wsb ================================================ disable Default C:\Users\zapek\workspace\Xeres\app\build\distributions true powershell -Command "Start-Process PowerShell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -Command Set-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Control\CI\Policy -Name VerifiedAndReputablePolicyState -Value 0; CiTool.exe -r' -Verb RunAs" ================================================ FILE: app/build.gradle ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ import org.panteleyev.jpackage.ImageType import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { id 'org.springframework.boot' id 'org.flywaydb.flyway' id 'org.panteleyev.jpackageplugin' id 'com.bakdata.mockito' } flyway { url = "jdbc:h2:file:${project.rootDir}/data/userdata" user = 'sa' } tasks.register('cleanLibs', Delete) { delete layout.buildDirectory.dir("libs") } bootJar { dependsOn cleanLibs manifest { attributes 'Implementation-Version': "${project.version}" attributes 'Implementation-Title': "${project.name}" } } bootRun { bootRun.jvmArgs "-ea", "-Djava.net.preferIPv4Stack=true", "-Dfile.encoding=UTF-8" bootRun.systemProperty 'spring.profiles.active', 'dev' } springBoot { buildInfo { excludes = ['time'] // make the build repeatable properties { name = rootProject.name } } } test { useJUnitPlatform() test.jvmArgs "-ea", "-Djava.net.preferIPv4Stack=true", "-Dfile.encoding=UTF-8" } tasks.register('copyInstaller', Copy) { dependsOn cleanLibs from layout.settingsDirectory.dir("installer") into layout.buildDirectory.dir("libs") } bootBuildImage { // Don't forget to set the image platform, for example: -Dimage.platform=linux-x86_64 or -Dimage.platform=linux-aarch_64 imageName = "zapek/${rootProject.name.toLowerCase(Locale.ROOT)}:${project.version}" } tasks.register('deletePortable', Delete) { delete layout.buildDirectory.dir("distributions").get().dir(rootProject.name) } tasks.register('createPortableFile') { notCompatibleWithConfigurationCache("Uses execution-time configuration filtering") doLast { def portable = new File("${layout.buildDirectory.dir("distributions").get().dir(rootProject.name)}", "Portable") if (!portable.getParentFile().exists()) { portable.getParentFile().mkdirs() } portable.createNewFile() } } tasks.register('packagePortable', Zip) { dependsOn createPortableFile finalizedBy deletePortable archiveFileName = "${rootProject.name}-${project.version}-portable.zip" destinationDirectory = layout.buildDirectory.dir("distributions") from layout.buildDirectory.dir("distributions").get().dir(rootProject.name) } jpackage { dependsOn bootJar, copyInstaller finalizedBy packagePortable appName = parent.project.name appVersion = "${project.version}".split("-")[0] vendor = "David Gerber" copyright = "Copyright 2019-2026 by David Gerber. All Rights Reserved" appDescription = parent.project.name input = layout.buildDirectory.dir("libs") destination = layout.buildDirectory.dir("distributions") mainJar = tasks.bootJar.archiveFileName.get() if (project.hasProperty("jpackage.portable")) { type = ImageType.APP_IMAGE } else { licenseFile = layout.settingsDirectory.file("LICENSE") aboutUrl = "https://xeres.io" } // Do not supply files relative to currentDir. It can change depending on how the executable is started javaOptions = ['-Djava.net.preferIPv4Stack=true', '-Dfile.encoding=UTF-8', '-splash:$APPDIR/startup.png', '-Xms256m', '-Xmx1g', '-XX:+UseZGC', '-XX:MaxMetaspaceSize=256m', '-XX:+UseCompactObjectHeaders', '-Dvisualvm.display.name=Xeres', '-Dlogging.logback.rollingpolicy.clean-history-on-start=true', '-Dlogging.logback.rollingpolicy.max-file-size=20MB', '-Dlogging.logback.rollingpolicy.max-history=3', '-Dspring.output.ansi.enabled=never'] windows { if (!project.hasProperty("jpackage.portable")) { type = ImageType.MSI winMenu = true winPerUserInstall = true winDirChooser = true winMenuGroup = parent.project.name winUpgradeUuid = "97a4aaa5-0a3f-47f9-b0a2-f91876d9e7dd" } icon = layout.settingsDirectory.file("icon.ico") } linux { if (project.hasProperty("jpackage.rpm")) { type = ImageType.RPM } linuxShortcut = true icon = layout.settingsDirectory.file("icon.png") } mac { icon = layout.settingsDirectory.file("icon.icns") } } jacocoTestReport { reports { xml.required = true html.required = false } } javadoc { options.overview = "src/main/javadoc/overview.html" } dependencies { implementation(platform(SpringBootPlugin.BOM_COORDINATES)) annotationProcessor(platform(SpringBootPlugin.BOM_COORDINATES)) developmentOnly(platform(SpringBootPlugin.BOM_COORDINATES)) annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' // handles @ConfigurationProperties implementation project(':common') implementation project(':ui') implementation 'org.springframework.boot:spring-boot-starter-jackson' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'com.h2database:h2' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation('org.springframework.boot:spring-boot-starter-webclient') { // to bring in netty, but also the WebClient that we configure exclude group: 'io.netty', module: 'netty-transport-native-epoll' // We don't use epoll exclude group: 'io.netty', module: 'netty-codec-native-quic' // Real programmer don't eat quiche (aka we don't need HTTP/3 and it adds 10 MB by using the google quiche library) } implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.security:spring-security-messaging' // seems to be missing from spring-boot-starter-security implementation 'org.springframework.boot:spring-boot-starter-flyway' implementation 'tools.jackson.datatype:jackson-datatype-jakarta-jsonp' runtimeOnly 'org.leadpony.joy:joy-classic:2.1.0' // JSON-P implementation implementation "org.bouncycastle:bcpg-jdk18on:$bouncycastleVersion" // use bcpg-debug-jdk18on for debugger support implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion" // use bcpkix-debug-jkd18on for debugger support implementation "org.jsoup:jsoup:$jsoupVersion" implementation 'com.github.atomashpolskiy:bt-dht:1.10' implementation "org.apache.commons:commons-lang3:$apacheCommonsLangVersion" implementation "org.apache.commons:commons-collections4:$apacheCommonsCollectionsVersion" implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:$springOpenApiVersion" implementation "net.java.dev.jna:jna-platform:$jnaVersion" implementation 'com.maxmind.geoip2:geoip2:5.1.0' implementation "com.google.zxing:javase:$zxingVersion" implementation 'com.sangupta:bloomfilter:0.9.0' implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:$springOpenApiVersion" implementation "org.commonmark:commonmark:$commonMarkVersion" implementation "org.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion" implementation "org.graalvm.polyglot:polyglot:$graalvmVersion" implementation "org.graalvm.polyglot:js:$graalvmVersion" implementation 'com.tianscar.javasound:javasound-speex:0.9.8' implementation "com.twelvemonkeys.imageio:imageio-webp:$twelveMonkeysVersion" implementation "com.twelvemonkeys.imageio:imageio-jpeg:$twelveMonkeysVersion" // Improved formats implementation "com.twelvemonkeys.imageio:imageio-bmp:$twelveMonkeysVersion" // Adds .ico support implementation "com.twelvemonkeys.imageio:imageio-iff:$twelveMonkeysVersion" // A blast from the past :) implementation("com.twelvemonkeys.imageio:imageio-batik:$twelveMonkeysVersion") implementation("org.apache.xmlgraphics:batik-all:1.19") developmentOnly 'org.springframework.boot:spring-boot-starter-actuator' developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: "com.vaadin.external.google", module: "android-json" } testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webflux-test' testImplementation(testFixtures(project(":common"))) testImplementation "com.tngtech.archunit:archunit-junit5:$archunitVersion" } ================================================ FILE: app/src/main/java/io/xeres/app/XeresApplication.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app; import io.xeres.app.application.environment.*; import io.xeres.common.properties.StartupProperties; import io.xeres.ui.UiStarter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import static io.xeres.common.properties.StartupProperties.Property.UI; @SpringBootApplication(scanBasePackageClasses = {io.xeres.app.XeresApplication.class, io.xeres.ui.UiStarter.class}) public class XeresApplication { private static final Logger log = LoggerFactory.getLogger(XeresApplication.class); // Spring Boot requires main to be static, always static void main(String[] args) { DefaultProperties.setDefaults(); Cloud.checkIfRunningOnCloud(); HostVariable.parse(); CommandArgument.parse(args); LocalPortFinder.ensureFreePort(); if (!StartupProperties.getBoolean(UI, true)) { log.info("no gui mode"); SpringApplication.run(XeresApplication.class, args); } else { log.info("gui mode"); UiStarter.start(XeresApplication.class, args); // this starts spring as well } } } ================================================ FILE: app/src/main/java/io/xeres/app/api/DefaultHandler.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.info.Contact; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.info.License; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityScheme; import io.xeres.app.api.exception.UnprocessableEntityException; import io.xeres.common.AppName; import jakarta.persistence.EntityExistsException; import jakarta.persistence.EntityNotFoundException; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.*; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import java.net.UnknownHostException; import java.util.Arrays; import java.util.NoSuchElementException; import java.util.Optional; @RestControllerAdvice @OpenAPIDefinition( info = @Info( title = AppName.NAME, version = "1.0", summary = "A decentralized and secure application for communication and sharing", license = @License(name = "GPL v3", url = "https://www.gnu.org/licenses/gpl-3.0.en.html"), contact = @Contact(name = "Xeres", url = "https://xeres.io"), description = """ This is the REST interface for controlling the application. Don't forget to use the _Authorize_ button on the right to enter the same credentials as the ones in _Settings / Remote_ (you can cut & paste, don't forget to make the password visible first or it will copy asterisks). **Note**: because some swagger-ui developers are [braindead](https://github.com/swagger-api/swagger-ui/issues/2030), 64-bit values output are truncated to 53-bit ones. """ ), security = @SecurityRequirement( name = "api" // Mark all endpoints as authenticated. Otherwise, remove and add @SecurityRequirement(name = "api") separately to all controller classes or methods ) ) @SecurityScheme(name = "api", scheme = "basic", type = SecuritySchemeType.HTTP, in = SecuritySchemeIn.HEADER) public class DefaultHandler extends ResponseEntityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(DefaultHandler.class); public static final String TRACE = "trace"; @ExceptionHandler({ NoSuchElementException.class, EntityNotFoundException.class, UnknownHostException.class}) public ErrorResponse handleNotFoundException(Exception e) { logError(e, log.isDebugEnabled()); return ErrorResponse.builder(e, HttpStatus.NOT_FOUND, e.getMessage()) .property(TRACE, ExceptionUtils.getStackTrace(e)) .build(); } @ExceptionHandler(UnprocessableEntityException.class) public ErrorResponse handleUnprocessableEntityException(UnprocessableEntityException e) { logError(e, log.isDebugEnabled()); return ErrorResponse.builder(e, HttpStatus.UNPROCESSABLE_CONTENT, e.getMessage()) .property(TRACE, ExceptionUtils.getStackTrace(e)) .build(); } @ExceptionHandler(EntityExistsException.class) public ErrorResponse handleEntityExistsException(EntityExistsException e) { logError(e, log.isDebugEnabled()); return ErrorResponse.builder(e, HttpStatus.CONFLICT, e.getMessage()) .property(TRACE, ExceptionUtils.getStackTrace(e)) .build(); } @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { logError(e, log.isDebugEnabled()); return ErrorResponse.builder(e, HttpStatus.BAD_REQUEST, e.getMessage()) .property(TRACE, ExceptionUtils.getStackTrace(e)) .build(); } @ExceptionHandler(Exception.class) public ErrorResponse handleException(Exception e) { logError(e, true); return ErrorResponse.builder(e, HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()) .property(TRACE, ExceptionUtils.getStackTrace(e)) .build(); } /** * Generates a ResponseStatusException. Those are typically done from media endpoints * and there's no way to put JSON error messages in there, so just ignore them. * * @param e the exception * @return a ResponseEntity with just the status code and no message */ @ExceptionHandler(ResponseStatusException.class) public ResponseEntity handleResponseStatusException(ResponseStatusException e) { return new ResponseEntity<>(e.getStatusCode()); } // This one has to use an override @Override protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { var problemDetail = handleValidationException(ex); return ResponseEntity.status(status.value()).body(problemDetail); } private ProblemDetail handleValidationException(MethodArgumentNotValidException ex) { var details = Optional.of(ex.getDetailMessageArguments()) .map(args -> Arrays.stream(args) .filter(msg -> !ObjectUtils.isEmpty(msg)) .reduce("Wrong input,", (a, b) -> a + " " + b) ) .orElse("").toString(); var problemDetail = ProblemDetail.forStatusAndDetail(ex.getStatusCode(), details); problemDetail.setInstance(ex.getBody().getInstance()); return problemDetail; } private void logError(Exception e, boolean withStackTrace) { if (withStackTrace) { log.error("{}: {}", e.getClass().getSimpleName(), e.getMessage(), e); } else { log.error("{}: {}", e.getClass().getSimpleName(), e.getMessage()); } } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/board/BoardController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.board; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.service.BoardMessageService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.board.BoardRsService; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.common.dto.board.BoardGroupDTO; import io.xeres.common.dto.board.BoardMessageDTO; import io.xeres.common.id.MsgId; import io.xeres.common.rest.board.UpdateBoardMessageReadRequest; import io.xeres.common.util.image.ImageUtils; import jakarta.validation.Valid; import org.apache.commons.collections4.CollectionUtils; import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import static io.xeres.app.database.model.board.BoardMapper.*; import static io.xeres.common.rest.PathConfig.BOARDS_PATH; @Tag(name = "Boards", description = "Boards") @RestController @RequestMapping(value = BOARDS_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class BoardController { private final BoardRsService boardRsService; private final IdentityService identityService; private final BoardMessageService boardMessageService; private final UnHtmlService unHtmlService; public BoardController(BoardRsService boardRsService, IdentityService identityService, BoardMessageService boardMessageService, UnHtmlService unHtmlService) { this.boardRsService = boardRsService; this.identityService = identityService; this.boardMessageService = boardMessageService; this.unHtmlService = unHtmlService; } @GetMapping("/groups") @Operation(summary = "Gets the list of boards") public List getBoardGroups() { return toDTOs(boardRsService.findAllGroups()); } @PostMapping(value = "/groups", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Creates a board") @ApiResponse(responseCode = "201", description = "Board created successfully", headers = @Header(name = "Board", description = "The location of the created board", schema = @Schema(type = "string"))) public ResponseEntity createBoardGroup(@RequestParam(value = "name") String name, @RequestParam(value = "description") String description, @RequestParam(value = "image", required = false) MultipartFile imageFile) throws IOException { var ownIdentity = identityService.getOwnIdentity(); var id = boardRsService.createBoardGroup(ownIdentity.getGxsId(), name, description, imageFile); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(BOARDS_PATH + "/groups/{id}").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @PutMapping(value = "/groups/{groupId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Updates a board") @ResponseStatus(HttpStatus.NO_CONTENT) public void updateBoardGroup(@PathVariable long groupId, @RequestParam(value = "name") String name, @RequestParam(value = "description") String description, @RequestParam(value = "image", required = false) MultipartFile imageFile, @RequestParam(value = "updateImage", required = false) Boolean updateImage) throws IOException { boardRsService.updateBoardGroup(groupId, name, description, imageFile, updateImage != null ? updateImage : false); } @GetMapping(value = "/groups/{id}/image", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE}) @Operation(summary = "Returns an board's image") @ApiResponse(responseCode = "200", description = "Board image found") @ApiResponse(responseCode = "204", description = "Board image is empty") @ApiResponse(responseCode = "404", description = "Board not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity downloadBoardGroupImage(@PathVariable long id) { var group = boardRsService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype var imageType = ImageUtils.getImageMimeType(group.getImage()); if (imageType == null) { return ResponseEntity.noContent().build(); } return ResponseEntity.ok() .contentLength(group.getImage().length) .contentType(imageType) .body(new InputStreamResource(new ByteArrayInputStream(group.getImage()))); } @GetMapping("/groups/{groupId}") @Operation(summary = "Gets the details of a board") @ApiResponse(responseCode = "200", description = "Request successful") public BoardGroupDTO getBoardGroupById(@PathVariable long groupId) { return toDTO(boardRsService.findById(groupId).orElseThrow()); } @GetMapping("/groups/{groupId}/unread-count") @Operation(summary = "Get the unread count of a board") public int getBoardUnreadCount(@PathVariable long groupId) { return boardRsService.getUnreadCount(groupId); } @PutMapping("/groups/{groupId}/subscription") @Operation(summary = "Subscribes to a board") @ResponseStatus(HttpStatus.NO_CONTENT) public void subscribeToBoardGroup(@PathVariable long groupId) { boardRsService.subscribeToBoardGroup(groupId); } @PutMapping("/groups/{groupId}/read") @Operation(summary = "Sets all messages in the group as read or unread") @ResponseStatus(HttpStatus.NO_CONTENT) public void setAllGroupMessagesReadState(@PathVariable long groupId, @RequestParam(value = "read") Boolean read) { boardRsService.setAllGroupMessagesReadState(groupId, read); } @DeleteMapping("/groups/{groupId}/subscription") @Operation(summary = "Unsubscribes from a board") @ResponseStatus(HttpStatus.NO_CONTENT) public void unsubscribeFromBoardGroup(@PathVariable long groupId) { boardRsService.unsubscribeFromBoardGroup(groupId); } @GetMapping("/groups/{groupId}/messages") @Operation(summary = "Gets the messages from a group") public Page getBoardMessages(@PathVariable long groupId, @PageableDefault(size = 50, sort = {"published"}, direction = Direction.DESC) Pageable pageable) { var boardMessages = boardRsService.findAllMessages(groupId, pageable); return new PageImpl<>(toBoardMessageDTOs(unHtmlService, boardMessages, boardMessageService.getAuthorsMapFromMessages(boardMessages), boardMessageService.getMessagesMapFromSummaries(groupId, boardMessages)), pageable, boardMessages.getTotalElements()); } @GetMapping("/messages/{messageId}") @Operation(summary = "Gets a message") @ApiResponse(responseCode = "200", description = "Request successful") public BoardMessageDTO getBoardMessage(@PathVariable long messageId) { var boardMessage = boardRsService.findMessageById(messageId).orElseThrow(); Objects.requireNonNull(boardMessage, "Board message " + messageId + " not found"); var author = identityService.findByGxsId(boardMessage.getAuthorGxsId()); HashSet messageSet = HashSet.newHashSet(2); // they can be null so no Set.of() possible CollectionUtils.addIgnoreNull(messageSet, boardMessage.getOriginalMsgId()); CollectionUtils.addIgnoreNull(messageSet, boardMessage.getParentMsgId()); var messages = boardRsService.findAllMessagesIncludingOlds(boardMessage.getGxsId(), messageSet).stream() .collect(Collectors.toMap(BoardMessageItem::getMsgId, BoardMessageItem::getId)); return toDTO( unHtmlService, boardMessage, author.map(GxsGroupItem::getName).orElse(null), messages.getOrDefault(boardMessage.getOriginalMsgId(), 0L), messages.getOrDefault(boardMessage.getParentMsgId(), 0L) ); } @PostMapping(value = "/messages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Creates a board message") @ApiResponse(responseCode = "201", description = "Board message created successfully", headers = @Header(name = "Message", description = "The location of the created message", schema = @Schema(type = "string"))) public ResponseEntity createBoardMessage(@RequestParam(value = "boardId") long boardId, @RequestParam(value = "title") String title, @RequestParam(value = "content", required = false) String content, @RequestParam(value = "link", required = false) String link, @RequestParam(value = "image", required = false) MultipartFile imageFile) throws IOException { var ownIdentity = identityService.getOwnIdentity(); var id = boardRsService.createBoardMessage( ownIdentity, boardId, title, content, link, imageFile ); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(BOARDS_PATH + "/messages/{id}").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @GetMapping(value = "/messages/{id}/image", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, MediaType.IMAGE_GIF_VALUE, "image/webp"}) @Operation(summary = "Returns an board message's image") @ApiResponse(responseCode = "200", description = "Board message image found") @ApiResponse(responseCode = "204", description = "Board message image is empty") @ApiResponse(responseCode = "404", description = "Board not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity downloadBoardMessageImage(@PathVariable long id) { var group = boardRsService.findMessageById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype var imageType = ImageUtils.getImageMimeType(group.getImage()); if (imageType == null) { return ResponseEntity.noContent().build(); } return ResponseEntity.ok() .contentLength(group.getImage().length) .contentType(imageType) .body(new InputStreamResource(new ByteArrayInputStream(group.getImage()))); } @PatchMapping("/messages") @Operation(summary = "Modifies board message read state") @ResponseStatus(HttpStatus.OK) public void setBoardMessageReadState(@Valid @RequestBody UpdateBoardMessageReadRequest request) { boardRsService.setMessageReadState(request.messageId(), request.read()); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/channel/ChannelController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.channel; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.database.model.channel.ChannelMapper; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.service.ChannelMessageService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.channel.ChannelRsService; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.common.dto.channel.ChannelFileDTO; import io.xeres.common.dto.channel.ChannelGroupDTO; import io.xeres.common.dto.channel.ChannelMessageDTO; import io.xeres.common.id.MsgId; import io.xeres.common.rest.channel.UpdateChannelMessageReadRequest; import io.xeres.common.util.image.ImageUtils; import jakarta.validation.Valid; import org.apache.commons.collections4.CollectionUtils; import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import static io.xeres.app.database.model.channel.ChannelMapper.*; import static io.xeres.common.rest.PathConfig.CHANNELS_PATH; @Tag(name = "Channels", description = "Channels") @RestController @RequestMapping(value = CHANNELS_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ChannelController { private final ChannelRsService channelRsService; private final IdentityService identityService; private final ChannelMessageService channelMessageService; private final UnHtmlService unHtmlService; public ChannelController(ChannelRsService channelRsService, IdentityService identityService, ChannelMessageService channelMessageService, UnHtmlService unHtmlService) { this.channelRsService = channelRsService; this.identityService = identityService; this.channelMessageService = channelMessageService; this.unHtmlService = unHtmlService; } @GetMapping("/groups") @Operation(summary = "Gets the list of channels") public List getChannelGroups() { return toDTOs(channelRsService.findAllGroups()); } @PostMapping(value = "/groups", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Creates a channel") @ApiResponse(responseCode = "201", description = "Channel created successfully", headers = @Header(name = "Channel", description = "The location of the created channel", schema = @Schema(type = "string"))) public ResponseEntity createChannelGroup(@RequestParam(value = "name") String name, @RequestParam(value = "description") String description, @RequestParam(value = "image", required = false) MultipartFile imageFile) throws IOException { var ownIdentity = identityService.getOwnIdentity(); var id = channelRsService.createChannelGroup(ownIdentity.getGxsId(), name, description, imageFile); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(CHANNELS_PATH + "/groups/{id}").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @PutMapping(value = "/groups/{groupId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Updates a channel") @ResponseStatus(HttpStatus.NO_CONTENT) public void updateChannelGroup(@PathVariable long groupId, @RequestParam(value = "name") String name, @RequestParam(value = "description") String description, @RequestParam(value = "image", required = false) MultipartFile imageFile, @RequestParam(value = "updateImage", required = false) Boolean updateImage) throws IOException { channelRsService.updateChannelGroup(groupId, name, description, imageFile, updateImage != null ? updateImage : false); } @GetMapping(value = "/groups/{id}/image", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE}) @Operation(summary = "Returns a channel's image") @ApiResponse(responseCode = "200", description = "Channel's image found") @ApiResponse(responseCode = "204", description = "Channel's image is empty") @ApiResponse(responseCode = "404", description = "Channel not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity downloadChannelGroupImage(@PathVariable long id) { var group = channelRsService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype var imageType = ImageUtils.getImageMimeType(group.getImage()); if (imageType == null) { return ResponseEntity.noContent().build(); } return ResponseEntity.ok() .contentLength(group.getImage().length) .contentType(imageType) .body(new InputStreamResource(new ByteArrayInputStream(group.getImage()))); } @GetMapping("/groups/{groupId}") @Operation(summary = "Gets the details of a channel") @ApiResponse(responseCode = "200", description = "Request successful") public ChannelGroupDTO getChannelGroupById(@PathVariable long groupId) { return toDTO(channelRsService.findById(groupId).orElseThrow()); } @GetMapping("/groups/{groupId}/unread-count") @Operation(summary = "Get the unread count of a channel") public int getChannelUnreadCount(@PathVariable long groupId) { return channelRsService.getUnreadCount(groupId); } @PutMapping("/groups/{groupId}/subscription") @Operation(summary = "Subscribes to a channel") @ResponseStatus(HttpStatus.NO_CONTENT) public void subscribeToChannelGroup(@PathVariable long groupId) { channelRsService.subscribeToChannelGroup(groupId); } @PutMapping("/groups/{groupId}/read") @Operation(summary = "Sets all messages in the group as read or unread") @ResponseStatus(HttpStatus.NO_CONTENT) public void setAllGroupMessagesReadState(@PathVariable long groupId, @RequestParam(value = "read") Boolean read) { channelRsService.setAllGroupMessagesReadState(groupId, read); } @DeleteMapping("/groups/{groupId}/subscription") @Operation(summary = "Unsubscribes from a channel") @ResponseStatus(HttpStatus.NO_CONTENT) public void unsubscribeFromChannelGroup(@PathVariable long groupId) { channelRsService.unsubscribeFromChannelGroup(groupId); } @GetMapping("/groups/{groupId}/messages") @Operation(summary = "Gets the summary of messages in a group") public Page getChannelMessages(@PathVariable long groupId, @PageableDefault(size = 50, sort = {"published"}, direction = Sort.Direction.DESC) Pageable pageable) { var channelMessages = channelRsService.findAllMessages(groupId, pageable); return new PageImpl<>(toSummaryMessageDTOs(channelMessages, channelMessageService.getAuthorsMapFromMessages(channelMessages), channelMessageService.getMessagesMapFromSummaries(groupId, channelMessages)), pageable, channelMessages.getTotalElements()); } @GetMapping("/messages/{messageId}") @Operation(summary = "Gets a message") @ApiResponse(responseCode = "200", description = "Request successful") public ChannelMessageDTO getChannelMessage(@PathVariable long messageId) { var channelMessage = channelRsService.findMessageById(messageId).orElseThrow(); Objects.requireNonNull(channelMessage, "Channel message " + messageId + " not found"); var author = identityService.findByGxsId(channelMessage.getAuthorGxsId()); HashSet messageSet = HashSet.newHashSet(2); // they can be null so no Set.of() possible CollectionUtils.addIgnoreNull(messageSet, channelMessage.getOriginalMsgId()); CollectionUtils.addIgnoreNull(messageSet, channelMessage.getParentMsgId()); var messages = channelRsService.findAllMessagesIncludingOlds(channelMessage.getGxsId(), messageSet).stream() .collect(Collectors.toMap(ChannelMessageItem::getMsgId, ChannelMessageItem::getId)); return toDTO( unHtmlService, channelMessage, author.map(GxsGroupItem::getName).orElse(null), messages.getOrDefault(channelMessage.getOriginalMsgId(), 0L), messages.getOrDefault(channelMessage.getParentMsgId(), 0L), true ); } @PostMapping(value = "/messages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Creates a channel message") @ApiResponse(responseCode = "201", description = "Channel message created successfully", headers = @Header(name = "Message", description = "The location of the created message", schema = @Schema(type = "string"))) public ResponseEntity createChannelMessage(@RequestParam(value = "channelId") long channelId, @RequestParam(value = "title") String title, @RequestParam(value = "content", required = false) String content, @RequestParam(value = "originalId", required = false) Long originalId, @RequestParam(value = "image", required = false) MultipartFile imageFile, @RequestPart(value = "files", required = false) List files) throws IOException { var ownIdentity = identityService.getOwnIdentity(); var id = channelRsService.createChannelMessage( ownIdentity, channelId, title, content, imageFile, ChannelMapper.toFileItems(files), originalId != null ? originalId : 0L ); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(CHANNELS_PATH + "/messages/{id}").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @GetMapping(value = "/messages/{id}/image", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE}) @Operation(summary = "Returns a channel message's image") @ApiResponse(responseCode = "200", description = "Channel message image found") @ApiResponse(responseCode = "204", description = "Channel message image is empty") @ApiResponse(responseCode = "404", description = "Channel not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity downloadChannelMessageImage(@PathVariable long id) { var group = channelRsService.findMessageById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype var imageType = ImageUtils.getImageMimeType(group.getImage()); if (imageType == null) { return ResponseEntity.noContent().build(); } return ResponseEntity.ok() .contentLength(group.getImage().length) .contentType(imageType) .body(new InputStreamResource(new ByteArrayInputStream(group.getImage()))); } @PatchMapping("/messages") @Operation(summary = "Modifies channel message read state") @ResponseStatus(HttpStatus.OK) public void setChannelMessageReadState(@Valid @RequestBody UpdateChannelMessageReadRequest request) { channelRsService.setMessageReadState(request.messageId(), request.read()); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/chat/ChatController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.chat; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.service.IdentityService; import io.xeres.app.service.LocationService; import io.xeres.app.xrs.service.chat.ChatBacklogService; import io.xeres.app.xrs.service.chat.ChatRsService; import io.xeres.app.xrs.service.chat.RoomFlags; import io.xeres.common.dto.chat.ChatBacklogDTO; import io.xeres.common.dto.chat.ChatRoomBacklogDTO; import io.xeres.common.dto.chat.ChatRoomContextDTO; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.rest.chat.ChatRoomVisibility; import io.xeres.common.rest.chat.CreateChatRoomRequest; import io.xeres.common.rest.chat.DistantChatRequest; import io.xeres.common.rest.chat.InviteToChatRoomRequest; import jakarta.persistence.EntityExistsException; import jakarta.persistence.EntityNotFoundException; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.EnumSet; import java.util.List; import java.util.stream.Collectors; import static io.xeres.app.database.model.chat.ChatMapper.fromDistantChatBacklogToChatBacklogDTOs; import static io.xeres.app.database.model.chat.ChatMapper.toChatBacklogDTOs; import static io.xeres.app.database.model.chat.ChatMapper.toChatRoomBacklogDTOs; import static io.xeres.app.database.model.chat.ChatMapper.toDTO; import static io.xeres.app.database.model.location.LocationMapper.toDTO; import static io.xeres.common.rest.PathConfig.CHAT_PATH; @Tag(name = "Chat", description = "Chat rooms, private messages, distant chats, ...", externalDocs = @ExternalDocumentation(url = "https://github.com/zapek/Xeres/wiki/Chat", description = "Chat protocol")) @RestController @RequestMapping(value = CHAT_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ChatController { private static final int PRIVATE_CHAT_DEFAULT_MAX_LINES = 20; private static final Duration PRIVATE_CHAT_DEFAULT_DURATION = Duration.ofDays(7); private static final int ROOM_CHAT_DEFAULT_MAX_LINES = 50; private static final Duration ROOM_CHAT_DEFAULT_DURATION = Duration.ofDays(7); private final ChatRsService chatRsService; private final ChatBacklogService chatBacklogService; private final LocationService locationService; private final IdentityService identityService; public ChatController(ChatRsService chatRsService, ChatBacklogService chatBacklogService, LocationService locationService, IdentityService identityService) { this.chatRsService = chatRsService; this.chatBacklogService = chatBacklogService; this.locationService = locationService; this.identityService = identityService; } @PostMapping("/rooms") @Operation(summary = "Creates a chat room") @ApiResponse(responseCode = "201", description = "Chat room created successfully", headers = @Header(name = "Room", description = "The location of the created chat room", schema = @Schema(type = "string"))) public ResponseEntity createChatRoom(@Valid @RequestBody CreateChatRoomRequest createChatRoomRequest) { var id = chatRsService.createChatRoom(createChatRoomRequest.name(), createChatRoomRequest.topic(), createChatRoomRequest.visibility() == ChatRoomVisibility.PUBLIC ? EnumSet.of(RoomFlags.PUBLIC) : EnumSet.noneOf(RoomFlags.class), createChatRoomRequest.signedIdentities()); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(CHAT_PATH + "/rooms/{id}").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @PostMapping("/rooms/invite") @Operation(summary = "Invites one or several locations to a chat room") public void inviteToChatRoom(@Valid @RequestBody InviteToChatRoomRequest inviteToChatRoomRequest) { chatRsService.inviteLocationsToChatRoom(inviteToChatRoomRequest.chatRoomId(), inviteToChatRoomRequest.locationIdentifiers().stream() .map(LocationIdentifier::fromString) .collect(Collectors.toSet())); } @PutMapping("/rooms/{id}/subscription") @Operation(summary = "Subscribes to a chat room") @ResponseStatus(HttpStatus.NO_CONTENT) public void subscribeToChatRoom(@PathVariable @Parameter(description = "The room's unique 64-bit identifier") long id) { chatRsService.joinChatRoom(id); } @DeleteMapping("/rooms/{id}/subscription") @Operation(summary = "Unsubscribes from a chat room") @ResponseStatus(HttpStatus.NO_CONTENT) public void unsubscribeFromChatRoom(@PathVariable @Parameter(description = "The room's unique 64-bit identifier") long id) { chatRsService.leaveChatRoom(id); } @GetMapping("/rooms") @Operation(summary = "Gets a chat room context", description = "The context contains all rooms, status, current nickname, etc...") public ChatRoomContextDTO getChatRoomContext() { return toDTO(chatRsService.getChatRoomContext()); } @GetMapping("/rooms/{roomId}/messages") @Operation(summary = "Gets the chat room messages backlog") @ApiResponse(responseCode = "200", description = "OK") @ApiResponse(responseCode = "404", description = "No room found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public List getChatRoomMessages(@PathVariable @Parameter(description = "The room's unique 64-bit identifier") long roomId, @RequestParam(value = "maxLines", required = false) @Min(1) @Max(500) Integer maxLines, @RequestParam(value = "from", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from) { return toChatRoomBacklogDTOs(chatBacklogService.getChatRoomMessages( roomId, from != null ? from.toInstant(ZoneOffset.UTC) : Instant.now().minus(ROOM_CHAT_DEFAULT_DURATION), maxLines != null ? maxLines : ROOM_CHAT_DEFAULT_MAX_LINES)); } @DeleteMapping("/rooms/{roomId}/messages") @Operation(summary = "Clears the chat room messages backlog of a given chat room") @ResponseStatus(HttpStatus.NO_CONTENT) @ApiResponse(responseCode = "404", description = "No chat room found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public void deleteChatRoomMessages(@PathVariable @Parameter(description = "The room's unique 64-bit identifier") long roomId) { chatBacklogService.deleteChatRoomMessages(roomId); } @GetMapping("/chats/{locationId}/messages") @Operation(summary = "Gets the private chat messages backlog") @ApiResponse(responseCode = "200", description = "OK") @ApiResponse(responseCode = "404", description = "No location found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public List getChatMessages(@PathVariable @Parameter(description = "The location id") long locationId, @RequestParam(value = "maxLines", required = false) @Min(1) @Max(500) Integer maxLines, @RequestParam(value = "from", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from) { var location = locationService.findLocationById(locationId).orElseThrow(); return toChatBacklogDTOs(chatBacklogService.getMessages( location, from != null ? from.toInstant(ZoneOffset.UTC) : Instant.now().minus(PRIVATE_CHAT_DEFAULT_DURATION), maxLines != null ? maxLines : PRIVATE_CHAT_DEFAULT_MAX_LINES)); } @DeleteMapping("/chats/{locationId}/messages") @Operation(summary = "Clears the private chat messages backlog of a given location") @ResponseStatus(HttpStatus.NO_CONTENT) @ApiResponse(responseCode = "404", description = "No location found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public void deleteChatMessages(@PathVariable @Parameter(description = "The location id") long locationId) { var location = locationService.findLocationById(locationId).orElseThrow(); chatBacklogService.deleteMessages(location); } @PostMapping("/distant-chats") @Operation(summary = "Creates a distant chat") @ApiResponse(responseCode = "200", description = "OK") @ApiResponse(responseCode = "404", description = "No identity found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "409", description = "Tunnel already exists", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public LocationDTO createDistantChat(@Valid @RequestBody DistantChatRequest distantChatRequest) { var identity = identityService.findById(distantChatRequest.identityId()).orElseThrow(); var location = toDTO(chatRsService.createDistantChat(identity)); if (location == null) { throw new EntityExistsException("Distant chat already active"); } return location; } @DeleteMapping("/distant-chats/{identityId}") @Operation(summary = "Closes a distant chat") @ResponseStatus(HttpStatus.NO_CONTENT) @ApiResponse(responseCode = "404", description = "No identity found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) void closeDistantChat(@PathVariable long identityId) { var identity = identityService.findById(identityId).orElseThrow(); if (!chatRsService.closeDistantChat(identity)) { throw new EntityNotFoundException("No distant chat for identity id " + identityId); } } @GetMapping("/distant-chats/{identityId}/messages") @Operation(summary = "Gets the distant chat messages backlog of a given identity") @ApiResponse(responseCode = "200", description = "OK") @ApiResponse(responseCode = "404", description = "No identity found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public List getDistantChatMessages(@PathVariable @Parameter(description = "The identity id") long identityId, @RequestParam(value = "maxLines", required = false) @Min(1) @Max(500) Integer maxLines, @RequestParam(value = "from", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from) { var identity = identityService.findById(identityId).orElseThrow(); return fromDistantChatBacklogToChatBacklogDTOs(chatBacklogService.getDistantMessages( identity, from != null ? from.toInstant(ZoneOffset.UTC) : Instant.now().minus(PRIVATE_CHAT_DEFAULT_DURATION), maxLines != null ? maxLines : PRIVATE_CHAT_DEFAULT_MAX_LINES)); } @DeleteMapping("/distant-chats/{identityId}/messages") @Operation(summary = "Clears the distant chat messages backlog of a given identity") @ResponseStatus(HttpStatus.NO_CONTENT) @ApiResponse(responseCode = "404", description = "No identity found for given id", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public void deleteDistantChatMessages(@PathVariable @Parameter(description = "The identity id") long identityId) { var identity = identityService.findById(identityId).orElseThrow(); chatBacklogService.deleteDistantMessages(identity); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/chat/ChatMessageController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.chat; import io.xeres.app.service.MessageService; import io.xeres.app.xrs.service.chat.ChatRsService; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.ChatMessage; import io.xeres.common.message.chat.ChatRoomMessage; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.stereotype.Controller; import java.util.Objects; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; import static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE; import static io.xeres.common.message.MessagePath.*; /** * This controller receives WebSocket messages sent to /app, which means they're produced by the app user. *

* WebSocket diagram */ @Controller @MessageMapping(CHAT_ROOT) public class ChatMessageController { private static final Logger log = LoggerFactory.getLogger(ChatMessageController.class); private final ChatRsService chatRsService; private final MessageService messageService; public ChatMessageController(ChatRsService chatRsService, MessageService messageService) { this.chatRsService = chatRsService; this.messageService = messageService; } @MessageMapping(CHAT_PRIVATE_DESTINATION) public void processPrivateChatMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatMessage chatMessage) { switch (messageType) { case CHAT_PRIVATE_MESSAGE -> { logMessage("Received private chat websocket message, sending to peer location: " + destinationId, chatMessage.getContent()); var locationIdentifier = LocationIdentifier.fromString(destinationId); chatRsService.sendPrivateMessage(locationIdentifier, chatMessage.getContent()); chatMessage.setOwn(true); messageService.sendToConsumers(chatPrivateDestination(), messageType, locationIdentifier, chatMessage); } case CHAT_TYPING_NOTIFICATION -> { log.debug("Sending private chat typing notification..."); Objects.requireNonNull(destinationId); chatRsService.sendPrivateTypingNotification(LocationIdentifier.fromString(destinationId)); } case CHAT_AVATAR -> { log.debug("Requesting private chat avatar..."); Objects.requireNonNull(destinationId); chatRsService.sendAvatarRequest(LocationIdentifier.fromString(destinationId)); } default -> throw new IllegalStateException("Unexpected value: " + messageType); } } @MessageMapping(CHAT_DISTANT_DESTINATION) public void processDistantChatMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatMessage chatMessage) { switch (messageType) { case CHAT_PRIVATE_MESSAGE -> { logMessage("Received distant chat websocket message, sending to peer gxsId: " + destinationId, chatMessage.getContent()); var gxsId = GxsId.fromString(destinationId); chatRsService.sendPrivateMessage(gxsId, chatMessage.getContent()); chatMessage.setOwn(true); messageService.sendToConsumers(chatDistantDestination(), messageType, gxsId, chatMessage); } case CHAT_TYPING_NOTIFICATION -> { log.debug("Sending distant chat typing notification..."); Objects.requireNonNull(destinationId); chatRsService.sendPrivateTypingNotification(GxsId.fromString(destinationId)); } default -> throw new IllegalStateException("Unexpected value: " + messageType); } } @MessageMapping(CHAT_ROOM_DESTINATION) public void processChatRoomMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatRoomMessage chatRoomMessage) { switch (messageType) { case CHAT_ROOM_MESSAGE -> { logMessage("Sending to room " + destinationId + ", size: " + (chatRoomMessage.isEmpty() ? 0 : chatRoomMessage.getContent().length()), chatRoomMessage.getContent()); Objects.requireNonNull(destinationId); var chatRoomId = Long.parseLong(destinationId); chatRsService.sendChatRoomMessage(chatRoomId, chatRoomMessage.getContent()); messageService.sendToConsumers(chatRoomDestination(), messageType, chatRoomId, chatRoomMessage); } case CHAT_ROOM_TYPING_NOTIFICATION -> { log.debug("Sending chat room typing notification..."); Objects.requireNonNull(destinationId); chatRsService.sendChatRoomTypingNotification(Long.parseLong(destinationId)); } default -> throw new IllegalStateException("Unexpected value: " + messageType); } } @MessageMapping(CHAT_BROADCAST_DESTINATION) public void processBroadcastMessageFromProducer(@Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatMessage chatMessage) { switch (messageType) { case CHAT_BROADCAST_MESSAGE -> { logMessage("Sending broadcast message", chatMessage.getContent()); chatRsService.sendBroadcastMessage(chatMessage.getContent()); } default -> throw new IllegalStateException("Unexpected value: " + messageType); } } @MessageExceptionHandler @SendToUser(DIRECT_PREFIX + "/errors") // XXX: how can we use this? Well, it works... just have to subscribe to it public String handleException(Throwable e) { log.debug("Got exception: {}", e.getMessage(), e); return e.getMessage(); } private void logMessage(String info, String message) { if (log.isTraceEnabled()) { log.trace("{}, content: {}", info, message); } else if (log.isDebugEnabled()) { log.debug("{}", info); } } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/chat/doc-files/websocket.puml ================================================ @startuml 'https://plantuml.com/component-diagram package "UI Client" { component producer #Cyan [ Producer /app/chat/private ] component consumer #Cyan [ Consumer /topic/chat/private ] } package "App Server" { [MessageHandler] #Green } cloud "RS Network" { [Friend] } [producer] --> [MessageHandler] : /app [MessageHandler] --> [consumer] : /topic [MessageHandler] <-> [Friend] @enduml ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/chat/package-info.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * Chat rooms and private messaging REST controller */ package io.xeres.app.api.controller.chat; ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/config/ConfigController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.config; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.api.exception.InternalServerErrorException; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.service.CapabilityService; import io.xeres.app.service.LocationService; import io.xeres.app.service.NetworkService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.backup.BackupService; import io.xeres.app.xrs.service.identity.IdentityRsService; import io.xeres.app.xrs.service.status.StatusRsService; import io.xeres.common.location.Availability; import io.xeres.common.rest.config.*; import jakarta.validation.Valid; import jakarta.xml.bind.JAXBException; import org.bouncycastle.openpgp.PGPException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import javax.xml.stream.XMLStreamException; import java.io.IOException; import java.net.UnknownHostException; import java.nio.file.Paths; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.spec.InvalidKeySpecException; import java.util.Optional; import java.util.Set; import static io.xeres.app.service.ResourceCreationState.ALREADY_EXISTS; import static io.xeres.app.service.ResourceCreationState.FAILED; import static io.xeres.common.rest.PathConfig.*; @Tag(name = "Configuration", description = "Runtime general configuration") @RestController @RequestMapping(value = CONFIG_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ConfigController { private static final Logger log = LoggerFactory.getLogger(ConfigController.class); private final ProfileService profileService; private final LocationService locationService; private final IdentityRsService identityRsService; private final CapabilityService capabilityService; private final BackupService backupService; private final NetworkService networkService; private final StatusRsService statusRsService; public ConfigController(ProfileService profileService, LocationService locationService, IdentityRsService identityRsService, CapabilityService capabilityService, BackupService backupService, NetworkService networkService, StatusRsService statusRsService) { this.profileService = profileService; this.locationService = locationService; this.identityRsService = identityRsService; this.capabilityService = capabilityService; this.backupService = backupService; this.networkService = networkService; this.statusRsService = statusRsService; } @PostMapping("/profile") @Operation(summary = "Creates own profile") @ApiResponse(responseCode = "200", description = "Profile already exists") @ApiResponse(responseCode = "201", description = "Profile created successfully", headers = @Header(name = "Location", description = "The location of the created profile", schema = @Schema(type = "string"))) @ApiResponse(responseCode = "422", description = "Profile entity cannot be processed", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity createOwnProfile(@Valid @RequestBody OwnProfileRequest ownProfileRequest) { var name = ownProfileRequest.name(); log.debug("Processing creation of Profile {}", name); var status = profileService.generateProfileKeys(name); if (status == FAILED) { throw new InternalServerErrorException("Failed to generate profile keys"); } networkService.checkReadiness(); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(PROFILES_PATH + "/{id}").buildAndExpand(1L).toUri(); return status == ALREADY_EXISTS ? ResponseEntity.ok().build() : ResponseEntity.created(location).build(); } @PostMapping("/location") @Operation(summary = "Creates own location") @ApiResponse(responseCode = "200", description = "Location already exists") @ApiResponse(responseCode = "201", description = "Location created successfully", headers = @Header(name = "Location", description = "The location of the created location", schema = @Schema(type = "string"))) @ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity createOwnLocation(@Valid @RequestBody OwnLocationRequest ownLocationRequest) { var name = ownLocationRequest.name(); log.debug("Processing creation of Location {}", name); var status = locationService.generateOwnLocation(name); if (status == FAILED) { throw new InternalServerErrorException("Failed to generate location"); } networkService.checkReadiness(); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(LOCATIONS_PATH + "/{id}").buildAndExpand(1L).toUri(); return status == ALREADY_EXISTS ? ResponseEntity.ok().build() : ResponseEntity.created(location).build(); } @PutMapping("/location/availability") @Operation(summary = "Changes our own availability") @ApiResponse(responseCode = "200", description = "Availability changed successfully") @ApiResponse(responseCode = "400", description = "Location does not exist", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity changeAvailability(@RequestBody Availability availability) { if (!locationService.hasOwnLocation()) { throw new IllegalArgumentException("Location does not exist"); } statusRsService.changeAvailability(availability); return ResponseEntity.ok().build(); } @PostMapping("/identity") @Operation(summary = "Creates own identity") @ApiResponse(responseCode = "200", description = "Identity already exists") @ApiResponse(responseCode = "201", description = "Identity created successfully") @ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity createOwnIdentity(@Valid @RequestBody OwnIdentityRequest ownIdentityRequest) { var name = ownIdentityRequest.name(); log.debug("Creating identity {}", name); var status = identityRsService.generateOwnIdentity(name, !ownIdentityRequest.anonymous()); if (status == FAILED) { throw new InternalServerErrorException("Failed to generate identity"); } networkService.checkReadiness(); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(IDENTITIES_PATH + "/{id}").buildAndExpand(1L).toUri(); return status == ALREADY_EXISTS ? ResponseEntity.ok().build() : ResponseEntity.created(location).build(); } @GetMapping("/external-ip") @Operation(summary = "Gets the external IP address and port", description = "Note that an external IP address is not strictly required if for example the host is on a public IP already.") @ApiResponse(responseCode = "200", description = "Request successful") @ApiResponse(responseCode = "404", description = "No location or no external IP address", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public IpAddressResponse getExternalIpAddress() { var connection = locationService.findOwnLocation().orElseThrow() .getConnections() .stream() .filter(Connection::isExternal) .findFirst().orElseThrow(); return new IpAddressResponse(connection.getIp(), connection.getPort()); } @GetMapping("/internal-ip") @Operation(summary = "Gets the internal IP address and port") @ApiResponse(responseCode = "200", description = "Request successful") @ApiResponse(responseCode = "404", description = "No location or no internal IP address", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public IpAddressResponse getInternalIpAddress() { return new IpAddressResponse(Optional.ofNullable(networkService.getLocalIpAddress()).orElseThrow(), networkService.getPort()); } @GetMapping("/hostname") @Operation(summary = "Gets the machine's hostname") @ApiResponse(responseCode = "200", description = "Request successful") @ApiResponse(responseCode = "404", description = "No hostname (host configuration problem)", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public HostnameResponse getHostname() throws UnknownHostException { return new HostnameResponse(locationService.getHostname()); } @GetMapping("/username") @Operation(summary = "Gets the OS session's username") @ApiResponse(responseCode = "200", description = "Request successful") @ApiResponse(responseCode = "404", description = "No username (no user session)", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public UsernameResponse getUsername() { return new UsernameResponse(locationService.getUsername()); } @GetMapping("/capabilities") @Operation(summary = "Gets the system's capabilities") @ApiResponse(responseCode = "200", description = "Request successful") public Set getCapabilities() { return capabilityService.getCapabilities(); } @GetMapping(value = "/export", produces = MediaType.APPLICATION_XML_VALUE) @Operation(summary = "Exports a minimal configuration") @ApiResponse(responseCode = "200", description = "Request successful") public ResponseEntity getBackup() throws JAXBException { return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"xeres_backup.xml\"") .body(backupService.backup()); } @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Imports a minimal configuration") @ApiResponse(responseCode = "200", description = "Request successful") public ResponseEntity restoreFromBackup(@RequestBody MultipartFile file) throws JAXBException, IOException, InvalidKeyException, CertificateException, NoSuchAlgorithmException, InvalidKeySpecException, PGPException, XMLStreamException { backupService.restore(file); networkService.checkReadiness(); return ResponseEntity.ok().build(); } @PostMapping(value = "/import-profile-from-rs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Imports a RS keyring") @ApiResponse(responseCode = "200", description = "Request successful") public ResponseEntity importProfileFromRs(@RequestBody MultipartFile file, @RequestParam(value = "locationName") String locationName, @RequestParam(value = "password", required = false) String password) { backupService.importProfileFromRs(file, locationName, password); networkService.checkReadiness(); return ResponseEntity.ok().build(); } @PostMapping(value = "/import-friends-from-rs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Imports RS friends") @ApiResponse(responseCode = "200", description = "Request successful") public ResponseEntity importFriendsFromRs(@RequestBody MultipartFile file) throws JAXBException, IOException, XMLStreamException { var response = backupService.importFriendsFromRs(file); return ResponseEntity.status(response.errors() > 0 ? HttpStatus.MULTI_STATUS : HttpStatus.OK) .body(response); } @PostMapping("/verify-update") @Operation(summary = "Verify an update file") @ApiResponse(responseCode = "200", description = "File verified successfully") public boolean verifyUpdate(@Valid @RequestBody VerifyUpdateRequest request) { //noinspection JvmTaintAnalysis var path = Paths.get(request.filePath()); return backupService.verifyUpdate(path, request.signature()); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/config/package-info.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * Configuration related REST controller. *

This is used to store and retrieve user settings. *

Note: do not store anything UI related in there because the UI can * be run separately. */ package io.xeres.app.api.controller.config; ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/connection/ConnectionController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.connection; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.database.model.location.Location; import io.xeres.app.job.PeerConnectionJob; import io.xeres.app.service.LocationService; import io.xeres.common.dto.profile.ProfileDTO; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.rest.connection.ConnectionRequest; import jakarta.validation.Valid; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import java.util.List; import static io.xeres.app.database.model.profile.ProfileMapper.toDeepDTOs; import static io.xeres.common.rest.PathConfig.CONNECTIONS_PATH; import static java.util.function.Predicate.not; @Tag(name = "Connection", description = "Connected peers") @RestController @RequestMapping(value = CONNECTIONS_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ConnectionController { private final LocationService locationService; private final PeerConnectionJob peerConnectionJob; public ConnectionController(LocationService locationService, PeerConnectionJob peerConnectionJob) { this.locationService = locationService; this.peerConnectionJob = peerConnectionJob; } @GetMapping("/profiles") @Operation(summary = "Gets all currently connected profiles") public List getConnectedProfiles() { return toDeepDTOs(locationService.getConnectedLocations().stream() .filter(not(Location::isOwn)) .map(Location::getProfile) .toList()); } @PutMapping("/connect") @Operation(summary = "Attempts to connect to a location") @ApiResponse(responseCode = "200", description = "Request completed successfully") @ApiResponse(responseCode = "404", description = "No location found for given identifier", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity connect(@Valid @RequestBody ConnectionRequest connectionRequest) { var location = locationService.findLocationByLocationIdentifier(LocationIdentifier.fromString(connectionRequest.locationIdentifier())).orElseThrow(); peerConnectionJob.connectImmediately(location, connectionRequest.connectionIndex()); return ResponseEntity.ok().build(); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/contact/ContactController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.contact; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.service.ContactService; import io.xeres.common.rest.contact.Contact; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import static io.xeres.common.rest.PathConfig.CONTACT_PATH; @Tag(name = "Contact", description = "Contacts") @RestController @RequestMapping(value = CONTACT_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ContactController { private final ContactService contactService; public ContactController(ContactService contactService) { this.contactService = contactService; } @GetMapping("") @Operation(summary = "Gets all the contacts") public List getContacts() { return contactService.getContacts(); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/file/FileController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.file; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.xrs.service.filetransfer.FileTransferRsService; import io.xeres.common.id.Sha1Sum; import io.xeres.common.rest.file.FileDownloadRequest; import io.xeres.common.rest.file.FileProgress; import io.xeres.common.rest.file.FileSearchRequest; import io.xeres.common.rest.file.FileSearchResponse; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import java.util.List; import static io.xeres.common.rest.PathConfig.FILES_PATH; @Tag(name = "File", description = "File service") @RestController @RequestMapping(value = FILES_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class FileController { private final FileTransferRsService fileTransferRsService; public FileController(FileTransferRsService fileTransferRsService) { this.fileTransferRsService = fileTransferRsService; } @PostMapping("/search") @Operation(summary = "Searches for files") public FileSearchResponse search(@Valid @RequestBody FileSearchRequest fileSearchRequest) { return new FileSearchResponse(fileTransferRsService.turtleSearch(fileSearchRequest.name())); } @PostMapping("/download") @Operation(summary = "Downloads a file") @ApiResponse(responseCode = "200", description = "Download created successfully") @ApiResponse(responseCode = "400", description = "Invalid hash") public long download(@RequestBody FileDownloadRequest fileDownloadRequest) { var hash = Sha1Sum.fromString(fileDownloadRequest.hash()); if (hash.isNullIdentifier()) { throw new IllegalArgumentException("Invalid hash"); } return fileTransferRsService.download(fileDownloadRequest.name(), hash, fileDownloadRequest.size(), fileDownloadRequest.locationIdentifier()); } @GetMapping("/downloads") @Operation(summary = "Shows the current downloads") public List getDownloads() { return fileTransferRsService.getDownloadStatistics(); } @GetMapping("/uploads") @Operation(summary = "Shows the current uploads") public List getUploads() { return fileTransferRsService.getUploadStatistics(); } @DeleteMapping("/downloads/{id}") @Operation(summary = "Removes/cancels a download") @ResponseStatus(HttpStatus.NO_CONTENT) public void removeDownload(@PathVariable long id) { fileTransferRsService.removeDownload(id); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/forum/ForumController.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.forum; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.service.ForumMessageService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.forum.ForumRsService; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.common.dto.forum.ForumGroupDTO; import io.xeres.common.dto.forum.ForumMessageDTO; import io.xeres.common.id.MsgId; import io.xeres.common.rest.forum.CreateForumMessageRequest; import io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest; import io.xeres.common.rest.forum.UpdateForumMessageReadRequest; import jakarta.validation.Valid; import org.apache.commons.collections4.CollectionUtils; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import static io.xeres.app.database.model.forum.ForumMapper.*; import static io.xeres.common.rest.PathConfig.FORUMS_PATH; @Tag(name = "Forums", description = "Forums") @RestController @RequestMapping(value = FORUMS_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ForumController { private final ForumRsService forumRsService; private final IdentityService identityService; private final ForumMessageService forumMessageService; private final UnHtmlService unHtmlService; public ForumController(ForumRsService forumRsService, IdentityService identityService, ForumMessageService forumMessageService, UnHtmlService unHtmlService) { this.forumRsService = forumRsService; this.identityService = identityService; this.forumMessageService = forumMessageService; this.unHtmlService = unHtmlService; } @GetMapping("/groups") @Operation(summary = "Gets the list of forums") public List getForumGroups() { return toDTOs(forumRsService.findAllGroups()); } @PostMapping("/groups") @Operation(summary = "Creates a forum") @ApiResponse(responseCode = "201", description = "Forum created successfully", headers = @Header(name = "Forum", description = "The location of the created forum", schema = @Schema(type = "string"))) public ResponseEntity createForumGroup(@Valid @RequestBody CreateOrUpdateForumGroupRequest createOrUpdateForumGroupRequest) { var ownIdentity = identityService.getOwnIdentity(); var id = forumRsService.createForumGroup(ownIdentity.getGxsId(), createOrUpdateForumGroupRequest.name(), createOrUpdateForumGroupRequest.description()); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(FORUMS_PATH + "/groups/{id}").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @PutMapping("/groups/{groupId}") @Operation(summary = "Updates a forum") @ResponseStatus(HttpStatus.NO_CONTENT) public void updateForumGroup(@PathVariable long groupId, @Valid @RequestBody CreateOrUpdateForumGroupRequest createOrUpdateForumGroupRequest) { forumRsService.updateForumGroup(groupId, createOrUpdateForumGroupRequest.name(), createOrUpdateForumGroupRequest.description()); } @GetMapping("/groups/{groupId}") @Operation(summary = "Gets the details of a forum") @ApiResponse(responseCode = "200", description = "Request successful") public ForumGroupDTO getForumGroupById(@PathVariable long groupId) { return toDTO(forumRsService.findById(groupId).orElseThrow()); } @GetMapping("/groups/{groupId}/unread-count") @Operation(summary = "Get the unread count of a forum") public int getForumUnreadCount(@PathVariable long groupId) { return forumRsService.getUnreadCount(groupId); } @PutMapping("/groups/{groupId}/subscription") @Operation(summary = "Subscribes to a forum") @ResponseStatus(HttpStatus.NO_CONTENT) public void subscribeToForumGroup(@PathVariable long groupId) { forumRsService.subscribeToForumGroup(groupId); } @PutMapping("/groups/{groupId}/read") @Operation(summary = "Mark all messages as read or unread") @ResponseStatus(HttpStatus.NO_CONTENT) public void markAllMessagesAsRead(@PathVariable long groupId, @RequestParam(value = "read") Boolean read) { forumRsService.setAllGroupMessagesReadState(groupId, read); } @DeleteMapping("/groups/{groupId}/subscription") @Operation(summary = "Unsubscribes from a forum") @ResponseStatus(HttpStatus.NO_CONTENT) public void unsubscribeFromForumGroup(@PathVariable long groupId) { forumRsService.unsubscribeFromForumGroup(groupId); } @GetMapping("/groups/{groupId}/messages") @Operation(summary = "Gets the summary of messages in a group") public Page getForumMessages(@PathVariable long groupId, @PageableDefault(size = 50, sort = {"published"}, direction = Sort.Direction.DESC) Pageable pageable) { var forumMessages = forumRsService.findAllMessagesSummary(groupId, pageable); return new PageImpl<>(toSummaryMessageDTOs(forumMessages, forumMessageService.getAuthorsMapFromSummaries(forumMessages), forumMessageService.getMessagesMapFromSummaries(groupId, forumMessages)), pageable, forumMessages.getTotalElements()); } @GetMapping("/messages/{messageId}") @Operation(summary = "Gets a message") @ApiResponse(responseCode = "200", description = "Request successful") public ForumMessageDTO getForumMessage(@PathVariable long messageId) { var forumMessage = forumRsService.findMessageById(messageId); Objects.requireNonNull(forumMessage, "Forum message " + messageId + " not found"); var author = identityService.findByGxsId(forumMessage.getAuthorGxsId()); HashSet messageSet = HashSet.newHashSet(2); // they can be null so no Set.of() possible CollectionUtils.addIgnoreNull(messageSet, forumMessage.getOriginalMsgId()); CollectionUtils.addIgnoreNull(messageSet, forumMessage.getParentMsgId()); var messages = forumRsService.findAllMessagesIncludingOlds(forumMessage.getGxsId(), messageSet).stream() .collect(Collectors.toMap(ForumMessageItem::getMsgId, ForumMessageItem::getId)); return toDTO( unHtmlService, forumMessage, author.map(GxsGroupItem::getName).orElse(null), messages.getOrDefault(forumMessage.getOriginalMsgId(), 0L), messages.getOrDefault(forumMessage.getParentMsgId(), 0L), true ); } @PostMapping("/messages") @Operation(summary = "Creates a forum message") @ApiResponse(responseCode = "201", description = "Forum message created successfully", headers = @Header(name = "Message", description = "The location of the created message", schema = @Schema(type = "string"))) public ResponseEntity createForumMessage(@Valid @RequestBody CreateForumMessageRequest createMessageRequest) { var ownIdentity = identityService.getOwnIdentity(); var id = forumRsService.createForumMessage( ownIdentity, createMessageRequest.forumId(), createMessageRequest.title(), createMessageRequest.content(), createMessageRequest.parentId(), createMessageRequest.originalId() ); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(FORUMS_PATH + "/messages/{id}").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @PatchMapping("/messages") @Operation(summary = "Modifies forum message read state") @ResponseStatus(HttpStatus.OK) public void setForumMessageReadState(@Valid @RequestBody UpdateForumMessageReadRequest request) { forumRsService.setMessageReadState(request.messageId(), request.read()); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/geoip/GeoIpController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.geoip; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.service.GeoIpService; import io.xeres.common.rest.geoip.CountryResponse; import jakarta.persistence.EntityNotFoundException; import org.springframework.http.MediaType; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Locale; import static io.xeres.common.rest.PathConfig.GEOIP_PATH; @Tag(name = "GeoIP", description = "GeoIP lookups") @RestController @RequestMapping(value = GEOIP_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class GeoIpController { private final GeoIpService geoIpService; public GeoIpController(GeoIpService geoIpService) { this.geoIpService = geoIpService; } @GetMapping("/{ip}") @Operation(summary = "Gets the ISO country code of an IP address") @ApiResponse(responseCode = "200", description = "Request successful") @ApiResponse(responseCode = "404", description = "No country found for IP address", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public CountryResponse getIsoCountry(@PathVariable String ip) { var country = geoIpService.getCountry(ip); if (country == null) { throw new EntityNotFoundException(); } return new CountryResponse(country.name().toLowerCase(Locale.ROOT)); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/identity/IdentityController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.identity; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.service.IdentityService; import io.xeres.app.service.identicon.IdenticonService; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.xrs.service.identity.IdentityRsService; import io.xeres.common.dto.identity.IdentityDTO; import io.xeres.common.id.GxsId; import io.xeres.common.identity.Type; import io.xeres.common.util.image.ImageUtils; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.Collections; import java.util.List; import static io.xeres.app.database.model.identity.IdentityMapper.toDTO; import static io.xeres.app.database.model.identity.IdentityMapper.toDTOs; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; import static org.apache.commons.lang3.StringUtils.isNotBlank; @Tag(name = "Identities", description = "Identities") @RestController @RequestMapping(value = IDENTITIES_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class IdentityController { private final IdentityService identityService; private final IdentityRsService identityRsService; private final ContactNotificationService contactNotificationService; private final IdenticonService identiconService; public IdentityController(IdentityService identityService, IdentityRsService identityRsService, ContactNotificationService contactNotificationService, IdenticonService identiconService) { this.identityService = identityService; this.identityRsService = identityRsService; this.contactNotificationService = contactNotificationService; this.identiconService = identiconService; } @GetMapping("/{id}") @Operation(summary = "Returns an identity") @ApiResponse(responseCode = "200", description = "Identity found") @ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public IdentityDTO findIdentityById(@PathVariable long id) { return toDTO(identityService.findById(id).orElseThrow()); } @GetMapping(value = "/{id}/image", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE}) @Operation(summary = "Returns an identity's avatar image") @ApiResponse(responseCode = "200", description = "Identity's avatar image found") @ApiResponse(responseCode = "204", description = "Identity's avatar image is empty") @ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity downloadIdentityImage(@PathVariable long id) { var identity = identityService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype var imageType = ImageUtils.getImageMimeType(identity.getImage()); if (imageType == null) { var image = identiconService.getIdenticon(identity.getGxsId().getBytes()); return ResponseEntity.ok() .contentLength(image.length) .contentType(ImageUtils.getImageMimeType(image)) .body(new InputStreamResource(new ByteArrayInputStream(image))); } return ResponseEntity.ok() .contentLength(identity.getImage().length) .contentType(imageType) .body(new InputStreamResource(new ByteArrayInputStream(identity.getImage()))); } @GetMapping(value = "/image", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE}) @Operation(summary = "Returns an identity's image by GxsId (possibly autogenerated)") @ApiResponse(responseCode = "200", description = "Request successful") public ResponseEntity downloadImageByGxsId(@RequestParam(value = "gxsId") String gxsId, @RequestParam(value = "find", required = false) Boolean find) { byte[] image = null; var gxs = GxsId.fromString(gxsId); if (Boolean.TRUE.equals(find)) { var identity = identityService.findByGxsId(gxs).orElse(null); if (identity != null && ImageUtils.getImageMimeType(identity.getImage()) != null) { image = identity.getImage(); } } if (image == null) { image = identiconService.getIdenticon(gxs.getBytes()); } return ResponseEntity.ok() .contentLength(image.length) .contentType(ImageUtils.getImageMimeType(image)) .body(new InputStreamResource(new ByteArrayInputStream(image))); } @PostMapping(value = "/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "Changes an identity's avatar image") @ApiResponse(responseCode = "201", description = "Identity's avatar image created") @ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "415", description = "Image's media type unsupported", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "422", description = "Image unprocessable", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity uploadIdentityImage(@PathVariable long id, @RequestBody MultipartFile file) throws IOException { var identity = identityRsService.saveOwnIdentityImage(id, file); contactNotificationService.addOrUpdateIdentities(List.of(identity)); var location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(IDENTITIES_PATH + "/{id}/image").buildAndExpand(id).toUri(); return ResponseEntity.created(location).build(); } @DeleteMapping("/{id}/image") @Operation(summary = "Removes an identity's image") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteIdentityImage(@PathVariable long id) { var identity = identityRsService.deleteOwnIdentityImage(id); contactNotificationService.addOrUpdateIdentities(List.of(identity)); } @GetMapping @Operation(summary = "Searches all identities", description = "If no search parameters are provided, return all identities") public List findIdentities( @RequestParam(value = "name", required = false) String name, @RequestParam(value = "gxsId", required = false) String gxsId, @RequestParam(value = "type", required = false) Type type) { if (isNotBlank(name)) { return toDTOs(identityService.findAllByName(name)); } else if (isNotBlank(gxsId)) { var identity = identityService.findByGxsId(GxsId.fromString(gxsId)); return identity.map(id -> List.of(toDTO(id))).orElse(Collections.emptyList()); } else if (type != null) { return toDTOs(identityService.findAllByType(type)); } return toDTOs(identityService.getAll()); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/location/LocationController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.location; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.service.LocationService; import io.xeres.app.service.QrCodeService; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.rest.location.RSIdResponse; import io.xeres.common.rsid.Type; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import java.awt.image.BufferedImage; import static io.xeres.app.database.model.location.LocationMapper.toDTO; import static io.xeres.common.rest.PathConfig.LOCATIONS_PATH; @Tag(name = "Location", description = "Local instance") @RestController @RequestMapping(value = LOCATIONS_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class LocationController { private final LocationService locationService; private final QrCodeService qrCodeService; public LocationController(LocationService locationService, QrCodeService qrCodeService) { this.locationService = locationService; this.qrCodeService = qrCodeService; } @GetMapping("/{id}") @Operation(summary = "Returns a location") @ApiResponse(responseCode = "200", description = "Location found") @ApiResponse(responseCode = "404", description = "Location not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public LocationDTO findLocationById(@PathVariable long id) { return toDTO(locationService.findLocationById(id).orElseThrow()); } @GetMapping("/{id}/rs-id") @Operation(summary = "Returns a location's RSId") @ApiResponse(responseCode = "200", description = "Location found") @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public RSIdResponse getRSIdOfLocation(@PathVariable long id, @RequestParam(value = "type", required = false) Type type) { var location = locationService.findLocationById(id).orElseThrow(); return new RSIdResponse(location.getProfile().getName(), location.getSafeName(), location.getRsId(type == null ? Type.ANY : type).getArmored()); } @GetMapping(value = "/{id}/rs-id/qr-code", produces = MediaType.IMAGE_PNG_VALUE) @Operation(summary = "Returns a location's RSId as a QR code") @ApiResponse(responseCode = "200", description = "Location found") @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity getRSIdOfLocationAsQrCode(@PathVariable long id) { var location = locationService.findLocationById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype return ResponseEntity.ok(qrCodeService.generateQrCode(location.getRsId(Type.SHORT_INVITE).getArmored())); } @GetMapping("/{id}/service/{serviceId}") @Operation(summary = "Returns if a location supports a service") @ApiResponse(responseCode = "200", description = "Service supported") @ApiResponse(responseCode = "404", description = "Service not supported") public ResponseEntity isServiceSupported(@PathVariable long id, @PathVariable int serviceId) { var location = locationService.findLocationById(id).orElseThrow(); var supported = locationService.isServiceSupported(location, serviceId); if (supported) { return ResponseEntity.ok().build(); } else { return ResponseEntity.notFound().build(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/notification/NotificationController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.notification; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.service.notification.availability.AvailabilityNotificationService; import io.xeres.app.service.notification.board.BoardNotificationService; import io.xeres.app.service.notification.channel.ChannelNotificationService; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.app.service.notification.file.FileSearchNotificationService; import io.xeres.app.service.notification.file.FileTrendNotificationService; import io.xeres.app.service.notification.forum.ForumNotificationService; import io.xeres.app.service.notification.status.StatusNotificationService; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import static io.xeres.common.rest.PathConfig.NOTIFICATIONS_PATH; @Tag(name = "Notification", description = "Out of band notifications") @RestController @RequestMapping(value = NOTIFICATIONS_PATH, produces = MediaType.TEXT_EVENT_STREAM_VALUE) public class NotificationController { private final StatusNotificationService statusNotificationService; private final ForumNotificationService forumNotificationService; private final FileNotificationService fileNotificationService; private final FileSearchNotificationService fileSearchNotificationService; private final FileTrendNotificationService fileTrendNotificationService; private final ContactNotificationService contactNotificationService; private final AvailabilityNotificationService availabilityNotificationService; private final BoardNotificationService boardNotificationService; private final ChannelNotificationService channelNotificationService; public NotificationController(StatusNotificationService statusNotificationService, ForumNotificationService forumNotificationService, FileNotificationService fileNotificationService, FileSearchNotificationService fileSearchNotificationService, FileTrendNotificationService fileTrendNotificationService, ContactNotificationService contactNotificationService, AvailabilityNotificationService availabilityNotificationService, BoardNotificationService boardNotificationService, ChannelNotificationService channelNotificationService) { this.statusNotificationService = statusNotificationService; this.forumNotificationService = forumNotificationService; this.fileNotificationService = fileNotificationService; this.fileSearchNotificationService = fileSearchNotificationService; this.fileTrendNotificationService = fileTrendNotificationService; this.contactNotificationService = contactNotificationService; this.availabilityNotificationService = availabilityNotificationService; this.boardNotificationService = boardNotificationService; this.channelNotificationService = channelNotificationService; } @GetMapping("/status") @Operation(summary = "Subscribes to status notifications") public SseEmitter setupStatusNotification() { return statusNotificationService.addClient(); } @GetMapping("/forum") @Operation(summary = "Subscribes to forum notifications") public SseEmitter setupForumNotification() { return forumNotificationService.addClient(); } @GetMapping("/board") @Operation(summary = "Subscribes to board notifications") public SseEmitter setupBoardNotification() { return boardNotificationService.addClient(); } @GetMapping("/channel") @Operation(summary = "Subscribes to channel notifications") public SseEmitter setupChannelNotification() { return channelNotificationService.addClient(); } @GetMapping("/file") @Operation(summary = "Subscribes to file notifications") public SseEmitter setupFileNotification() { return fileNotificationService.addClient(); } @GetMapping("/file-search") @Operation(summary = "Subscribes to file search notifications") public SseEmitter setupFileSearchNotification() { return fileSearchNotificationService.addClient(); } @GetMapping("/file-trend") @Operation(summary = "Subscribes to file trend notifications") public SseEmitter setupFileTrendNotification() { return fileTrendNotificationService.addClient(); } @GetMapping("/contact") @Operation(summary = "Subscribes to contact notifications") public SseEmitter setupContactNotification() { return contactNotificationService.addClient(); } @GetMapping("/availability") @Operation(summary = "Subscribes to connection notifications") public SseEmitter setupConnectionNotification() { return availabilityNotificationService.addClient(); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/profile/ProfileController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.profile; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.api.exception.UnprocessableEntityException; import io.xeres.app.crypto.rsid.RSId; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.job.PeerConnectionJob; import io.xeres.app.service.ContactService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.identicon.IdenticonService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.common.dto.profile.ProfileDTO; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.pgp.Trust; import io.xeres.common.rest.contact.Contact; import io.xeres.common.rest.profile.ProfileKeyAttributes; import io.xeres.common.rest.profile.RsIdRequest; import io.xeres.common.util.image.ImageUtils; import jakarta.validation.Valid; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayInputStream; import java.nio.ByteBuffer; import java.util.Collections; import java.util.List; import static io.xeres.app.database.model.profile.ProfileMapper.*; import static io.xeres.common.rest.PathConfig.PROFILES_PATH; import static io.xeres.common.rsid.Type.ANY; import static org.apache.commons.lang3.StringUtils.isNotBlank; @Tag(name = "Profile", description = "User's profiles") @RestController @RequestMapping(value = PROFILES_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ProfileController { private final ProfileService profileService; private final IdentityService identityService; private final LocationService locationService; private final ContactService contactService; private final PeerConnectionJob peerConnectionJob; private final StatusNotificationService statusNotificationService; private final IdenticonService identiconService; public ProfileController(ProfileService profileService, IdentityService identityService, LocationService locationService, ContactService contactService, PeerConnectionJob peerConnectionJob, StatusNotificationService statusNotificationService, IdenticonService identiconService) { this.profileService = profileService; this.identityService = identityService; this.locationService = locationService; this.contactService = contactService; this.peerConnectionJob = peerConnectionJob; this.statusNotificationService = statusNotificationService; this.identiconService = identiconService; } @GetMapping("/{id}") @Operation(summary = "Returns a profile") @ApiResponse(responseCode = "200", description = "Profile found") @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ProfileDTO findProfileById(@PathVariable long id) { return toDeepDTO(profileService.findProfileById(id).orElseThrow()); } @GetMapping("/{id}/key-attributes") @Operation(summary = "Returns the profile's key attributes") @ApiResponse(responseCode = "200", description = "Profile found") @ApiResponse(responseCode = "400", description = "Error in the profile's key") @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ProfileKeyAttributes findProfileKeyAttributes(@PathVariable long id) { return profileService.findProfileKeyAttributes(id); } @GetMapping("/{id}/contacts") @Operation(summary = "Returns the profile's identities as contacts") public List findContactsForProfile(@PathVariable long id) { return contactService.getContactsForProfileId(id); } @GetMapping(value = "/{id}/image", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE}) @Operation(summary = "Returns a profile's avatar image (currently an identicon)") @ApiResponse(responseCode = "200", description = "Profile's avatar image found") @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity downloadImage(@PathVariable long id) { var profile = profileService.findProfileById(id).orElseThrow(); var image = identiconService.getIdenticon(ByteBuffer.wrap(new byte[8]).putLong(profile.getPgpIdentifier()).array()); return ResponseEntity.ok() .contentLength(image.length) .contentType(ImageUtils.getImageMimeType(image)) .body(new InputStreamResource(new ByteArrayInputStream(image))); } @GetMapping @Operation(summary = "Searches all profiles", description = "If no search parameters are provided, return all profiles") @ApiResponse(responseCode = "200", description = "All matched profiles") public List findProfiles(@RequestParam(value = "name", required = false) String name, @RequestParam(value = "locationIdentifier", required = false) String locationIdentifierString, @RequestParam(value = "pgpIdentifier", required = false) String pgpIdentifierString, @RequestParam(value = "withLocations", required = false) Boolean withLocations) { if (isNotBlank(name)) { return toDTOs(profileService.findProfilesByName(name)); } else if (isNotBlank(locationIdentifierString)) { var locationIdentifier = LocationIdentifier.fromString(locationIdentifierString); var profile = profileService.findProfileByLocationIdentifier(locationIdentifier); return profile.map(p -> List.of(Boolean.TRUE.equals(withLocations) ? toDeepDTO(p, locationIdentifier) : toDTO(p))).orElse(Collections.emptyList()); } else if (isNotBlank(pgpIdentifierString)) { var profile = profileService.findProfileByPgpIdentifier(Long.parseUnsignedLong(pgpIdentifierString, 16)); return profile.map(p -> List.of(Boolean.TRUE.equals(withLocations) ? toDeepDTO(p) : toDTO(p))).orElse(Collections.emptyList()); } return toDTOs(profileService.getAllProfiles()); } @PostMapping @Operation(summary = "Creates a profile and its possible location from an RS ID") @ApiResponse(responseCode = "201", description = "Profile created successfully", headers = @Header(name = "location", description = "the location of the profile")) @ApiResponse(responseCode = "422", description = "Profile entity cannot be processed", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity createProfileFromRsId(@Valid @RequestBody RsIdRequest rsIdRequest, @RequestParam(value = "connectionIndex", required = false) Integer connectionIndex, @RequestParam(value = "trust", required = false) Trust trust) { var profile = profileService.getProfileFromRSId(RSId.parse(rsIdRequest.rsId(), ANY).orElseThrow(() -> new UnprocessableEntityException("RS id is invalid"))); var locationToConnectTo = profile.getLocations().stream().findFirst(); if (trust != null) { if (trust == Trust.ULTIMATE) { throw new IllegalArgumentException("ULTIMATE trust cannot be set"); } profile.setTrust(trust); } var savedProfile = profileService.createOrUpdateProfile(profile); statusNotificationService.setTotalUsers((int) locationService.countLocations()); locationToConnectTo.ifPresent(location -> { if (connectionIndex != null && connectionIndex >= 0) { peerConnectionJob.connectImmediately(location, connectionIndex); } }); var profileLocation = ServletUriComponentsBuilder.fromCurrentRequest() .path("/{id}") .replaceQuery(null) .buildAndExpand(savedProfile.getId()).toUri(); return ResponseEntity.created(profileLocation).build(); } @PostMapping("/check") @Operation(summary = "Checks an RS ID") @ApiResponse(responseCode = "200", description = "RS ID is OK") @ApiResponse(responseCode = "422", description = "RS ID cannot be processed", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ProfileDTO checkProfileFromRsId(@Valid @RequestBody RsIdRequest rsIdRequest) { var rsId = RSId.parse(rsIdRequest.rsId(), ANY).orElseThrow(() -> new UnprocessableEntityException("RS id is invalid")); return toDeepDTO(profileService.getProfileFromRSId(rsId)); } @PutMapping("/{id}/trust") @Operation(summary = "Sets the trust of a profile") @ResponseStatus(HttpStatus.NO_CONTENT) public void setTrust(@PathVariable long id, @RequestBody Trust trust) { var profile = profileService.findProfileById(id).orElseThrow(() -> new UnprocessableEntityException("Profile not found")); if (profile.isOwn()) { throw new IllegalArgumentException("Cannot change the trust of own profile"); } if (trust == Trust.ULTIMATE) { throw new IllegalArgumentException("ULTIMATE trust cannot be set"); } profile.setTrust(trust); profileService.createOrUpdateProfile(profile); } @DeleteMapping("/{id}") @Operation(summary = "Deletes a profile") @ApiResponse(responseCode = "200", description = "Profile successfully deleted") @ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteProfile(@PathVariable long id) { if (Profile.isOwn(id)) { throw new UnprocessableEntityException("The main profile cannot be deleted"); } identityService.removeAllLinksToProfile(id); profileService.deleteProfile(id); statusNotificationService.setTotalUsers((int) locationService.countLocations()); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/settings/SettingsController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.settings; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.service.SettingsService; import io.xeres.common.dto.settings.SettingsDTO; import jakarta.json.JsonPatch; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import static io.xeres.app.database.model.settings.SettingsMapper.fromDTO; import static io.xeres.app.database.model.settings.SettingsMapper.toDTO; import static io.xeres.common.rest.PathConfig.SETTINGS_PATH; @Tag(name = "Settings", description = "Persisted settings") @RestController @RequestMapping(value = SETTINGS_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class SettingsController { private final SettingsService settingsService; public SettingsController(SettingsService settingsService) { this.settingsService = settingsService; } @GetMapping @Operation(summary = "Gets the current settings") public SettingsDTO getSettings() { return settingsService.getSettings(); } @PatchMapping(consumes = "application/json-patch+json") @Operation(summary = "Updates the settings") public ResponseEntity updateSettings(@RequestBody JsonPatch jsonPatch) { var newSettings = settingsService.applyPatchToSettings(jsonPatch); return ResponseEntity.ok(toDTO(newSettings)); } @PutMapping @Operation(summary = "Updates the settings") public ResponseEntity updateSettings(@RequestBody SettingsDTO settingsDTO) { var newSettings = settingsService.applySettings(fromDTO(settingsDTO)); return ResponseEntity.ok(toDTO(newSettings)); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/share/ShareController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.share; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.api.exception.InternalServerErrorException; import io.xeres.app.service.file.FileService; import io.xeres.common.dto.share.ShareDTO; import io.xeres.common.rest.share.TemporaryShareRequest; import io.xeres.common.rest.share.TemporaryShareResponse; import io.xeres.common.rest.share.UpdateShareRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; import java.nio.file.Paths; import java.util.List; import static io.xeres.app.database.model.share.ShareMapper.fromDTOs; import static io.xeres.app.database.model.share.ShareMapper.toDTOs; import static io.xeres.common.rest.PathConfig.SHARES_PATH; @Tag(name = "Share", description = "File shares") @RestController @RequestMapping(value = SHARES_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class ShareController { private final FileService fileService; public ShareController(FileService fileService) { this.fileService = fileService; } @GetMapping @Operation(summary = "Returns all shares", description = "Return all configured shares") @ApiResponse(responseCode = "200", description = "All shares") public List getShares() { var shares = fileService.getShares(); return toDTOs(shares, fileService.getFilesMapFromShares(shares)); } @PostMapping @Operation(summary = "Adds/Updates shares") @ApiResponse(responseCode = "201", description = "Shares created/updated successfully") @ApiResponse(responseCode = "422", description = "Shares cannot be processed", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) public ResponseEntity createAndUpdateShares(@Valid @RequestBody UpdateShareRequest updateSharesRequest) { fileService.synchronize(fromDTOs(updateSharesRequest.shares())); return ResponseEntity.status(HttpStatus.CREATED).build(); } @PostMapping("/temporary") @Operation(summary = "Adds a file to share temporarily") @ApiResponse(responseCode = "200", description = "File added to temporary share successfully") public TemporaryShareResponse shareTemporarily(@Valid @RequestBody TemporaryShareRequest temporaryShareRequest) { //noinspection JvmTaintAnalysis var path = Paths.get(temporaryShareRequest.filePath()); var hash = fileService.calculateTemporaryFileHash(path); if (hash == null) { throw new InternalServerErrorException("Cannot compute hash of file"); } return new TemporaryShareResponse(hash.toString()); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/statistics/StatisticsController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.statistics; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import io.xeres.app.xrs.service.bandwidth.BandwidthRsService; import io.xeres.app.xrs.service.rtt.RttRsService; import io.xeres.app.xrs.service.turtle.TurtleRsService; import io.xeres.common.rest.statistics.DataCounterStatisticsResponse; import io.xeres.common.rest.statistics.RttStatisticsResponse; import io.xeres.common.rest.statistics.TurtleStatisticsResponse; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import static io.xeres.app.api.controller.statistics.StatisticsMapper.toDTO; import static io.xeres.common.rest.PathConfig.STATISTICS_PATH; @Tag(name = "Statistics", description = "Statistics service") @RestController @RequestMapping(value = STATISTICS_PATH, produces = MediaType.APPLICATION_JSON_VALUE) public class StatisticsController { private final TurtleRsService turtleRsService; private final RttRsService rttRsService; private final BandwidthRsService bandwidthRsService; public StatisticsController(TurtleRsService turtleRsService, RttRsService rttRsService, BandwidthRsService bandwidthRsService) { this.turtleRsService = turtleRsService; this.rttRsService = rttRsService; this.bandwidthRsService = bandwidthRsService; } @GetMapping("/turtle") @Operation(summary = "Gets turtle statistics") public TurtleStatisticsResponse getTurtleStatistics() { return toDTO(turtleRsService.getStatistics()); } @GetMapping("/rtt") @Operation(summary = "Gets RTT statistics") public RttStatisticsResponse getRttStatistics() { return rttRsService.getStatistics(); } @GetMapping("/data-counter") @Operation(summary = "Gets global data counter statistics") public DataCounterStatisticsResponse getDataCounterStatistics() { return bandwidthRsService.getDataCounterStatistics(); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/statistics/StatisticsMapper.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.statistics; import io.xeres.app.xrs.service.turtle.TurtleStatistics; import io.xeres.common.rest.statistics.TurtleStatisticsResponse; final class StatisticsMapper { private StatisticsMapper() { throw new UnsupportedOperationException("Utility class"); } public static TurtleStatisticsResponse toDTO(TurtleStatistics turtleStatistics) { if (turtleStatistics == null) { return null; } return new TurtleStatisticsResponse( turtleStatistics.getForwardTotal(), turtleStatistics.getDataUpload(), turtleStatistics.getDataDownload(), turtleStatistics.getTunnelRequestsUpload(), turtleStatistics.getTunnelRequestsDownload(), turtleStatistics.getSearchRequestsUpload(), turtleStatistics.getSearchRequestsDownload(), turtleStatistics.getTotalUpload(), turtleStatistics.getTotalDownload() ); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/controller/voip/VoipMessageController.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.voip; import io.xeres.app.xrs.service.voip.VoipRsService; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.voip.VoipAction; import io.xeres.common.message.voip.VoipMessage; import jakarta.validation.Valid; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Controller; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; import static io.xeres.common.message.MessagePath.VOIP_PRIVATE_DESTINATION; import static io.xeres.common.message.MessagePath.VOIP_ROOT; @Controller @MessageMapping(VOIP_ROOT) public class VoipMessageController { private final VoipRsService voipRsService; public VoipMessageController(VoipRsService voipRsService) { this.voipRsService = voipRsService; } @MessageMapping(VOIP_PRIVATE_DESTINATION) public void processPrivateVoipMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Payload @Valid VoipMessage voipMessage) { var locationIdentifier = LocationIdentifier.fromString(destinationId); switch (voipMessage.getAction()) { case VoipAction.RING -> voipRsService.call(locationIdentifier); case VoipAction.ACKNOWLEDGE -> voipRsService.accept(locationIdentifier); case VoipAction.CLOSE -> voipRsService.hangup(locationIdentifier); } } } ================================================ FILE: app/src/main/java/io/xeres/app/api/converter/BufferedImageConverter.java ================================================ package io.xeres.app.api.converter; import org.springframework.context.annotation.Bean; import org.springframework.http.converter.BufferedImageHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.stereotype.Component; import java.awt.image.BufferedImage; @Component public class BufferedImageConverter { @Bean public HttpMessageConverter createBufferedImageHttpMessageConverter() { return new BufferedImageHttpMessageConverter(); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/exception/InternalServerErrorException.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.exception; import java.io.Serial; public class InternalServerErrorException extends RuntimeException { @Serial private static final long serialVersionUID = 371250063985938335L; public InternalServerErrorException(String message) { super(message); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/exception/UnprocessableEntityException.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.exception; import java.io.Serial; public class UnprocessableEntityException extends RuntimeException { @Serial private static final long serialVersionUID = -889467114836196650L; public UnprocessableEntityException(String message) { super(message); } } ================================================ FILE: app/src/main/java/io/xeres/app/api/package-info.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * REST API. */ package io.xeres.app.api; ================================================ FILE: app/src/main/java/io/xeres/app/application/SingleInstanceRun.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application; import io.xeres.common.AppName; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.channels.FileLock; import java.nio.file.Files; import java.util.Locale; import java.util.Optional; /** * Utility class to detect if an application is already running. */ public final class SingleInstanceRun { private static final Logger log = LoggerFactory.getLogger(SingleInstanceRun.class); private static final String LOCK_FILE_NAME = "." + AppName.NAME.toLowerCase(Locale.ROOT) + ".lock"; private static File file; private static RandomAccessFile randomAccessFile; private static FileLock lock; private SingleInstanceRun() { throw new UnsupportedOperationException("Utility class"); } /** * Enforces an application to have a single instance of itself, given a certain directory. * * @param dataDir the directory to be used by the application. If it's null, no enforcing is performed and * true is returned because there's no data dir to conflict with * @return true if the application can run without conflicts; false if it's already running */ public static boolean enforceSingleInstance(String dataDir) { if (dataDir == null) { return true; } file = new File(dataDir, LOCK_FILE_NAME); var result = false; try { randomAccessFile = new RandomAccessFile(file, "rw"); lock = Optional.ofNullable(randomAccessFile.getChannel().tryLock()).orElseThrow(() -> new IllegalStateException("Lock already acquired by another process")); result = true; Runtime.getRuntime().addShutdownHook(Thread.ofVirtual().unstarted(new ShutdownHook())); } catch (IOException | IllegalStateException | IllegalArgumentException e) { log.debug("Couldn't enforce single instance: {}.", e.getMessage()); } catch (SecurityException _) { log.warn("Shutdown hook denied by SecurityManager; There will be a dangling lock file at {}", LOCK_FILE_NAME); } return result; } private static class ShutdownHook implements Runnable { @Override public void run() { try { lock.release(); randomAccessFile.close(); Files.delete(file.toPath()); } catch (IOException | SecurityException _) { // No logging in the shutdown hook because logback also uses one to clean up } } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/Startup.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application; import io.xeres.app.application.autostart.AutoStart; import io.xeres.app.application.events.LocationReadyEvent; import io.xeres.app.application.events.SettingsChangedEvent; import io.xeres.app.configuration.DataDirConfiguration; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.settings.Settings; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.*; import io.xeres.app.service.UiBridgeService.SplashStatus; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.app.service.shell.ShellService; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.common.events.ConnectWebSocketsEvent; import io.xeres.common.events.StartupEvent; import io.xeres.common.mui.MUI; import io.xeres.common.util.RemoteUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.Duration; @Component public class Startup implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(Startup.class); /** * Minimum time to run before doing a backup. This avoids making useless * backups when performing tests. */ public static final Duration BACKUP_UPTIME = Duration.ofMinutes(5); private final LocationService locationService; private final SettingsService settingsService; private final DatabaseSessionManager databaseSessionManager; private final DataDirConfiguration dataDirConfiguration; private final NetworkService networkService; private final PeerConnectionManager peerConnectionManager; private final UiBridgeService uiBridgeService; private final IdentityManager identityManager; private final StatusNotificationService statusNotificationService; private final AutoStart autoStart; private final ShellService shellService; private final FileNotificationService fileNotificationService; private final InfoService infoService; private final UpgradeService upgradeService; private final ApplicationEventPublisher publisher; public Startup(LocationService locationService, SettingsService settingsService, DatabaseSessionManager databaseSessionManager, DataDirConfiguration dataDirConfiguration, NetworkService networkService, PeerConnectionManager peerConnectionManager, UiBridgeService uiBridgeService, IdentityManager identityManager, StatusNotificationService statusNotificationService, AutoStart autoStart, ShellService shellService, FileNotificationService fileNotificationService, InfoService infoService, UpgradeService upgradeService, ApplicationEventPublisher publisher) { this.locationService = locationService; this.settingsService = settingsService; this.databaseSessionManager = databaseSessionManager; this.dataDirConfiguration = dataDirConfiguration; this.networkService = networkService; this.peerConnectionManager = peerConnectionManager; this.uiBridgeService = uiBridgeService; this.identityManager = identityManager; this.statusNotificationService = statusNotificationService; this.autoStart = autoStart; this.shellService = shellService; this.fileNotificationService = fileNotificationService; this.infoService = infoService; this.upgradeService = upgradeService; this.publisher = publisher; } @Override public void run(ApplicationArguments args) { // This is a convenient place to start code as it works in both UI and non-UI mode infoService.showStartupInfo(); checkRequirements(); infoService.showCapabilities(); infoService.showFeatures(); infoService.showDebug(); publisher.publishEvent(new StartupEvent()); // This is synchronous and allows WebClients to configure themselves. if (RemoteUtils.isRemoteUiClient()) { log.info("Remote UI mode"); publisher.publishEvent(new ConnectWebSocketsEvent()); // Make sure the websockets connect return; } upgradeService.upgrade(); if (networkService.checkReadiness()) { uiBridgeService.setSplashStatus(SplashStatus.NETWORK); } else { log.info("Waiting... Use the user interface to send commands to create a profile"); uiBridgeService.closeSplashScreen(); } } /** * Called when the application setup is ready (aka we have a location). * * @param ignoredEvent the {@link LocationReadyEvent} */ @EventListener public void onApplicationEvent(LocationReadyEvent ignoredEvent) { try (var ignored = new DatabaseSession(databaseSessionManager)) { syncAutoStart(); statusNotificationService.setTotalUsers((int) locationService.countLocations()); networkService.start(); } MUI.setShell(shellService); uiBridgeService.closeSplashScreen(); } @EventListener public void onSettingsChangedEvent(SettingsChangedEvent event) { compareSettingsAndApplyActions(event.oldSettings(), event.newSettings()); } @EventListener // We don't use @PreDestroy because netty uses other beans on shutdown, and we don't want them in shutdown state already public void onApplicationEvent(ContextClosedEvent ignored) { backupUserData(); log.info("Shutting down..."); identityManager.shutdown(); peerConnectionManager.shutdown(); statusNotificationService.shutdown(); fileNotificationService.shutdown(); networkService.stop(); } private void backupUserData() { if (dataDirConfiguration.getDataDir() != null && infoService.getUptime().compareTo(BACKUP_UPTIME) > 0) // Don't back up the database when running unit tests, and not if we run for not enough time { settingsService.backup(dataDirConfiguration.getDataDir()); } } private static void checkRequirements() { if (Charset.defaultCharset() != StandardCharsets.UTF_8) { throw new IllegalArgumentException("Platform charset must be UTF-8, found: " + Charset.defaultCharset()); } } private void compareSettingsAndApplyActions(Settings oldSettings, Settings newSettings) { networkService.compareSettingsAndApplyActions(oldSettings, newSettings); applyAutoStart(oldSettings, newSettings); } private void applyAutoStart(Settings oldSettings, Settings newSettings) { if (newSettings.isAutoStartEnabled() != oldSettings.isAutoStartEnabled()) { if (newSettings.isAutoStartEnabled()) { autoStart.enable(); } else { autoStart.disable(); } } } private void syncAutoStart() { if (settingsService.isAutoStartEnabled() != autoStart.isEnabled()) { log.info("Autostart is desynced, forcing to {}", settingsService.isAutoStartEnabled()); if (settingsService.isAutoStartEnabled()) { autoStart.enable(); } else { autoStart.disable(); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/autostart/AutoStart.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.autostart; import org.springframework.stereotype.Component; @Component public class AutoStart { private final AutoStarter autoStarter; public AutoStart(AutoStarter autoStarter) { this.autoStarter = autoStarter; } public boolean isSupported() { return autoStarter.isSupported(); } public boolean isEnabled() { if (!isSupported()) { return false; } return autoStarter.isEnabled(); } public void enable() { if (isSupported()) { autoStarter.enable(); } } public void disable() { if (isSupported()) { autoStarter.disable(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/autostart/AutoStarter.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.autostart; public interface AutoStarter { /** * Checks if the auto start feature is supported by the system. *

* Usually depends on the host OS and installation mode (for example, portable mode doesn't support auto start). * * @return true if auto start is supported */ boolean isSupported(); /** * Checks if the auto start feature is enabled for the application. * * @return true if auto start is enabled */ boolean isEnabled(); /** * Enables auto start for the application. */ void enable(); /** * Disables auto start for the application. */ void disable(); } ================================================ FILE: app/src/main/java/io/xeres/app/application/autostart/autostarter/AutoStarterGeneric.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.autostart.autostarter; import io.xeres.app.application.autostart.AutoStarter; public class AutoStarterGeneric implements AutoStarter { @Override public boolean isSupported() { return false; } @Override public boolean isEnabled() { throw new UnsupportedOperationException(); } @Override public void enable() { throw new UnsupportedOperationException(); } @Override public void disable() { throw new UnsupportedOperationException(); } } ================================================ FILE: app/src/main/java/io/xeres/app/application/autostart/autostarter/AutoStarterWindows.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.autostart.autostarter; import io.xeres.app.application.autostart.AutoStarter; import io.xeres.common.AppName; import io.xeres.common.util.OsUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.file.Files; import java.nio.file.Path; import static com.sun.jna.platform.win32.Advapi32Util.*; import static com.sun.jna.platform.win32.WinReg.HKEY_CURRENT_USER; import static org.apache.commons.lang3.StringUtils.isNotBlank; /** * Handles the automatic startup of the application by Windows. *

* In case of problems, press ctrl-alt-del, launch the Task Manager, go to Startup apps and * make sure the status is set to Enabled. */ public class AutoStarterWindows implements AutoStarter { private static final Logger log = LoggerFactory.getLogger(AutoStarterWindows.class); public static final String REGISTRY_RUN_PATH = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"; public static final String EXECUTABLE_EXTENSION = ".exe"; private Path applicationPath; @Override public boolean isSupported() { return isNotBlank(getApplicationPath()); } @Override public boolean isEnabled() { return registryValueExists(HKEY_CURRENT_USER, REGISTRY_RUN_PATH, AppName.NAME); } @Override public void enable() { registrySetStringValue(HKEY_CURRENT_USER, REGISTRY_RUN_PATH, AppName.NAME, "\"" + getApplicationPath() + "\"" + " --iconified"); } @Override public void disable() { registryDeleteValue(HKEY_CURRENT_USER, REGISTRY_RUN_PATH, AppName.NAME); } /** * Gets the application path. * * @return the application path */ private String getApplicationPath() { if (applicationPath != null) { return applicationPath.toString(); } var basePath = OsUtils.getApplicationHome(); // Get the parent directory of 'app' because that's where the executable is if (basePath.getParent() == null) { log.error("Couldn't get parent directory of application path {}", basePath); return null; } var appPath = basePath.getParent().resolve(AppName.NAME + EXECUTABLE_EXTENSION); if (Files.notExists(appPath)) { log.error("Application path does not exist: {}", appPath); return null; } applicationPath = appPath.toAbsolutePath(); log.info("Application path: {}", appPath); return applicationPath.toString(); } } ================================================ FILE: app/src/main/java/io/xeres/app/application/environment/Cloud.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.environment; import io.xeres.common.properties.StartupProperties; import java.util.Arrays; import static io.xeres.common.properties.StartupProperties.Property.UI; /** * Utility class containing cloud related functions. */ public final class Cloud { private Cloud() { throw new UnsupportedOperationException("Utility class"); } /** * Checks if we are running on the cloud. This is done by checking if the profile cloud is in the SPRING_PROFILES_ACTIVE env variable. * * @return true if running on the cloud */ private static boolean isRunningOnCloud() { var profiles = System.getenv("SPRING_PROFILES_ACTIVE"); if (profiles != null) { var tokens = profiles.split(","); return Arrays.asList(tokens).contains( "cloud"); } return false; } public static void checkIfRunningOnCloud() { if (isRunningOnCloud()) { StartupProperties.setBoolean(UI, "false", StartupProperties.Origin.ENVIRONMENT_VARIABLE); } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/environment/CommandArgument.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.environment; import io.xeres.common.AppName; import io.xeres.common.mui.MUI; import io.xeres.common.properties.StartupProperties; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.DefaultApplicationArguments; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import static org.apache.commons.collections4.ListUtils.emptyIfNull; /** * Utility class to handle user supplied command line arguments. */ public final class CommandArgument { private CommandArgument() { throw new UnsupportedOperationException("Utility class"); } private static final String HELP = "help"; private static final String VERSION = "version"; private static final String NO_GUI = "no-gui"; private static final String DATA_DIR = "data-dir"; private static final String CONTROL_PORT = "control-port"; private static final String CONTROL_ADDRESS = "control-address"; private static final String NO_CONTROL_PASSWORD = "no-control-password"; private static final String SERVER_ADDRESS = "server-address"; private static final String SERVER_PORT = "server-port"; private static final String FAST_SHUTDOWN = "fast-shutdown"; private static final String REMOTE_PASSWORD = "remote-password"; private static final String SERVER_ONLY = "server-only"; private static final String REMOTE_CONNECT = "remote-connect"; private static final String ICONIFIED = "iconified"; private static final String NO_HTTPS = "no-https"; /** * Parses command line arguments. Should be called before Spring Boot is initialized. * * @param args the command line arguments */ public static void parse(String[] args) { var appArgs = new DefaultApplicationArguments(args); for (var arg : appArgs.getNonOptionArgs()) { switch (arg) { case "-h", "-help", "help" -> showHelp(); default -> throw new IllegalArgumentException("Unknown argument [" + arg + "]. Run with the --help argument."); } } for (var arg : appArgs.getOptionNames()) { switch (arg) { case HELP -> showHelp(); case VERSION -> showVersion(); case DATA_DIR -> setString(StartupProperties.Property.DATA_DIR, appArgs, arg); case CONTROL_PORT -> { setPort(StartupProperties.Property.CONTROL_PORT, appArgs, arg); setPort(StartupProperties.Property.UI_PORT, appArgs, arg); } case CONTROL_ADDRESS -> setString(StartupProperties.Property.CONTROL_ADDRESS, appArgs, arg); case NO_CONTROL_PASSWORD -> setBooleanInverted(StartupProperties.Property.CONTROL_PASSWORD, appArgs, arg); case SERVER_ADDRESS -> setString(StartupProperties.Property.SERVER_ADDRESS, appArgs, arg); case SERVER_PORT -> setPort(StartupProperties.Property.SERVER_PORT, appArgs, arg); case REMOTE_CONNECT -> { var ipAndPort = emptyIfNull(appArgs.getOptionValues(arg)).stream() .findFirst() .orElseThrow(() -> new IllegalArgumentException(REMOTE_CONNECT + " must specify a host or host:port like 'localhost' or 'localhost:6232'")); StartupProperties.setUiRemoteConnect(ipAndPort, StartupProperties.Origin.ARGUMENT); } case REMOTE_PASSWORD -> setString(StartupProperties.Property.REMOTE_PASSWORD, appArgs, arg); case NO_GUI -> setBooleanInverted(StartupProperties.Property.UI, appArgs, arg); case FAST_SHUTDOWN -> setBoolean(StartupProperties.Property.FAST_SHUTDOWN, appArgs, arg); case SERVER_ONLY -> setBoolean(StartupProperties.Property.SERVER_ONLY, appArgs, arg); case ICONIFIED -> setBoolean(StartupProperties.Property.ICONIFIED, appArgs, arg); case NO_HTTPS -> setBooleanInverted(StartupProperties.Property.HTTPS, appArgs, arg); default -> throw new IllegalArgumentException("Unknown argument " + arg); } } } private static void setBoolean(StartupProperties.Property property, ApplicationArguments appArgs, String arg) { if (!emptyIfNull(appArgs.getOptionValues(arg)).isEmpty()) { throw new IllegalArgumentException("--" + arg + " doesn't expect a value"); } StartupProperties.setBoolean(property, "true", StartupProperties.Origin.ARGUMENT); } private static void setBooleanInverted(StartupProperties.Property property, ApplicationArguments appArgs, String arg) { if (!emptyIfNull(appArgs.getOptionValues(arg)).isEmpty()) { throw new IllegalArgumentException("--" + arg + " doesn't expect a value"); } StartupProperties.setBoolean(property, "false", StartupProperties.Origin.ARGUMENT); } private static void setString(StartupProperties.Property property, ApplicationArguments appArgs, String arg) { try { StartupProperties.setString(property, getValue(appArgs, arg), StartupProperties.Origin.ARGUMENT); } catch (IllegalArgumentException _) { throw new IllegalArgumentException("--" + arg + " does not contain a value"); } } private static void setPort(StartupProperties.Property property, ApplicationArguments appArgs, String arg) { try { StartupProperties.setPort(property, getValue(appArgs, arg), StartupProperties.Origin.ARGUMENT); } catch (IllegalArgumentException _) { throw new IllegalArgumentException("--" + arg + " must specify a port bigger than 0 and smaller than 65536"); } } private static String getValue(ApplicationArguments appArgs, String arg) { var optionValues = emptyIfNull(appArgs.getOptionValues(arg)); if (optionValues.isEmpty()) { throw new IllegalArgumentException("--" + arg + " expects a value"); } else if (optionValues.size() > 1) { throw new IllegalArgumentException("--" + arg + " cannot be specified more than once"); } return optionValues.getFirst(); } private static void showHelp() { var output = String.format(""" Usage: %s [--options] where options include: --no-gui start without an UI --iconified start iconified into the tray --data-dir= specify the data directory --control-address= specify the address to bind to for incoming remote access (defaults to 127.0.0.1) --control-port= specify the control port for remote access --no-control-password do not protect the control address with a password --no-https do not use HTTPS for the control connection --server-address= specify a local address to bind to (defaults to all interfaces) --server-port= specify the local port to bind to for incoming peer connections --fast-shutdown ignore proper shutdown procedure (not recommended) --server-only only accept incoming connections, do not make outgoing ones --remote-connect=[:] act as an UI client only and connect to a remote server --remote-password= password to use when connecting remotely --version print the version of the software --help print this help message See https://xeres.io/docs/ for more details. """, AppName.NAME); portableOutput(output); System.exit(0); } private static void showVersion() { var buildInfo = CommandArgument.class.getClassLoader().getResourceAsStream("META-INF/build-info.properties"); if (buildInfo != null) { try (var reader = new BufferedReader(new InputStreamReader(buildInfo))) { reader.lines().filter(s -> s.startsWith("build.version=")) .forEach(s -> portableOutput(AppName.NAME + " " + s.substring(s.indexOf('=') + 1))); } catch (IOException e) { portableOutput("Couldn't get version information: " + e.getMessage()); } } else { portableOutput("Couldn't get version information: resource not found"); } System.exit(0); } private static void portableOutput(String s) { if (System.console() != null) { System.out.print(s); } else { MUI.showInformation(s); } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/environment/DefaultProperties.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.environment; import io.xeres.common.properties.StartupProperties; import io.xeres.common.properties.StartupProperties.Origin; import io.xeres.common.util.OsUtils; import java.io.IOException; import java.nio.file.Files; import static io.xeres.common.properties.StartupProperties.Property.HTTPS; import static io.xeres.common.properties.StartupProperties.Property.LOGFILE; public final class DefaultProperties { private DefaultProperties() { throw new UnsupportedOperationException("Utility class"); } public static void setDefaults() { // We default to HTTPS and have to specify it here because RemoteUtils // uses the property to know in which mode it is. StartupProperties.setBoolean(HTTPS, "true", Origin.PROPERTY); // If we're running from jpackage (aka, we're a final installation), // then we set the log file to a sensible path. We have to do it early too! if (OsUtils.isInstalled()) { var logFile = OsUtils.getLogFile(); try { Files.createDirectories(logFile.getParent()); StartupProperties.setString(LOGFILE, logFile.toString(), Origin.PROPERTY); } catch (IOException e) { throw new RuntimeException(e); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/environment/HostVariable.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.environment; import io.xeres.common.properties.StartupProperties; import io.xeres.common.properties.StartupProperties.Property; import java.util.Optional; import static io.xeres.common.properties.StartupProperties.Property.*; /** * This utility class allows setting properties using the content of env variables. * This is especially useful when run from containers. */ public final class HostVariable { /** * The location of the data directory. Either an absolute or a relative path. */ private static final String XERES_DATA_DIR = "XERES_DATA_DIR"; /** * The control port of the server (that is, where the UI client is sending commands to). */ private static final String XERES_CONTROL_PORT = "XERES_CONTROL_PORT"; /** * The interface address to bind to (default: all). */ private static final String XERES_SERVER_ADDRESS = "XERES_SERVER_ADDRESS"; /** * The incoming port for peer connections. */ private static final String XERES_SERVER_PORT = "XERES_SERVER_PORT"; /** * If we are running in server mode only (that is, we're only accepting incoming connections). * Ideal for a chat server. */ private static final String XERES_SERVER_ONLY = "XERES_SERVER_ONLY"; private static final String XERES_HTTPS = "XERES_HTTPS"; private static final String XERES_CONTROL_PASSWORD = "XERES_CONTROL_PASSWORD"; private static final String ENVIRONMENT_VARIABLE_STRING = "Environment variable"; private HostVariable() { throw new UnsupportedOperationException("Utility class"); } /** * Sets properties using env variables. */ public static void parse() { get(XERES_DATA_DIR).ifPresent(s -> setString(XERES_DATA_DIR, DATA_DIR, s)); get(XERES_SERVER_ONLY).ifPresent(s -> setBoolean(XERES_SERVER_ONLY, SERVER_ONLY, s)); get(XERES_CONTROL_PORT).ifPresent(s -> { setPort(XERES_CONTROL_PORT, CONTROL_PORT, s); setPort(XERES_CONTROL_PORT, UI_PORT, s); }); get(XERES_SERVER_ADDRESS).ifPresent(s -> setString(XERES_SERVER_ADDRESS, SERVER_ADDRESS, s)); get(XERES_SERVER_PORT).ifPresent(s -> setPort(XERES_SERVER_PORT, SERVER_PORT, s)); get(XERES_HTTPS).ifPresent(s -> setBoolean(XERES_HTTPS, HTTPS, s)); get(XERES_CONTROL_PASSWORD).ifPresent(s -> setBoolean(XERES_CONTROL_PASSWORD, CONTROL_PASSWORD, s)); } private static Optional get(String key) { return Optional.ofNullable(System.getenv(key)); } private static void setString(String name, Property property, String value) { try { StartupProperties.setString(property, value, StartupProperties.Origin.ENVIRONMENT_VARIABLE); } catch (IllegalArgumentException _) { throw new IllegalArgumentException(ENVIRONMENT_VARIABLE_STRING + " " + name + " does not contain a value"); } } private static void setBoolean(String name, Property property, String value) { try { StartupProperties.setBoolean(property, value, StartupProperties.Origin.ENVIRONMENT_VARIABLE); } catch (IllegalArgumentException _) { throw new IllegalArgumentException(ENVIRONMENT_VARIABLE_STRING + " " + name + " does not contain a boolean value (" + value + ")"); } } private static void setPort(String name, Property property, String value) { try { StartupProperties.setPort(property, value, StartupProperties.Origin.ENVIRONMENT_VARIABLE); } catch (IllegalArgumentException _) { throw new IllegalArgumentException(ENVIRONMENT_VARIABLE_STRING + " " + name + " does not contain a valid port bigger than 0 and smaller than 65536 (" + value + ")"); } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/environment/LocalPortFinder.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.environment; import io.xeres.common.properties.StartupProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.ServerSocket; import static io.xeres.common.properties.StartupProperties.Property.*; public final class LocalPortFinder { private static final Logger log = LoggerFactory.getLogger(LocalPortFinder.class); private static final int DEFAULT_PORT = 6232; private static final int MAX_INSTANCES = 1024; private LocalPortFinder() { throw new UnsupportedOperationException("Utility class"); } public static void ensureFreePort() { var uiAddress = StartupProperties.getBoolean(UI_ADDRESS); if (uiAddress != null) { return; // Don't bother with free port selection if we only want to connect to a remote client } var port = StartupProperties.getInteger(CONTROL_PORT); if (port == null) { port = DEFAULT_PORT; } var portMax = Math.min(65536, port + MAX_INSTANCES); var portFound = -1; for (int i = port; i < portMax; i++) { try (var ignored = new ServerSocket(i)) { portFound = i; break; } catch (IOException _) { // Port already in use } } if (portFound == -1) { throw new IllegalStateException("No local port available, tried range: " + port + "-" + portMax); } else { if (port != portFound) { log.info("Local port {} already used, using {} instead", port, portFound); } // Make sure the properties are always set StartupProperties.setPort(CONTROL_PORT, String.valueOf(portFound), StartupProperties.Origin.PROPERTY); StartupProperties.setPort(UI_PORT, String.valueOf(portFound), StartupProperties.Origin.PROPERTY); } } } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/DhtNodeFoundEvent.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.HostPort; /** * This event is sent when a node is found using the DHT. * * @param locationIdentifier the location identifier * @param hostPort the host and port of the node */ public record DhtNodeFoundEvent(LocationIdentifier locationIdentifier, HostPort hostPort) { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/IpChangedEvent.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; /** * This event is sent when the current local IP changed. * * @param localIpAddress the new IP address */ public record IpChangedEvent(String localIpAddress) { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/LocationReadyEvent.java ================================================ /* * Copyright (c) 2019-2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; /** * Event that is sent once the application has a location (that is, a profile + location has been created or is available) * and is thus ready to start the network to connect to other peers. */ public record LocationReadyEvent() { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/NetworkReadyEvent.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; /** * Event that is sent once the network is ready (aka the peer service is started). */ public record NetworkReadyEvent() { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/PeerConnectedEvent.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; import io.xeres.common.id.LocationIdentifier; public record PeerConnectedEvent(LocationIdentifier locationIdentifier) { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/PeerDisconnectedEvent.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; import io.xeres.common.id.LocationIdentifier; /** * Event that is sent when a peer is disconnected. * * @param id the location id * @param locationIdentifier the location identifier */ public record PeerDisconnectedEvent(long id, LocationIdentifier locationIdentifier) { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/SettingsChangedEvent.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; import io.xeres.app.database.model.settings.Settings; /** * This event is sent when the settings are changed. * * @param oldSettings the old settings * @param newSettings the new settings */ public record SettingsChangedEvent(Settings oldSettings, Settings newSettings) { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/UpnpEvent.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.events; /** * This event is sent when there's some update on the UPNP side. * * @param localPort the local port * @param portsForwarded if true, the ports have been forwarded with UPNP * @param externalIpFound if true, the external IP address has been found */ public record UpnpEvent(int localPort, boolean portsForwarded, boolean externalIpFound) { } ================================================ FILE: app/src/main/java/io/xeres/app/application/events/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * Spring application events. * Beware: those events are asynchronous which means they'll run in a new thread. If you * need a synchronous event, make sure your event implements SynchronousEvent. */ package io.xeres.app.application.events; ================================================ FILE: app/src/main/java/io/xeres/app/configuration/AsynchronousEventsConfiguration.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.common.events.SynchronousEvent; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; import org.springframework.context.PayloadApplicationEvent; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.event.ApplicationEventMulticaster; import org.springframework.context.event.SimpleApplicationEventMulticaster; import org.springframework.core.ResolvableType; import org.springframework.core.task.SimpleAsyncTaskExecutor; import java.util.concurrent.RejectedExecutionException; /** * This configuration makes the events asynchronous, that is, the method * publishing them will return immediately instead of blocking. If you want synchronous events, * just make them implement SynchronousEvent. */ @Configuration public class AsynchronousEventsConfiguration { @Bean(name = "applicationEventMulticaster") public ApplicationEventMulticaster simpleApplicationEventMulticaster() { var eventMulticaster = new SimpleApplicationEventMulticaster() { @Override public void multicastEvent(ApplicationEvent event, ResolvableType eventType) { var type = eventType != null ? eventType : ResolvableType.forInstance(event); var executor = getTaskExecutor(); for (ApplicationListener listener : getApplicationListeners(event, type)) { if (executor != null && listener.supportsAsyncExecution() && !isSynchronousEvent(event)) { try { executor.execute(() -> invokeListener(listener, event)); } catch (RejectedExecutionException _) { invokeListener(listener, event); } } else { invokeListener(listener, event); } } } }; eventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor()); return eventMulticaster; } private static boolean isSynchronousEvent(ApplicationEvent event) { if (event instanceof SynchronousEvent) { return true; } //noinspection RedundantIfStatement if (event instanceof PayloadApplicationEvent && ((PayloadApplicationEvent) event).getPayload() instanceof SynchronousEvent) { return true; } return false; } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/AutoStartConfiguration.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.application.autostart.AutoStarter; import io.xeres.app.application.autostart.autostarter.AutoStarterGeneric; import io.xeres.app.application.autostart.autostarter.AutoStarterWindows; import io.xeres.common.condition.OnWindowsCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; /** * Sets up the autostart feature that starts Xeres when the users logs in. * Currently implemented for Windows only. */ @Configuration public class AutoStartConfiguration { @Bean @Conditional(OnWindowsCondition.class) AutoStarter windowsAutoStarter() { return new AutoStarterWindows(); } @Bean @ConditionalOnMissingBean AutoStarter genericAutoStarter() { return new AutoStarterGeneric(); } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/CacheDirConfiguration.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.util.DevUtils; import io.xeres.common.util.OsUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; /** * This configuration handles the cache directory location. This is stored locally and is deleted upon * uninstallation. *

* Portable versions use a cache directory alongside the data directory. */ @Configuration public class CacheDirConfiguration { private static final Logger log = LoggerFactory.getLogger(CacheDirConfiguration.class); private final Environment environment; private String cacheDir; public CacheDirConfiguration(Environment environment) { this.environment = environment; } public String getCacheDir() { if (cacheDir != null) { return cacheDir; } // If a datasource is already set (that is, tests), then we don't return anything if (environment.getProperty("spring.datasource.url") != null) { return null; } if (environment.acceptsProfiles(Profiles.of("dev"))) { cacheDir = DevUtils.getDirFromDevelopmentSetup("cache"); } if (cacheDir == null) { cacheDir = getCacheDirFromPortableFileLocation(); } if (cacheDir == null) { cacheDir = OsUtils.getCacheDir().toString(); } var path = Path.of(cacheDir); if (Files.notExists(path)) { try { Files.createDirectory(path); } catch (IOException e) { log.error("Couldn't create cache directory: {}, {}. Cache won't be available", cacheDir, e.getMessage()); return null; } } return cacheDir; } private static String getCacheDirFromPortableFileLocation() { var portable = Path.of("portable"); if (Files.exists(portable)) { return portable.resolveSibling("Cache").toAbsolutePath().toString(); } return null; } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/CustomCsrfChannelInterceptor.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.stereotype.Component; /** * The following disables WebSocket's CSRF. */ @Component("csrfChannelInterceptor") public class CustomCsrfChannelInterceptor implements ChannelInterceptor { } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/DataDirConfiguration.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.application.SingleInstanceRun; import io.xeres.app.util.DevUtils; import io.xeres.common.AppName; import io.xeres.common.properties.StartupProperties; import io.xeres.common.util.OsUtils; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; import static io.xeres.common.properties.StartupProperties.Property.DATA_DIR; /** * Configuration for everything related to the user data directory (database, keys, user data, ...). */ @Configuration public class DataDirConfiguration { private static final String LOCAL_DATA = "data"; private final Environment environment; private String dataDir; public DataDirConfiguration(Environment environment) { this.environment = environment; } /** * Gets the data directory where all user data is stored. * Note: this is not really used as a proper bean. DataSourceConfiguration depends on it, but it's accessed by the method. * @return the path to the data directory */ @Bean public String getDataDir() { if (dataDir != null) { return dataDir; } // If a datasource is already set (that is, tests), then we don't return anything if (environment.getProperty("spring.datasource.url") != null) { return null; } dataDir = getDataDirFromArgs(); if (dataDir == null && environment.acceptsProfiles(Profiles.of("dev"))) { dataDir = DevUtils.getDirFromDevelopmentSetup(LOCAL_DATA); } if (dataDir == null) { dataDir = getDataDirFromPortableFileLocation(); } if (dataDir == null) { dataDir = OsUtils.getDataDir().toString(); } Objects.requireNonNull(dataDir); var path = Path.of(dataDir); if (Files.notExists(path)) { try { Files.createDirectory(path); } catch (IOException e) { throw new IllegalStateException("Couldn't create data directory: " + dataDir + ", :" + e.getMessage()); } } if (!SingleInstanceRun.enforceSingleInstance(dataDir)) { throw new IllegalStateException("An instance of " + AppName.NAME + " is already running, path: " + dataDir); } return dataDir; } private static String getDataDirFromArgs() { return StartupProperties.getString(DATA_DIR); } private static String getDataDirFromPortableFileLocation() { var portable = Path.of("portable"); if (Files.exists(portable)) { return portable.resolveSibling(LOCAL_DATA).toAbsolutePath().toString(); } return null; } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/DataSourceConfiguration.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.properties.DatabaseProperties; import io.xeres.app.service.UiBridgeService; import io.xeres.app.service.UiBridgeService.SplashStatus; import org.h2.tools.Upgrade; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; import javax.sql.DataSource; import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; /** * Configuration for the location and options of the database. */ @Configuration @DependsOn("getDataDir") public class DataSourceConfiguration { private static final Logger log = LoggerFactory.getLogger(DataSourceConfiguration.class); private static final int H2_UPGRADE_FROM_VERSION = 214; private static final int H2_UPGRADE_CURRENT_FORMAT = 3; private static final String H2_URL_PREFIX = "jdbc:h2:file:"; private static final String H2_USERNAME = "sa"; private final DatabaseProperties databaseProperties; private final DataDirConfiguration dataDirConfiguration; private final UiBridgeService uiBridgeService; public DataSourceConfiguration(DatabaseProperties databaseProperties, DataDirConfiguration dataDirConfiguration, UiBridgeService uiBridgeService) { this.databaseProperties = databaseProperties; this.dataDirConfiguration = dataDirConfiguration; this.uiBridgeService = uiBridgeService; } @Bean @ConditionalOnProperty(prefix = "spring.datasource", name = "url", havingValue = "false", matchIfMissing = true) public DataSource getDataSource() { uiBridgeService.setSplashStatus(SplashStatus.DATABASE); var useJMX = ";JMX=TRUE"; var disableTraces = ";TRACE_LEVEL_FILE=0"; // Set to 4 for verbose output using Slf4J var dataDir = Path.of(dataDirConfiguration.getDataDir(), "userdata").toString(); log.debug("Using database file: {}", dataDir); var dbOpts = ";DB_CLOSE_ON_EXIT=FALSE"; if (databaseProperties.getCacheSize() != null) { dbOpts += ";CACHE_SIZE=" + databaseProperties.getCacheSize(); } if (databaseProperties.getMaxCompactTime() != null) { dbOpts += ";MAX_COMPACT_TIME=" + databaseProperties.getMaxCompactTime(); } var url = H2_URL_PREFIX + dataDir + dbOpts + useJMX + disableTraces; upgradeIfNeeded(url); return DataSourceBuilder .create() .url(url) .username(H2_USERNAME) .driverClassName("org.h2.Driver") .build(); } private static void upgradeIfNeeded(String url) { if (!url.startsWith(H2_URL_PREFIX)) { log.debug("Not an H2 file, no upgrade needed"); return; } var fileName = url.substring(13, url.indexOf(";")) + ".mv.db"; var filePath = Path.of(fileName); if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { log.debug("No file present, no upgrade needed"); return; } try (var reader = new BufferedReader(new FileReader(filePath.toFile()))) { var header = reader.readLine(); if (header.contains("format:" + H2_UPGRADE_CURRENT_FORMAT)) { log.debug("No upgrade needed for H2"); return; } } catch (IOException e) { throw new RuntimeException("Couldn't read database: " + e.getMessage()); } var properties = new Properties(); properties.put("USER", H2_USERNAME); properties.put("PASSWORD", ""); try { Upgrade.upgrade(url, properties, H2_UPGRADE_FROM_VERSION); } catch (Exception e) { log.error("Couldn't perform upgrade: {}", e.getMessage(), e); } } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/EnumMappingConfiguration.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * This configuration makes sure that enums in web parameters don't require * to be in uppercase. */ @Configuration public class EnumMappingConfiguration implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { ApplicationConversionService.configure(registry); } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/GeoIpConfiguration.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import com.maxmind.geoip2.DatabaseReader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.io.IOException; import java.util.Objects; /** * This configuration sets up the GeoIP database. */ @Configuration public class GeoIpConfiguration { private static final Logger log = LoggerFactory.getLogger(GeoIpConfiguration.class); @Bean public DatabaseReader getDatabaseReader() { var database = Objects.requireNonNull(GeoIpConfiguration.class.getResourceAsStream("/GeoLite2-Country.mmdb")); try { return new DatabaseReader.Builder(database).build(); } catch (IOException e) { log.error("Couldn't setup GeoIP: {}", e.getMessage(), e); return null; } } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/IdleTimeConfiguration.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.xrs.service.status.GetIdleTime; import io.xeres.app.xrs.service.status.idletimer.GetIdleTimeGeneric; import io.xeres.app.xrs.service.status.idletimer.GetIdleTimeLinux; import io.xeres.app.xrs.service.status.idletimer.GetIdleTimeMac; import io.xeres.app.xrs.service.status.idletimer.GetIdleTimeWindows; import io.xeres.common.condition.OnLinuxCondition; import io.xeres.common.condition.OnMacCondition; import io.xeres.common.condition.OnWindowsCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; /** * This configuration sets up the idle time detector to know * when the user is idle. */ @Configuration public class IdleTimeConfiguration { @Bean @Conditional(OnWindowsCondition.class) GetIdleTime windowsIdleTime() { return new GetIdleTimeWindows(); } @Bean @Conditional(OnLinuxCondition.class) GetIdleTime linuxIdleTime() { return new GetIdleTimeLinux(); } @Bean @Conditional(OnMacCondition.class) GetIdleTime macIdleTime() { return new GetIdleTimeMac(); } @Bean @ConditionalOnMissingBean GetIdleTime genericIdleTime() { return new GetIdleTimeGeneric(); } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/SchedulerConfiguration.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; /** * Configuration of the scheduler. Just enables it. We use JDK 21 and virtual threads * are enabled so there's no need to set up a thread pool anymore. */ @Configuration @EnableScheduling public class SchedulerConfiguration { } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/SelfCertificateConfiguration.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.crypto.rsa.RSA; import org.apache.catalina.connector.Connector; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.tomcat.TomcatConnectorCustomizer; import org.springframework.boot.web.server.autoconfigure.ServerProperties; import org.springframework.context.annotation.Configuration; import java.io.File; import java.io.IOException; import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.KeyPair; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.time.Instant; import java.util.Date; import java.util.Objects; /** * Strongly inspired from let's encrypt helper by Valentyn Berezin. */ @Configuration @ConditionalOnExpression("'${server.ssl.enabled}' == 'true' && '${spring.main.web-application-type}' != 'none'") public class SelfCertificateConfiguration implements TomcatConnectorCustomizer { private static final Logger log = LoggerFactory.getLogger(SelfCertificateConfiguration.class); private static final int KEY_SIZE = 3072; private final ServerProperties serverProperties; public SelfCertificateConfiguration(ServerProperties serverProperties, DataDirConfiguration dataDirConfiguration) { this.serverProperties = serverProperties; if (dataDirConfiguration.getDataDir() == null) // Ignore for tests... { return; } Objects.requireNonNull(this.serverProperties.getSsl(), "Missing 'server.ssl.enabled' property"); this.serverProperties.getSsl().setKeyStore("file:" + Path.of(dataDirConfiguration.getDataDir(), "keystore.pfx").toAbsolutePath()); createKeystoreIfNeeded(); } private void createKeystoreIfNeeded() { var keystoreFile = getKeystoreFile(); if (keystoreFile.exists()) { log.debug("Keystore exists: {}", keystoreFile.getAbsolutePath()); return; } log.info("Creating self-signed certificate for HTTPS access..."); var keystore = createKeystoreWithSelfSignedCertificate(); saveKeystore(keystoreFile, keystore); log.info("Created keystore {}", keystoreFile.getAbsolutePath()); } private File getKeystoreFile() { //noinspection DataFlowIssue return new File(parseCertificateKeystoreFilePath(serverProperties.getSsl().getKeyStore())); } private String parseCertificateKeystoreFilePath(String path) { return path.replace("file://", "").replace("file:", ""); } private KeyStore createKeystoreWithSelfSignedCertificate() { try { var domainKey = RSA.generateKeys(KEY_SIZE); //noinspection DataFlowIssue var newKeystore = KeyStore.getInstance(serverProperties.getSsl().getKeyStoreType()); newKeystore.load(null, null); var signedDomain = selfSign(domainKey, Instant.EPOCH, Instant.EPOCH); newKeystore.setKeyEntry(serverProperties.getSsl().getKeyAlias(), domainKey.getPrivate(), keyPassword().toCharArray(), new Certificate[]{signedDomain}); return newKeystore; } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) { throw new RuntimeException(e); } } private Certificate selfSign(KeyPair keyPair, @SuppressWarnings("SameParameterValue") Instant notBefore, @SuppressWarnings("SameParameterValue") Instant notAfter) { var dnName = new X500Name("CN=Xeres"); var serialNumber = BigInteger.valueOf(Instant.now().toEpochMilli()); var subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()); var builder = new X509v3CertificateBuilder( dnName, serialNumber, Date.from(notBefore), Date.from(notAfter), dnName, subjectPublicKeyInfo ); try { var contentSigner = new JcaContentSignerBuilder("SHA256WithRSA").build(keyPair.getPrivate()); var certificateHolder = builder.build(contentSigner); return new JcaX509CertificateConverter().getCertificate(certificateHolder); } catch (CertificateException | OperatorCreationException e) { throw new RuntimeException(e); } } private String keyPassword() { //noinspection DataFlowIssue return serverProperties.getSsl().getKeyPassword() != null ? serverProperties.getSsl().getKeyPassword() : serverProperties.getSsl().getKeyStorePassword(); } private void saveKeystore(File keystoreFile, KeyStore keystore) { try (var out = Files.newOutputStream(keystoreFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { //noinspection DataFlowIssue keystore.store(out, serverProperties.getSsl().getKeyStorePassword().toCharArray()); } catch (CertificateException | IOException | NoSuchAlgorithmException | KeyStoreException e) { throw new RuntimeException(e); } } @Override public void customize(Connector connector) { // This is needed so that our configuration is called early, before Tomcat is initialized. } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/WebConfiguration.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.ui.client.PaginatedResponse; import org.springframework.context.annotation.Configuration; import org.springframework.data.web.config.EnableSpringDataWebSupport; import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO; /** * This configuration is used to enable Paginated elements to be output as a stable JSON. * See the {@link PaginatedResponse} DTO. */ @Configuration @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) public class WebConfiguration { } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/WebSecurityConfiguration.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.service.SettingsService; import io.xeres.common.properties.StartupProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager; import static io.xeres.common.properties.StartupProperties.Property.CONTROL_PASSWORD; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; @Configuration @EnableWebSecurity public class WebSecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, SettingsService settingsService) { http .csrf(AbstractHttpConfigurer::disable) // Not needed for desktop app .authorizeHttpRequests(authorize -> { authorize.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll(); if (settingsService.isRemoteEnabled()) { if (settingsService.hasRemotePassword() && StartupProperties.getBoolean(CONTROL_PASSWORD, true)) { authorize.anyRequest().authenticated(); } else { authorize.anyRequest().anonymous(); } } else { if (settingsService.hasRemotePassword() && StartupProperties.getBoolean(CONTROL_PASSWORD, true)) { authorize.anyRequest().access(new WebExpressionAuthorizationManager("isAuthenticated() && hasIpAddress('127.0.0.1')")); } else { authorize.anyRequest().access(new WebExpressionAuthorizationManager("isAnonymous() && hasIpAddress('127.0.0.1')")); } } } ) .httpBasic(Customizer.withDefaults()) .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)); return http.build(); } @Bean public UserDetailsService userDetailsService(SettingsService settingsService) { var userDetails = User.withUsername("user") .password("{noop}" + settingsService.getRemotePassword()) .roles("USER") .build(); return new InMemoryUserDetailsManager(userDetails); } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/WebServerConfiguration.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.application.environment.LocalPortFinder; import io.xeres.app.service.SettingsService; import io.xeres.common.properties.StartupProperties; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.boot.web.server.servlet.ConfigurableServletWebServerFactory; import org.springframework.context.annotation.Configuration; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.Objects; import static io.xeres.common.properties.StartupProperties.Property.CONTROL_PORT; @Configuration public class WebServerConfiguration implements WebServerFactoryCustomizer { private final SettingsService settingsService; public WebServerConfiguration(SettingsService settingsService) { this.settingsService = settingsService; } @Override public void customize(ConfigurableServletWebServerFactory factory) { // If we are allowing remote access, bind to all interfaces if (StartupProperties.Property.CONTROL_ADDRESS.isUnset() && settingsService.isRemoteEnabled()) { factory.setAddress(getAllInterfaces()); } // If the port configured in the settings is different from CONTROL_PORT, then use it instead. if (StartupProperties.Property.CONTROL_PORT.isUnset() && settingsService.hasRemotePortConfigured() && settingsService.getRemotePort() != Objects.requireNonNull(StartupProperties.getInteger(StartupProperties.Property.CONTROL_PORT))) { StartupProperties.setPort(CONTROL_PORT, String.valueOf(settingsService.getRemotePort()), StartupProperties.Origin.PROPERTY); LocalPortFinder.ensureFreePort(); factory.setPort(Objects.requireNonNull(StartupProperties.getInteger(StartupProperties.Property.CONTROL_PORT))); } } private static InetAddress getAllInterfaces() { try { return InetAddress.getByAddress(new byte[]{0, 0, 0, 0}); } catch (UnknownHostException e) { throw new RuntimeException(e); } } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/WebSocketConfiguration.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; import static io.xeres.common.message.MessagingConfiguration.MAXIMUM_MESSAGE_SIZE; @Configuration @EnableWebSocket @ConditionalOnExpression("'${spring.main.web-application-type}' != 'none'") public class WebSocketConfiguration implements WebSocketConfigurer { @Bean public ServletServerContainerFactoryBean createServletServerContainerFactoryBean() { var container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(MAXIMUM_MESSAGE_SIZE); container.setMaxBinaryMessageBufferSize(MAXIMUM_MESSAGE_SIZE); return container; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { // No custom handlers, we use STOMP } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/WebSocketLoggingConfiguration.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import jakarta.annotation.PostConstruct; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.WebSocketMessageBrokerStats; @Configuration public class WebSocketLoggingConfiguration { private final WebSocketMessageBrokerStats webSocketMessageBrokerStats; public WebSocketLoggingConfiguration(WebSocketMessageBrokerStats webSocketMessageBrokerStats) { this.webSocketMessageBrokerStats = webSocketMessageBrokerStats; } @PostConstruct private void init() { // Avoids stats messages printed each 30 minutes webSocketMessageBrokerStats.setLoggingPeriod(0L); } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/WebSocketMessageBrokerConfiguration.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Configuration; import org.springframework.context.event.EventListener; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.messaging.SessionDisconnectEvent; import org.springframework.web.socket.messaging.SessionSubscribeEvent; import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; import static io.xeres.common.message.MessagePath.APP_PREFIX; import static io.xeres.common.message.MessagePath.BROKER_PREFIX; import static io.xeres.common.message.MessagingConfiguration.MAXIMUM_MESSAGE_SIZE; /** * Configuration of the WebSocket. This is used for anything that requires a persistent connection from * the UI client to the server because of a bidirectional data stream (for example, chat windows). */ @Configuration @EnableWebSocketMessageBroker public class WebSocketMessageBrokerConfiguration implements WebSocketMessageBrokerConfigurer { private static final Logger log = LoggerFactory.getLogger(WebSocketMessageBrokerConfiguration.class); @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws"); registry.addEndpoint("/ws").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(BROKER_PREFIX); // this is for the broker (subscriptions, ...) registry.setApplicationDestinationPrefixes(APP_PREFIX); // this is for @Controller annotated endpoints using @MessageMapping and such } @EventListener public void handleSessionSubscribeEvent(SessionSubscribeEvent event) { log.debug("Subscription from {}", event); } @EventListener public void handleSessionUnsubscribeEvent(SessionUnsubscribeEvent event) { log.debug("Unsubscription from {}", event); } @EventListener public void handleSessionDisconnectEvent(SessionDisconnectEvent event) { log.debug("Disconnection from {}", event); } @Override public void configureWebSocketTransport(WebSocketTransportRegistration registry) { registry.setMessageSizeLimit(MAXIMUM_MESSAGE_SIZE); registry.setSendBufferSizeLimit(MAXIMUM_MESSAGE_SIZE); } } ================================================ FILE: app/src/main/java/io/xeres/app/configuration/WebSocketSecurityConfiguration.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import io.xeres.app.service.SettingsService; import io.xeres.common.properties.StartupProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; import org.springframework.security.authorization.AuthorityAuthorizationManager; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import static io.xeres.common.properties.StartupProperties.Property.CONTROL_PASSWORD; @Configuration @EnableWebSocketSecurity public class WebSocketSecurityConfiguration { @Bean AuthorizationManager> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages, SettingsService settingsService) { if (settingsService.hasRemotePassword() && StartupProperties.getBoolean(CONTROL_PASSWORD, true)) { return AuthorityAuthorizationManager.hasRole("USER"); } else { return AuthorityAuthorizationManager.hasRole("ANONYMOUS"); } } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/aead/AEAD.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.aead; import io.xeres.app.crypto.hmac.sha256.Sha256HMac; import javax.crypto.*; import javax.crypto.spec.ChaCha20ParameterSpec; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Objects; import static javax.crypto.Cipher.DECRYPT_MODE; import static javax.crypto.Cipher.ENCRYPT_MODE; /** * Authenticated Encryption with Associated Data. * This implementation uses Encrypt-then-MAC (EtM). */ public final class AEAD { private static final String ENCRYPTION_TRANSFORMATION_CHACHA20_POLY1305 = "ChaCha20-Poly1305"; private static final String ENCRYPTION_TRANSFORMATION_CHACHA20 = "ChaCha20"; private static final String ENCRYPTION_ALGORITHM_CHACHA20 = "ChaCha20"; private static final int TAG_SIZE = 16; private AEAD() { throw new UnsupportedOperationException("Utility class"); } /** * Generates a secret key. * * @return the secret key */ public static SecretKey generateKey() { try { var keyGenerator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM_CHACHA20); keyGenerator.init(256); return keyGenerator.generateKey(); } catch (NoSuchAlgorithmException e) { throw new IllegalArgumentException(e); } } /** * Encrypts using ChaCha20 as an AEAD cipher with Poly1305 as the authenticator. * @see RFC 7539 * @param key the secret key, not null * @param nonce a unique, securely generated nonce, not null * @param plainText the data to encrypt, not null * @param additionalAuthenticatedData additional authenticated data. Is used to authenticate the nonce, not null * @return the encrypted data */ public static byte[] encryptChaCha20Poly1305(SecretKey key, byte[] nonce, byte[] plainText, byte[] additionalAuthenticatedData) { Objects.requireNonNull(key); Objects.requireNonNull(nonce); Objects.requireNonNull(plainText); Objects.requireNonNull(additionalAuthenticatedData); if (nonce.length != 12) { throw new IllegalArgumentException("Nonce must be 12 bytes"); } try { return doChaCha20Poly1305(key, ENCRYPT_MODE, nonce, plainText, additionalAuthenticatedData); } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) { throw new IllegalArgumentException(e); } } /** * Decrypts using ChaCha20 as an AEAD cipher with Poly1305 as the authenticator. * @see RFC 7539 * @param key the secret key, not null * @param nonce the unique, securely generated nonce that was used for the encryption, not null * @param cipherText the encrypted data, not null * @param additionalAuthenticatedData additional authenticated data. Is used to authenticate the nonce, not null * @return the decrypted data */ public static byte[] decryptChaCha20Poly1305(SecretKey key, byte[] nonce, byte[] cipherText, byte[] additionalAuthenticatedData) { Objects.requireNonNull(key); Objects.requireNonNull(nonce); Objects.requireNonNull(cipherText); Objects.requireNonNull(additionalAuthenticatedData); if (nonce.length != 12) { throw new IllegalArgumentException("Nonce must be 12 bytes"); } try { return doChaCha20Poly1305(key, DECRYPT_MODE, nonce, cipherText, additionalAuthenticatedData); } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) { throw new IllegalArgumentException(e); } } private static byte[] doChaCha20Poly1305(SecretKey key, int operation, byte[] nonce, byte[] dataIn, byte[] additionalAuthenticatedData) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException { var cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION_CHACHA20_POLY1305); var ivParameterSpec = new IvParameterSpec(nonce); var keySpec = new SecretKeySpec(key.getEncoded(), ENCRYPTION_ALGORITHM_CHACHA20); cipher.init(operation, keySpec, ivParameterSpec); cipher.updateAAD(additionalAuthenticatedData); return cipher.doFinal(dataIn); } /** * Encrypts using ChaCha20 as an AEAD cipher with HMAC SHA-256. * * @param key the secret key, not null * @param nonce a unique, securely generated nonce, not null * @param plainText the data to encrypt, not null * @param additionalAuthenticatedData additional authenticated data. Can be used to authenticate the nonce, not null * @return the encrypted data * @see RFC 7539 */ public static byte[] encryptChaCha20Sha256(SecretKey key, byte[] nonce, byte[] plainText, byte[] additionalAuthenticatedData) { Objects.requireNonNull(key); Objects.requireNonNull(nonce); Objects.requireNonNull(plainText); Objects.requireNonNull(additionalAuthenticatedData); if (nonce.length != 12) { throw new IllegalArgumentException("Nonce must be 12 bytes"); } try { var encryptedData = doChaCha20(key, ENCRYPT_MODE, nonce, plainText); var tag = doSha256Hash(key, encryptedData, additionalAuthenticatedData); return ByteBuffer.allocate(encryptedData.length + TAG_SIZE) .put(encryptedData) .put(tag) .array(); } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) { throw new IllegalArgumentException(e); } } /** * Decrypts using ChaCha20 as an AEAD cipher with HMAC SHA-256. * * @param key the secret key, not null * @param nonce the unique, securely generated nonce that was used for the encryption, not null * @param cipherText the encrypted data, not null * @param additionalAuthenticatedData additional authenticated data. Is used to authenticate the nonce, not null * @return the decrypted data * @see RFC 7539 */ public static byte[] decryptChaCha20Sha256(SecretKey key, byte[] nonce, byte[] cipherText, byte[] additionalAuthenticatedData) { Objects.requireNonNull(key); Objects.requireNonNull(nonce); Objects.requireNonNull(cipherText); Objects.requireNonNull(additionalAuthenticatedData); if (nonce.length != 12) { throw new IllegalArgumentException("Nonce must be 12 bytes"); } var encryptedData = new byte[cipherText.length - TAG_SIZE]; var tag = new byte[TAG_SIZE]; var buf = ByteBuffer.wrap(cipherText); buf.get(encryptedData); buf.get(tag); try { var decryptedData = doChaCha20(key, DECRYPT_MODE, nonce, encryptedData); // Verify the SHA256 tag, this is performed after the decryption to avoid timing attacks. var resultingTag = doSha256Hash(key, encryptedData, additionalAuthenticatedData); if (!MessageDigest.isEqual(tag, resultingTag)) { throw new IllegalArgumentException("ChaCha20 SHA-256: Authentication failed"); } return decryptedData; } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) { throw new IllegalArgumentException(e); } } private static byte[] doChaCha20(SecretKey key, int operation, byte[] nonce, byte[] dataIn) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException { var cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION_CHACHA20); var chaCha20ParameterSpec = new ChaCha20ParameterSpec(nonce, 1); var keySpec = new SecretKeySpec(key.getEncoded(), ENCRYPTION_ALGORITHM_CHACHA20); cipher.init(operation, keySpec, chaCha20ParameterSpec); return cipher.doFinal(dataIn); } private static byte[] doSha256Hash(SecretKey key, byte[] encryptedData, byte[] additionalAuthenticatedData) { var tag = new byte[TAG_SIZE]; var hmac = new Sha256HMac(key); hmac.update(additionalAuthenticatedData); hmac.update(encryptedData); System.arraycopy(hmac.getBytes(), 0, tag, 0, TAG_SIZE); return tag; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/aes/AES.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.aes; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Objects; /** * AES 256 CBC encryption. */ public final class AES { private static final String ALGORITHM_AES = "AES/CBC/PKCS5Padding"; private static final int ROUNDS = 5; private static final int KEY_SIZE = 256; // in bits private static final int INDEX_KEY = 0; private static final int INDEX_IV = 1; private static final int IV_SIZE = 8; // in bytes private AES() { throw new UnsupportedOperationException("Utility class"); } /** * Encrypts using AES with a 16-byte key and an 8-byte salt. * * @param key the 16-byte key * @param iv an 8-byte initialization vector * @param plainText the plain text * @return the encoded text */ public static byte[] encrypt(byte[] key, byte[] iv, byte[] plainText) { return process(Cipher.ENCRYPT_MODE, key, iv, plainText); } /** * Decrypts using AES with a 16-byte key and an 8-byte salt. * * @param key the 16-byte key * @param iv an 8-byte initialization vector * @param encryptedText the encrypted text * @return the plain text */ public static byte[] decrypt(byte[] key, byte[] iv, byte[] encryptedText) { return process(Cipher.DECRYPT_MODE, key, iv, encryptedText); } private static byte[] process(int opMode, byte[] key, byte[] iv, byte[] data) { Objects.requireNonNull(key); Objects.requireNonNull(iv); Objects.requireNonNull(data); if (key.length != 16) { throw new IllegalArgumentException("Invalid key"); } if (iv.length != IV_SIZE) { throw new IllegalArgumentException("Invalid salt"); } try { var cipher = Cipher.getInstance(ALGORITHM_AES); var md = MessageDigest.getInstance("SHA-1"); byte[][] keyAndIv = EVP_BytesToKey(KEY_SIZE / Byte.SIZE, cipher.getBlockSize(), md, iv, key, ROUNDS); if (keyAndIv[INDEX_KEY].length != KEY_SIZE / Byte.SIZE) { throw new IllegalArgumentException("Key size is " + keyAndIv[INDEX_KEY].length + " bits, should be " + KEY_SIZE); } var secretKeySpecs = new SecretKeySpec(keyAndIv[INDEX_KEY], "AES"); var ivParameterSpecs = new IvParameterSpec(keyAndIv[INDEX_IV]); cipher.init(opMode, secretKeySpecs, ivParameterSpecs); return cipher.doFinal(data); } catch (NoSuchPaddingException | IllegalBlockSizeException | NoSuchAlgorithmException | InvalidAlgorithmParameterException | BadPaddingException | InvalidKeyException e) { throw new IllegalArgumentException(e); } } /** * OpenSSL equivalent, by Ola Bini, public domain. The source * is here. */ @SuppressWarnings("SameParameterValue") private static byte[][] EVP_BytesToKey(int keyLength, int ivLength, MessageDigest md, byte[] salt, byte[] data, int count) { var key = new byte[keyLength]; var iv = new byte[ivLength]; byte[] mdBuf = null; var keyPos = 0; var ivPos = 0; while (keyPos < keyLength || ivPos < ivLength) { md.reset(); // Include previous hash if not the first iteration if (mdBuf != null) { md.update(mdBuf); } md.update(data); md.update(salt, 0, 8); mdBuf = md.digest(); // Apply count iterations for (var i = 1; i < count; i++) { md.reset(); md.update(mdBuf); mdBuf = md.digest(); } // Fill key material var bufPos = 0; while (keyPos < keyLength && bufPos < mdBuf.length) { key[keyPos++] = mdBuf[bufPos++]; } // Fill IV material while (ivPos < ivLength && bufPos < mdBuf.length) { iv[ivPos++] = mdBuf[bufPos++]; } } return new byte[][]{key, iv}; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/dh/DiffieHellman.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.dh; import javax.crypto.KeyAgreement; import javax.crypto.spec.DHParameterSpec; import javax.crypto.spec.DHPublicKeySpec; import java.math.BigInteger; import java.security.*; import java.security.spec.InvalidKeySpecException; import io.xeres.common.util.SecureRandomUtils; public final class DiffieHellman { private static final String KEY_ALGORITHM = "DH"; // Those values are used by Retroshare (P is 2048 bits group, generated by OpenSSL) static final BigInteger P = new BigInteger("B3B86A844550486C7EA459FA468D3A8EFD71139593FE1C658BBEFA9B2FC0AD2628242C2CDC2F91F5B220ED29AAC271192A7374DFA28CDDCA70252F342D0821273940344A7A6A3CB70C7897A39864309F6CAC5C7EA18020EF882693CA2C12BB211B7BA8367D5A7C7252A5B5E840C9E8F081469EBA0B98BCC3F593A4D9C4D5DF539362084F1B9581316C1F80FDAD452FD56DBC6B8ED0775F596F7BB22A3FE2B4753764221528D33DB4140DE58083DB660E3E105123FC963BFF108AC3A268B7380FFA72005A1515C371287C5706FFA6062C9AC73A9B1A6AC842C2764CDACFC85556607E86611FDF486C222E4896CDF6908F239E177ACC641FCBFF72A758D1C10CBB", 16); static final BigInteger G = new BigInteger("5", 16); private DiffieHellman() { throw new UnsupportedOperationException("Utility class"); } public static KeyPair generateKeys() { try { var keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM); var dhParameterSpec = new DHParameterSpec(P, G); keyPairGenerator.initialize(dhParameterSpec, SecureRandomUtils.getGenerator()); return keyPairGenerator.generateKeyPair(); } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { throw new IllegalArgumentException("DH algorithm error: " + e.getMessage()); } } public static PublicKey getPublicKey(BigInteger pubKeyBigInteger) { try { var keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); return keyFactory.generatePublic(new DHPublicKeySpec(pubKeyBigInteger, P, G)); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { throw new IllegalArgumentException("DH algorithm error: " + e.getMessage()); } } public static byte[] generateCommonSecretKey(PrivateKey privateKey, PublicKey receivedPublicKey) { try { var keyAgreement = KeyAgreement.getInstance(KEY_ALGORITHM); keyAgreement.init(privateKey); keyAgreement.doPhase(receivedPublicKey, true); return keyAgreement.generateSecret(); } catch (InvalidKeyException | NoSuchAlgorithmException e) { throw new IllegalArgumentException("DH algorithm error: " + e.getMessage()); } } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/ec/Ed25519.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.ec; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; public final class Ed25519 { private static final String KEY_ALGORITHM = "Ed25519"; private Ed25519() { throw new UnsupportedOperationException("Utility class"); } public static KeyPair generateKeys(int size) { try { var keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM); keyPairGenerator.initialize(size); return keyPairGenerator.generateKeyPair(); } catch (NoSuchAlgorithmException _) { throw new IllegalArgumentException("Algorithm not supported"); } } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/hash/AbstractMessageDigest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hash; import java.nio.ByteBuffer; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public abstract class AbstractMessageDigest { protected final MessageDigest messageDigest; private byte[] result; protected AbstractMessageDigest(String algorithm) { try { messageDigest = MessageDigest.getInstance(algorithm); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } public void update(byte[] input) { resetCompletion(); messageDigest.update(input); } public void update(byte[] input, int offset, int length) { resetCompletion(); messageDigest.update(input, offset, length); } public void update(ByteBuffer input) { resetCompletion(); messageDigest.update(input); } public byte[] getBytes() { completeIfNeeded(); return result; } private void completeIfNeeded() { if (result == null) { result = messageDigest.digest(); } } private void resetCompletion() { result = null; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/hash/chat/ChatChallenge.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hash.chat; import io.xeres.common.id.Identifier; /** * Utility class to handle challenge codes, which allows peers to know if they * have a common private chat room without disclosing it first. */ public final class ChatChallenge { private ChatChallenge() { throw new UnsupportedOperationException("Utility class"); } public static long code(Identifier identifier, long chatRoomId, long messageId) { long code = 0; var id = identifier.getBytes(); for (var i = 0; i < identifier.getLength(); i++) { code += messageId; code ^= code >>> 35; code += code << 6; code ^= Byte.toUnsignedLong(id[i]) * chatRoomId; code += code << 26; code ^= code >>> 13; } return code; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/hash/sha1/Sha1MessageDigest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hash.sha1; import io.xeres.app.crypto.hash.AbstractMessageDigest; import io.xeres.common.id.Sha1Sum; public class Sha1MessageDigest extends AbstractMessageDigest { public Sha1MessageDigest() { super("SHA-1"); } public Sha1Sum getSum() { return new Sha1Sum(getBytes()); } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/hash/sha256/Sha256MessageDigest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hash.sha256; import io.xeres.app.crypto.hash.AbstractMessageDigest; public class Sha256MessageDigest extends AbstractMessageDigest { public Sha256MessageDigest() { super("SHA-256"); } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/hmac/AbstractHMac.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hmac; import javax.crypto.Mac; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; public abstract class AbstractHMac { protected final Mac mac; private byte[] result; protected AbstractHMac(SecretKey secretKey, String algorithm) { try { mac = Mac.getInstance(algorithm); mac.init(new SecretKeySpec(secretKey.getEncoded(), algorithm)); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException(e); } } public void update(byte[] input) { resetCompletion(); mac.update(input); } public void update(byte[] input, int offset, int length) { resetCompletion(); mac.update(input, offset, length); } public void update(ByteBuffer input) { resetCompletion(); mac.update(input); } public byte[] getBytes() { completeIfNeeded(); return result; } private void completeIfNeeded() { if (result == null) { result = mac.doFinal(); } } private void resetCompletion() { result = null; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/hmac/sha1/Sha1HMac.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hmac.sha1; import io.xeres.app.crypto.hmac.AbstractHMac; import javax.crypto.SecretKey; public class Sha1HMac extends AbstractHMac { public Sha1HMac(SecretKey secretKey) { super(secretKey, "HmacSHA1"); } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/hmac/sha256/Sha256HMac.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hmac.sha256; import io.xeres.app.crypto.hmac.AbstractHMac; import javax.crypto.SecretKey; public class Sha256HMac extends AbstractHMac { public Sha256HMac(SecretKey secretKey) { super(secretKey, "HmacSHA256"); } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/pgp/PGP.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.pgp; import io.xeres.app.crypto.rsa.RSA; import io.xeres.common.util.SecureRandomUtils; import org.bouncycastle.bcpg.ArmoredOutputStream; import org.bouncycastle.bcpg.BCPGOutputStream; import org.bouncycastle.bcpg.PublicKeyPacket; import org.bouncycastle.bcpg.SignaturePacket; import org.bouncycastle.openpgp.*; import org.bouncycastle.openpgp.jcajce.JcaPGPPublicKeyRingCollection; import org.bouncycastle.openpgp.operator.PGPContentSignerBuilder; import org.bouncycastle.openpgp.operator.jcajce.*; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.ByteBuffer; import java.security.InvalidKeyException; import java.security.KeyPair; import java.security.SignatureException; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Objects; import static io.xeres.common.Features.EXPERIMENTAL_EC; import static org.bouncycastle.bcpg.HashAlgorithmTags.*; import static org.bouncycastle.bcpg.PublicKeyAlgorithmTags.DSA; import static org.bouncycastle.bcpg.PublicKeyAlgorithmTags.Ed25519; import static org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags.AES_128; import static org.bouncycastle.openpgp.PGPPublicKey.RSA_GENERAL; import static org.bouncycastle.openpgp.PGPSignature.BINARY_DOCUMENT; import static org.bouncycastle.openpgp.PGPSignature.DEFAULT_CERTIFICATION; /** * Utility class containing all PGP related methods. */ public final class PGP { private PGP() { throw new UnsupportedOperationException("Utility class"); } public enum Armor { NONE, BASE64 } /** * Gets the PGP public key as an armored (ASCII) key. * * @param pgpPublicKey the public key * @param out the output stream * @throws IOException if three's an I/O error */ public static void getPublicKeyArmored(PGPPublicKey pgpPublicKey, OutputStream out) throws IOException { getPublicKeyArmored(pgpPublicKey.getEncoded(true), out); } /** * Gets the PGP public key as an armored (ASCII) key. * * @param data the public key as a byte array * @param out the output stream * @throws IOException if there's an I/O error */ public static void getPublicKeyArmored(byte[] data, OutputStream out) throws IOException { var aOut = new ArmoredOutputStream(out); var pgpObjectFactory = new PGPObjectFactory(data, new JcaKeyFingerprintCalculator()); var object = pgpObjectFactory.nextObject(); if (object instanceof PGPPublicKeyRing pgpPublicKeyRing) { for (var publicKey : pgpPublicKeyRing) { publicKey.encode(aOut); aOut.close(); } } else { throw new IllegalArgumentException("Wrong encoded key structure: " + object.getClass().getCanonicalName()); } } /** * Gets the PGP secret key. While a secret key needs a password to be converted to a private * key, this implementation uses an empty password. * * @param data a byte array containing the raw PGP key * @return the {@link PGPSecretKey} * @throws IllegalArgumentException if the key is wrong */ public static PGPSecretKey getPGPSecretKey(byte[] data) { var pgpObjectFactory = new PGPObjectFactory(data, new JcaKeyFingerprintCalculator()); try { var object = pgpObjectFactory.nextObject(); if (object instanceof PGPSecretKeyRing pgpSecretKeyRing) { if (!pgpSecretKeyRing.iterator().hasNext()) { throw new IllegalArgumentException("PGPSecretKeyRing is empty"); } return pgpSecretKeyRing.iterator().next(); } else { throw new IllegalArgumentException("PGPSecretKeyRing expected, got: " + object.getClass().getCanonicalName() + " instead"); } } catch (IOException e) { throw new IllegalArgumentException("PGPSecretKeyRing is corrupted", e); } } /** * Gets the PGP public key. * * @param data a byte array containing the raw PGP key * @return the {@link PGPPublicKey} * @throws InvalidKeyException if the key is wrong */ public static PGPPublicKey getPGPPublicKey(byte[] data) throws InvalidKeyException { var pgpObjectFactory = new PGPObjectFactory(data, new JcaKeyFingerprintCalculator()); try { var object = pgpObjectFactory.nextObject(); if (object instanceof PGPPublicKeyRing pgpPublicKeyRing) { if (!pgpPublicKeyRing.iterator().hasNext()) { throw new InvalidKeyException("PGPPublicKeyRing is empty"); } return pgpPublicKeyRing.iterator().next(); } else { throw new InvalidKeyException("PGPPublicKeyRing expected, got: " + object.getClass().getCanonicalName() + " instead"); } } catch (IOException e) { throw new InvalidKeyException("PGPPublicKeyRing is corrupted", e); } } /** * Generates a PGP secret key. *

* The key is a PGP V4 format, RSA key with a default certification, * SHA-256 integrity checksum and encrypted with AES-128. The packet sizes are encoded using the original format. *

* This was changed from the previous key format that used SHA-1 because RNP which will be used by the next Retroshare uses SHA-256. The previous version also used CAST5 as encryption. * * @param id the id of the key * @param suffix the suffix appended to the id * @param size the size of the key * @return the {@link PGPSecretKey} * @throws PGPException if somehow the PGP key generation failed (for example, wrong key size) */ public static PGPSecretKey generateSecretKey(String id, String suffix, int size) throws PGPException { KeyPair keyPair; if (EXPERIMENTAL_EC) { keyPair = io.xeres.app.crypto.ec.Ed25519.generateKeys(size); } else { keyPair = RSA.generateKeys(size); } PGPKeyPair pgpKeyPair = new JcaPGPKeyPair(EXPERIMENTAL_EC ? PublicKeyPacket.VERSION_6 : PublicKeyPacket.VERSION_4, EXPERIMENTAL_EC ? Ed25519 : RSA_GENERAL, keyPair, new Date()); return encryptKeyPair(pgpKeyPair, suffix != null ? (id + " " + suffix) : id); } public static PGPSecretKey encryptKeyPair(PGPKeyPair pgpKeyPair, String id) throws PGPException { var shaCalc = new JcaPGPDigestCalculatorProviderBuilder().build().get(SHA1); var signer = new JcaPGPContentSignerBuilder(pgpKeyPair.getPublicKey().getAlgorithm(), SHA256); var encryptor = new JcePBESecretKeyEncryptorBuilder(AES_128, shaCalc).setSecureRandom(SecureRandomUtils.getGenerator()).build("".toCharArray()); return new PGPSecretKey(pgpKeyPair.getPrivateKey(), certifiedPublicKey(pgpKeyPair, id, signer), shaCalc, true, encryptor); } private static PGPPublicKey certifiedPublicKey(PGPKeyPair keyPair, String id, PGPContentSignerBuilder certificationSignerBuilder) throws PGPException { var signatureGenerator = new PGPSignatureGenerator(certificationSignerBuilder, keyPair.getPublicKey(), EXPERIMENTAL_EC ? SignaturePacket.VERSION_6 : SignaturePacket.VERSION_4); signatureGenerator.init(DEFAULT_CERTIFICATION, keyPair.getPrivateKey()); signatureGenerator.setHashedSubpackets(null); signatureGenerator.setUnhashedSubpackets(null); var certification = signatureGenerator.generateCertification(id, keyPair.getPublicKey()); return PGPPublicKey.addCertification(keyPair.getPublicKey(), id, certification); } /** * Signs a message as a binary document using SHA-256. * * @param pgpSecretKey the secret key to sign the message with * @param in the message * @param out the resulting PGP signature * @param armor optional ASCII armoring (base 64 encoding) * @throws PGPException if there's a PGP error * @throws IOException if there's an I/O error */ public static void sign(PGPSecretKey pgpSecretKey, InputStream in, OutputStream out, Armor armor) throws PGPException, IOException { if (armor == Armor.BASE64) { out = new ArmoredOutputStream(out); } var pgpPrivateKey = pgpSecretKey.extractPrivateKey(new JcePBESecretKeyDecryptorBuilder() .build("".toCharArray())); var signatureGenerator = new PGPSignatureGenerator(new JcaPGPContentSignerBuilder(pgpSecretKey.getPublicKey().getAlgorithm(), SHA256), pgpSecretKey.getPublicKey(), EXPERIMENTAL_EC ? SignaturePacket.VERSION_6 : SignaturePacket.VERSION_4); signatureGenerator.init(BINARY_DOCUMENT, pgpPrivateKey); var bOut = new BCPGOutputStream(out); signatureGenerator.update(in.readAllBytes()); in.close(); signatureGenerator.generate().encode(bOut); if (armor == Armor.BASE64) { out.close(); } } /** * Verifies a PGP signature. *

* Note that only a handful of algorithms are supported. * * @param pgpPublicKey the public key corresponding to the private key used to generate the signature * @param signature the signature * @param in the message * @throws SignatureException if the message verification failed * @throws IOException if there's an I/O error * @throws PGPException if there's a PGP error */ public static void verify(PGPPublicKey pgpPublicKey, byte[] signature, InputStream in) throws IOException, SignatureException, PGPException { var pgpSignature = getSignature(signature); pgpSignature.init(new JcaPGPContentVerifierBuilderProvider(), pgpPublicKey); pgpSignature.update(in.readAllBytes()); in.close(); if (!pgpSignature.verify()) { throw new SignatureException("Wrong signature"); } } public static long getIssuer(byte[] signature) { try { var pgpSignature = getSignature(signature); return pgpSignature.getKeyID(); } catch (SignatureException | IOException _) { return 0L; } } /** * Gets the public key used for signing releases. * * @return the signing key * @throws IOException if I/O error * @throws PGPException if the key is somehow wrong */ public static PGPPublicKey getUpdateSigningKey() throws IOException, PGPException { InputStream in = Objects.requireNonNull(PGP.class.getResourceAsStream("/public.asc")); JcaPGPPublicKeyRingCollection publicKeyRingCollection; in = PGPUtil.getDecoderStream(in); publicKeyRingCollection = new JcaPGPPublicKeyRingCollection(in); in.close(); PGPPublicKey publicKey = null; Iterator keyRings = publicKeyRingCollection.getKeyRings(); while (publicKey == null && keyRings.hasNext()) { PGPPublicKeyRing keyRing = keyRings.next(); Iterator publicKeys = keyRing.getPublicKeys(); while (publicKey == null && publicKeys.hasNext()) { PGPPublicKey k = publicKeys.next(); if (k.isEncryptionKey()) { publicKey = k; } } } if (publicKey == null) { throw new IllegalStateException("Release signing public key not found"); } return publicKey; } private static PGPSignature getSignature(byte[] signature) throws SignatureException, IOException { var pgpObjectFactory = new PGPObjectFactory(signature, new JcaKeyFingerprintCalculator()); var object = pgpObjectFactory.nextObject(); if (!(object instanceof PGPSignatureList pgpSignatures)) { throw new SignatureException("Signature doesn't contain a PGP signature list"); } if (pgpSignatures.isEmpty()) { throw new SignatureException("Signature list empty"); } var pgpSignature = pgpSignatures.get(0); if (pgpSignature.getSignatureType() != BINARY_DOCUMENT) { throw new SignatureException("Signature is not of BINARY_DOCUMENT (" + pgpSignature.getSignatureType() + ")"); } if (pgpSignature.getVersion() != 4 && pgpSignature.getVersion() != 6) { throw new SignatureException("Signature is not PGP version 4 or 6 (" + pgpSignature.getVersion() + ")"); } if (!List.of(RSA_GENERAL, 3 /* RSA_SIGN */, DSA, Ed25519).contains(pgpSignature.getKeyAlgorithm())) { throw new SignatureException("Signature key algorithm is not of RSA, DSA or Ed25519 (" + pgpSignature.getSignatureType() + ")"); } if (!List.of(SHA1, SHA256, SHA384, SHA512).contains(pgpSignature.getHashAlgorithm())) { throw new SignatureException("Signature hash algorithm is not of SHA family (" + pgpSignature.getHashAlgorithm() + ")"); } return pgpSignature; } /** * Gets the PGP identifier, which is the last long of the PGP fingerprint * * @return the PGP identifier */ public static long getPGPIdentifierFromFingerprint(byte[] fingerprint) { var buf = ByteBuffer.allocate(Long.BYTES); if (fingerprint.length == 20) { buf.put(fingerprint, 12, 8); } else { buf.put(fingerprint, 0, 8); } buf.flip(); return buf.getLong(); } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/pgp/PGPSigner.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.pgp; import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; import org.bouncycastle.operator.ContentSigner; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import static io.xeres.app.crypto.pgp.PGP.Armor; import static io.xeres.app.crypto.pgp.PGP.sign; public class PGPSigner implements ContentSigner { private final ByteArrayOutputStream outputStream; private final PGPSecretKey pgpSecretKey; public PGPSigner(PGPSecretKey pgpSecretKey) { this.pgpSecretKey = pgpSecretKey; outputStream = new ByteArrayOutputStream(); } @Override public AlgorithmIdentifier getAlgorithmIdentifier() { return new AlgorithmIdentifier(PKCSObjectIdentifiers.sha256WithRSAEncryption); } @Override public OutputStream getOutputStream() { return outputStream; } @Override public byte[] getSignature() { try (var out = new ByteArrayOutputStream()) { sign(pgpSecretKey, new ByteArrayInputStream(outputStream.toByteArray()), out, Armor.NONE); outputStream.close(); return out.toByteArray(); } catch (PGPException | IOException e) { throw new IllegalStateException("Failed to sign certificate: " + e.getMessage(), e.getCause()); } } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/pgp/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * PGP related functions. Used for creating the private and public PGP keys * which identify one profile, also known as a user. Locations' certificates are then signed using * the private key.

* The public key is distributed to other profiles so that they can verify the location's certificate * signature. * * @see RFC 4880 */ package io.xeres.app.crypto.pgp; ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsa/RSA.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsa; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.common.annotation.RsDeprecated; import io.xeres.common.id.GxsId; import org.bouncycastle.asn1.ASN1InputStream; import org.bouncycastle.asn1.DERNull; import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.util.BigIntegers; import java.io.IOException; import java.security.*; import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Arrays; import java.util.Objects; /** * Implements all RSA related functions. Used for creating the private and public SSL keys * which identify one location, also known as a machine or node. */ public final class RSA { private static final String KEY_ALGORITHM = "RSA"; private static final String SIGNATURE_ALGORITHM = "SHA1withRSA"; // SHA1 is needed for Retroshare compatibility private RSA() { throw new UnsupportedOperationException("Utility class"); } /** * Generates an RSA private/public key pair. * * @param size the key size (512, 1024, 2048, 3072, 4096, etc...) * @return the key pair */ public static KeyPair generateKeys(int size) { try { var keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM); keyPairGenerator.initialize(size); return keyPairGenerator.generateKeyPair(); } catch (NoSuchAlgorithmException _) { throw new IllegalArgumentException("Algorithm not supported"); } } /** * Gets the RSA public key from the encoded form. * * @param data the public key in encoded bytes * @return the public key * @throws NoSuchAlgorithmException if the RSA algorithm is unavailable * @throws InvalidKeySpecException if it's not an RSA key */ public static PublicKey getPublicKey(byte[] data) throws NoSuchAlgorithmException, InvalidKeySpecException { Objects.requireNonNull(data); return KeyFactory.getInstance(KEY_ALGORITHM).generatePublic(new X509EncodedKeySpec(data)); } /** * Gets the RSA private key from the encoded form. * * @param data the private key in encoded bytes * @return the private key * @throws NoSuchAlgorithmException if the RSA algorithm is unavailable * @throws InvalidKeySpecException if it's not an RSA key */ public static PrivateKey getPrivateKey(byte[] data) throws NoSuchAlgorithmException, InvalidKeySpecException { Objects.requireNonNull(data); return KeyFactory.getInstance(KEY_ALGORITHM).generatePrivate(new PKCS8EncodedKeySpec(data)); } /** * Signs some data. * * @param privateKey the RSA private key * @param data the data to sign * @return the signature */ public static byte[] sign(PrivateKey privateKey, byte[] data) { Objects.requireNonNull(privateKey); Objects.requireNonNull(data); try { var signer = Signature.getInstance(SIGNATURE_ALGORITHM); signer.initSign(privateKey); signer.update(data); return signer.sign(); } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { throw new IllegalArgumentException(e); } } /** * Verifies signed data. * * @param publicKey the RSA public key * @param signature the signature * @param data the data to verify * @return true if verification is successful */ public static boolean verify(PublicKey publicKey, byte[] signature, byte[] data) { Objects.requireNonNull(publicKey); Objects.requireNonNull(signature); Objects.requireNonNull(data); try { var signer = Signature.getInstance(SIGNATURE_ALGORITHM); signer.initVerify(publicKey); signer.update(data); return signer.verify(signature); } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException _) { return false; } } /** * Converts an RSA private key from PKCS #8 to PKCS #1 * * @param privateKey the RSA private key * @return the RSA private key in PKCS #8 format * @throws IOException if the key format is wrong */ public static byte[] getPrivateKeyAsPkcs1(PrivateKey privateKey) throws IOException { Objects.requireNonNull(privateKey); var privateKeyInfo = PrivateKeyInfo.getInstance(privateKey.getEncoded()); var encodable = privateKeyInfo.parsePrivateKey(); var primitive = encodable.toASN1Primitive(); return primitive.getEncoded(); } /** * Converts a PKCS #1 byte array to an RSA private key * * @param data the DER encoded PKCS #1 byte array * @return an RSA private key * @throws IOException if the key format is wrong * @throws NoSuchAlgorithmException if the key format is wrong * @throws InvalidKeySpecException if the encoding is wrong */ public static PrivateKey getPrivateKeyFromPkcs1(byte[] data) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { Objects.requireNonNull(data); try (var asn1InputStream = new ASN1InputStream(data)) { var asn1Primitive = asn1InputStream.readObject(); var algorithmIdentifier = new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption, DERNull.INSTANCE); var privateKeyInfo = new PrivateKeyInfo(algorithmIdentifier, asn1Primitive); return getPrivateKey(privateKeyInfo.getEncoded()); } } /** * Converts an RSA public key from X.509 to PKCS #1 * * @param publicKey the RSA public key * @return the RSA public key in PKCS #1 format * @throws IOException if the key format is wrong */ public static byte[] getPublicKeyAsPkcs1(PublicKey publicKey) throws IOException { Objects.requireNonNull(publicKey); var subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded()); var primitive = subjectPublicKeyInfo.parsePublicKey(); return primitive.getEncoded(); } /** * Converts a PKCS #1 byte array to an RSA public key. * * @param data the DER encoded PKCS #1 byte array * @return an RSA public key * @throws IOException if the key format is wrong * @throws NoSuchAlgorithmException if the key format is wrong * @throws InvalidKeySpecException if the encoding is wrong */ public static PublicKey getPublicKeyFromPkcs1(byte[] data) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { Objects.requireNonNull(data); var algorithmIdentifier = new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption, DERNull.INSTANCE); var subjectPublicKeyInfo = new SubjectPublicKeyInfo(algorithmIdentifier, data); return getPublicKey(subjectPublicKeyInfo.getEncoded()); } /** * Computes the GxsId from the key. This is done by sha1 hashing the n and e numbers * and getting the first 16 bytes from it. * * @param publicKey the RSA public key * @return the GxsId */ public static GxsId getGxsId(PublicKey publicKey) { Objects.requireNonNull(publicKey); var rsaPublicKey = (RSAPublicKey) publicKey; return makeGxsId( BigIntegers.asUnsignedByteArray(rsaPublicKey.getModulus()), BigIntegers.asUnsignedByteArray(rsaPublicKey.getPublicExponent()) ); } private static GxsId makeGxsId(byte[] modulus, byte[] exponent) { var md = new Sha1MessageDigest(); md.update(modulus); md.update(exponent); // Copy the first 16 bytes of the sha1 sum to get the GxsId return new GxsId(Arrays.copyOfRange(md.getBytes(), 0, GxsId.LENGTH)); } /** * Computes the GxsId from the key. *

* Note: For compatibility with entities generated by old Retroshare versions. Is less secure. Do not use for new code. * * @param publicKey the RSA public key * @return the GxsId */ @RsDeprecated public static GxsId getGxsIdInsecure(PublicKey publicKey) { Objects.requireNonNull(publicKey); var rsaPublicKey = (RSAPublicKey) publicKey; return makeGxsIdInsecure(BigIntegers.asUnsignedByteArray(rsaPublicKey.getModulus())); } private static GxsId makeGxsIdInsecure(byte[] modulus) { return new GxsId(Arrays.copyOfRange(modulus, 0, GxsId.LENGTH)); } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rscrypto/RsCrypto.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rscrypto; import io.xeres.app.crypto.aead.AEAD; import io.xeres.common.util.SecureRandomUtils; import javax.crypto.SecretKey; import java.nio.ByteBuffer; /** * This class implements the custom RS encryption, notably to encrypt file transfer tunnels. *

* Format diagram */ public final class RsCrypto { public enum EncryptionFormat { CHACHA20_POLY1305(1), CHACHA20_SHA256(2); private final int value; EncryptionFormat(int value) { this.value = value; } public int getValue() { return value; } } private static final int INITIALIZATION_VECTOR_SIZE = 12; private static final int AUTHENTICATION_TAG_SIZE = 16; private static final int HEADER_SIZE = 4; private static final int EDATA_SIZE = 4; private RsCrypto() { throw new UnsupportedOperationException("Utility class"); } public static byte[] encryptAuthenticateData(SecretKey key, byte[] plainText, EncryptionFormat format) { // Initialization vector (AAD) var initializationVector = new byte[INITIALIZATION_VECTOR_SIZE]; SecureRandomUtils.nextBytes(initializationVector); var aad = new byte[INITIALIZATION_VECTOR_SIZE + EDATA_SIZE]; System.arraycopy(initializationVector, 0, aad, 0, INITIALIZATION_VECTOR_SIZE); aad[INITIALIZATION_VECTOR_SIZE] = (byte) ((plainText.length) & 0xff); aad[INITIALIZATION_VECTOR_SIZE + 1] = (byte) ((plainText.length >> 8) & 0xff); aad[INITIALIZATION_VECTOR_SIZE + 2] = (byte) ((plainText.length >> 16) & 0xff); aad[INITIALIZATION_VECTOR_SIZE + 3] = (byte) ((plainText.length >> 24) & 0xff); var totalSize = HEADER_SIZE + INITIALIZATION_VECTOR_SIZE + EDATA_SIZE + plainText.length + AUTHENTICATION_TAG_SIZE; var encryptedData = new byte[totalSize]; var offset = 0; // Header encryptedData[0] = (byte) 0xae; encryptedData[1] = (byte) 0xad; encryptedData[2] = (byte) format.getValue(); encryptedData[3] = (byte) 0x1; offset += HEADER_SIZE; // Copy AAD data (initialization vector + length) System.arraycopy(aad, 0, encryptedData, offset, aad.length); offset += aad.length; byte[] cipherText; if (encryptedData[2] == EncryptionFormat.CHACHA20_POLY1305.getValue()) { cipherText = AEAD.encryptChaCha20Poly1305(key, initializationVector, plainText, aad); } else if (encryptedData[2] == EncryptionFormat.CHACHA20_SHA256.getValue()) { cipherText = AEAD.encryptChaCha20Sha256(key, initializationVector, plainText, aad); } else { throw new IllegalArgumentException("Unsupported encrypted data type: " + encryptedData[2]); } System.arraycopy(cipherText, 0, encryptedData, offset, cipherText.length); return encryptedData; } public static byte[] decryptAuthenticateData(SecretKey key, byte[] cipherText) { if (cipherText.length < HEADER_SIZE + INITIALIZATION_VECTOR_SIZE + EDATA_SIZE) { throw new IllegalArgumentException("Ciphertext is too short"); } var buf = ByteBuffer.wrap(cipherText); var magic1 = buf.get(); var magic2 = buf.get(); var format = buf.get(); var magic3 = buf.get(); if (magic1 != (byte) 0xae && magic2 != (byte) 0xad && magic3 != (byte) 0x1) { throw new IllegalArgumentException("Invalid ciphertext header"); } if (format != EncryptionFormat.CHACHA20_POLY1305.getValue() && format != EncryptionFormat.CHACHA20_SHA256.getValue()) { throw new IllegalArgumentException("Unsupported encrypted data type: " + cipherText[2]); } var initializationVector = new byte[INITIALIZATION_VECTOR_SIZE]; buf.get(initializationVector); var aad = new byte[INITIALIZATION_VECTOR_SIZE + EDATA_SIZE]; var eDataArray = new byte[EDATA_SIZE]; buf.get(eDataArray); System.arraycopy(initializationVector, 0, aad, 0, INITIALIZATION_VECTOR_SIZE); System.arraycopy(eDataArray, 0, aad, INITIALIZATION_VECTOR_SIZE, EDATA_SIZE); var eDataSize = Byte.toUnsignedInt(eDataArray[0]); eDataSize += Byte.toUnsignedInt(eDataArray[1]) << 8; eDataSize += Byte.toUnsignedInt(eDataArray[2]) << 16; eDataSize += Byte.toUnsignedInt(eDataArray[3]) << 24; var expectedSize = eDataSize + HEADER_SIZE + INITIALIZATION_VECTOR_SIZE + EDATA_SIZE + AUTHENTICATION_TAG_SIZE; if (expectedSize != cipherText.length) { throw new IllegalArgumentException("Encrypted data size is wrong, expected: " + expectedSize + ", got: " + cipherText.length); } byte[] decryptedText; var encryptedText = new byte[eDataSize + AUTHENTICATION_TAG_SIZE]; buf.get(encryptedText); if (format == EncryptionFormat.CHACHA20_POLY1305.getValue()) { decryptedText = AEAD.decryptChaCha20Poly1305(key, initializationVector, encryptedText, aad); } else { decryptedText = AEAD.decryptChaCha20Sha256(key, initializationVector, encryptedText, aad); } var decryptedData = new byte[eDataSize]; System.arraycopy(decryptedText, 0, decryptedData, 0, eDataSize); return decryptedData; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rscrypto/doc-files/format.puml ================================================ @startuml map "Packet" as packet { Header => 4 bytes Initialization vector => 12 bytes Encrypted data size => 4 bytes Encrypted data => variable Authentication tag => 16 bytes } map "Header" as header { ChaCha20 Poly1305 => ae ad 01 01 ChaCha20 HMAC-SHA256 => ae ad 02 01 } @enduml ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsid/RSCertificate.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.net.protocol.DomainNameSocketAddress; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.common.dto.profile.ProfileConstants; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import org.apache.commons.lang3.StringUtils; import org.bouncycastle.openpgp.PGPPublicKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.cert.CertificateParsingException; import java.util.Base64; import java.util.HashSet; import java.util.Optional; import java.util.Set; import static io.xeres.app.crypto.rsid.RSIdArmor.*; class RSCertificate extends RSId { private static final Logger log = LoggerFactory.getLogger(RSCertificate.class); public static final int VERSION_06 = 6; static final int PGP_KEY = 1; static final int EXTERNAL_IP_AND_PORT = 2; static final int INTERNAL_IP_AND_PORT = 3; static final int DNS = 4; static final int SSL_ID = 5; static final int NAME = 6; static final int CHECKSUM = 7; static final int HIDDEN_NODE = 8; static final int VERSION = 9; static final int EXTRA_LOCATOR = 10; private PGPPublicKey pgpPublicKey; private ProfileFingerprint pgpFingerprint; private String name; private LocationIdentifier locationIdentifier; private PeerAddress hiddenNodeAddress; private PeerAddress internalIp; private PeerAddress externalIp; private PeerAddress dnsName; private final Set locators = new HashSet<>(); RSCertificate() { } @SuppressWarnings("DuplicatedCode") @Override void parseInternal(String data) throws CertificateParsingException { try { var certBytes = Base64.getDecoder().decode(cleanupInput(data.getBytes())); var checksum = RSIdCrc.calculate24bitsCrc(certBytes, certBytes.length - 5); // ignore the checksum PTAG which is 5 bytes in total and at the end var in = new ByteArrayInputStream(certBytes); var version = 0; Boolean checksumPassed = null; while (in.available() > 0) { var pTag = in.read(); var size = getPacketSize(in); if (size == 0) { continue; // seen in the wild, just skip them } var buf = new byte[size]; if (in.readNBytes(buf, 0, size) != size) { throw new IllegalArgumentException("Packet " + pTag + " is shorter than its advertised size"); } switch (pTag) { case VERSION -> version = buf[0]; case PGP_KEY -> setPgpPublicKey(buf); case NAME -> setLocationName(buf); case SSL_ID -> setLocationIdentifier(new LocationIdentifier(buf)); case DNS -> setDnsName(buf); case HIDDEN_NODE -> setHiddenNodeAddress(buf); case INTERNAL_IP_AND_PORT -> setInternalIp(buf); case EXTERNAL_IP_AND_PORT -> setExternalIp(buf); case CHECKSUM -> { if (buf.length != 3) { throw new IllegalArgumentException("Checksum corrupted"); } checksumPassed = checksum == (Byte.toUnsignedInt(buf[2]) << 16 | Byte.toUnsignedInt(buf[1]) << 8 | Byte.toUnsignedInt(buf[0])); // little endian } case EXTRA_LOCATOR -> { // XXX: insert the URLs (I probably need a RsUrl object... } default -> log.trace("Unhandled tag {}, ignoring.", pTag); } } if (version == 0) { throw new IllegalArgumentException("Missing certificate version"); } else if (version != RSCertificate.VERSION_06) { throw new IllegalArgumentException("Wrong certificate version: " + version); } if (checksumPassed == null) { throw new IllegalArgumentException("Missing checksum packet"); } else if (!checksumPassed) { throw new IllegalArgumentException("Wrong checksum"); } } catch (IllegalArgumentException | IOException e) { throw new CertificateParsingException("Parse error: " + e.getMessage(), e); } } @Override void checkRequiredFields() { if (getLocationIdentifier() == null) { throw new IllegalArgumentException("Missing location identifier"); } if (StringUtils.isBlank(getName())) { throw new IllegalArgumentException("Missing or wrong name"); } if (getPgpPublicKey().isEmpty()) { throw new IllegalArgumentException("Missing PGP public key"); } addPortToDnsName(); } private void addPortToDnsName() { if (dnsName != null && dnsName.isValid() && dnsName.getSocketAddress() instanceof DomainNameSocketAddress) { // Find another address for a port, then add it if (externalIp != null && externalIp.isValid()) { dnsName = PeerAddress.fromHostname(dnsName.getAddress().orElseThrow(), ((InetSocketAddress) externalIp.getSocketAddress()).getPort()); } else { dnsName = PeerAddress.fromInvalid(); } } } void setPgpPublicKey(byte[] data) throws CertificateParsingException { try { setPgpPublicKey(PGP.getPGPPublicKey(data)); } catch (InvalidKeyException e) { throw new CertificateParsingException("Error in RSCertificate PGP public key: " + e.getMessage(), e); } } /** * Same as setPgpPublicKey() but from a valid PGP key data. * This is done to avoid catching the exception. * * @param data the data */ void setVerifiedPgpPublicKey(byte[] data) { try { setPgpPublicKey(PGP.getPGPPublicKey(data)); } catch (InvalidKeyException e) { throw new RuntimeException(e); } } void setPgpPublicKey(PGPPublicKey pgpPublicKey) { this.pgpPublicKey = pgpPublicKey; pgpFingerprint = new ProfileFingerprint(pgpPublicKey.getFingerprint()); } private void setInternalIp(byte[] data) { internalIp = PeerAddress.fromByteArray(data); } void setInternalIp(String ipAndPort) { internalIp = PeerAddress.fromIpAndPort(ipAndPort); } private void setExternalIp(byte[] data) { externalIp = PeerAddress.fromByteArray(data); } void setExternalIp(String ipAndPort) { externalIp = PeerAddress.fromIpAndPort(ipAndPort); } private void setLocationName(byte[] name) throws CertificateParsingException { if (name.length > 255) // RS has no limit but let's enforce a sensible value { throw new CertificateParsingException("Certificate name too long: " + name.length); } this.name = new String(name, StandardCharsets.UTF_8); } void setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; } private void setHiddenNodeAddress(byte[] hiddenNodeAddress) { if (hiddenNodeAddress != null && hiddenNodeAddress.length >= 11 && hiddenNodeAddress.length <= 255) { setHiddenNodeAddress(new String(hiddenNodeAddress, StandardCharsets.US_ASCII)); } else { this.hiddenNodeAddress = PeerAddress.fromInvalid(); } } private void setHiddenNodeAddress(String hiddenNodeAddress) { this.hiddenNodeAddress = PeerAddress.fromHidden(hiddenNodeAddress); } @Override public Optional getInternalIp() { if (internalIp != null && internalIp.isValid()) { return Optional.of(internalIp); } return Optional.empty(); } @Override public Optional getExternalIp() { if (externalIp != null && externalIp.isValid()) { return Optional.of(externalIp); } return Optional.empty(); } @Override public ProfileFingerprint getPgpFingerprint() { return pgpFingerprint; } @Override public Optional getPgpPublicKey() { return Optional.ofNullable(pgpPublicKey); } @Override public String getName() { return name; } void setName(byte[] name) { this.name = StringUtils.substring(new String(name, StandardCharsets.UTF_8), 0, ProfileConstants.NAME_LENGTH_MAX); } @Override public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } @Override public Optional getDnsName() { if (dnsName != null && dnsName.isValid()) { return Optional.of(dnsName); } return Optional.empty(); } private void setDnsName(byte[] dnsName) { setDnsName(new String(dnsName, StandardCharsets.US_ASCII)); } void setDnsName(String dnsName) { this.dnsName = PeerAddress.fromHostname(dnsName); } @Override public Optional getHiddenNodeAddress() { if (hiddenNodeAddress != null && hiddenNodeAddress.isValid()) { return Optional.of(hiddenNodeAddress); } return Optional.empty(); } void addLocator(String locator) { var peerAddress = PeerAddress.fromUrl(locator); if (peerAddress.isValid()) { locators.add(peerAddress); } } @Override public Set getLocators() { return locators; } @Override public String getArmored() { var out = new ByteArrayOutputStream(); addPacket(VERSION, new byte[]{RSCertificate.VERSION_06}, out); addPacket(PGP_KEY, getPgpPublicKeyData(pgpPublicKey), out); if (getHiddenNodeAddress().isPresent()) { addPacket(HIDDEN_NODE, getHiddenNodeAddress().get().getAddressAsBytes().orElseThrow(), out); } else { getExternalIp().ifPresent(peerAddress -> addPacket(EXTERNAL_IP_AND_PORT, peerAddress.getAddressAsBytes().orElseThrow(), out)); getInternalIp().ifPresent(peerAddress -> addPacket(INTERNAL_IP_AND_PORT, peerAddress.getAddressAsBytes().orElseThrow(), out)); getDnsName().ifPresent(peerAddress -> addPacket(DNS, peerAddress.getAddressAsBytes().orElseThrow(), out)); } addPacket(NAME, getName().getBytes(), out); addPacket(SSL_ID, getLocationIdentifier().getBytes(), out); getLocators().forEach(peerAddress -> addPacket(EXTRA_LOCATOR, peerAddress.getAddressAsBytes().orElseThrow(), out)); addCrcPacket(CHECKSUM, out); return wrapWithBase64(out.toByteArray(), RSIdArmor.WrapMode.SLICED); } private static byte[] getPgpPublicKeyData(PGPPublicKey pgpPublicKey) { try { return pgpPublicKey.getEncoded(); } catch (IOException e) { throw new RuntimeException(e); } } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsid/RSId.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import io.xeres.common.rsid.Type; import org.apache.commons.lang3.StringUtils; import org.bouncycastle.openpgp.PGPPublicKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.InvocationTargetException; import java.security.cert.CertificateParsingException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.Set; import static io.xeres.common.rsid.Type.*; /** * This abstract class represents an RS ID, which is a string that allows to exchange a profile identity * with another user. */ public abstract class RSId { private static final Logger log = LoggerFactory.getLogger(RSId.class); private static final Map, Type> engines = LinkedHashMap.newLinkedHashMap(2); static { engines.put(ShortInvite.class, SHORT_INVITE); engines.put(RSCertificate.class, CERTIFICATE); } /** * Parses an ID. * * @param data the ID encoded in a string * @param type restrict the type to parse or use ANY * @return an RSId */ public static Optional parse(String data, Type type) { if (StringUtils.isBlank(data)) { return Optional.empty(); } String error = null; for (var entry : engines.entrySet()) { var engineClass = entry.getKey(); var engineType = entry.getValue(); if (type != ANY && type != engineType) { continue; } try { var rsId = engineClass.getDeclaredConstructor().newInstance(); rsId.parseInternal(data); rsId.checkRequiredFieldsAndThrow(); return Optional.of(rsId); } catch (NoSuchMethodException _) { throw new IllegalArgumentException(engineClass.getSimpleName() + " requires an empty constructor"); } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) { throw new RuntimeException(e); } catch (CertificateParsingException e) { // Parsing failed, try the next one error = e.getMessage(); } } log.debug("RSId parsing error: {}", error); return Optional.empty(); } abstract void parseInternal(String data) throws CertificateParsingException; abstract void checkRequiredFields(); /** * Gets the internal IP (IP used on the LAN). * * @return the internal IP (for example 192.168.1.10) */ public abstract Optional getInternalIp(); /** * Gets the external IP (IP used on the Internet). * * @return the external IP (for example 85.12.43.18) */ public abstract Optional getExternalIp(); /** * Gets the PGP fingerprint. Should always be available. * * @return the PGP fingerprint */ public abstract ProfileFingerprint getPgpFingerprint(); /** * Gets the PGP public key (optional). * * @return the PGP public key */ public abstract Optional getPgpPublicKey(); /** * Gets the profile name (usually the name or nickname of the user). * * @return the profile name */ public abstract String getName(); /** * Gets the location identifier (node identifier). * * @return the location identifier */ public abstract LocationIdentifier getLocationIdentifier(); /** * Gets the DNS name. * * @return the DNS name */ public abstract Optional getDnsName(); /** * Gets the hidden node address, if this is a hidden node. * * @return the hidden node address */ public abstract Optional getHiddenNodeAddress(); /** * Gets a set of addresses where the node is available. * * @return a set of addresses */ public abstract Set getLocators(); /** * Gets an armored version of the certificate or short invite. It's encoded using base64 and can be * used in emails, forums, etc... * * @return an ASCII armored version of it */ public abstract String getArmored(); /** * Gets the PGP identifier, which is the last long of the PGP fingerprint * * @return the PGP identifier */ public Long getPgpIdentifier() { if (getPgpFingerprint() == null) { return null; } return PGP.getPGPIdentifierFromFingerprint(getPgpFingerprint().getBytes()); } protected static byte[] cleanupInput(byte[] data) { try (var out = new ByteArrayOutputStream()) { for (var b : data) { if (b == ' ' || b == '\n' || b == '\t' || b == '\r') { continue; } out.write(b); } return out.toByteArray(); } catch (IOException e) { throw new IllegalStateException(e); } } protected static int getPacketSize(InputStream in) throws IOException { var octet1 = in.read(); if (octet1 < 192) // size is coded in one byte { return octet1; } else if (octet1 < 224) // size is coded in 2 bytes { var octet2 = in.read(); return ((octet1 - 192) << 8) + octet2 + 192; } else { throw new IllegalArgumentException("Unsupported packet data size"); } } private void checkRequiredFieldsAndThrow() throws CertificateParsingException { try { checkRequiredFields(); } catch (IllegalArgumentException e) { throw new CertificateParsingException("Required field error: " + e.getMessage(), e); } } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsid/RSIdArmor.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Base64; final class RSIdArmor { private static final Logger log = LoggerFactory.getLogger(RSIdArmor.class); enum WrapMode { CONTINUOUS, SLICED } private RSIdArmor() { throw new UnsupportedOperationException("Utility class"); } static void addPacket(int pTag, byte[] data, ByteArrayOutputStream out) { if (data != null) { // This is like PGP packets, see https://tools.ietf.org/html/rfc4880 out.write(pTag); if (data.length < 192) // size is coded in one byte { // one byte out.write(data.length); } else if (data.length < 8384) // size is coded in 2 bytes { var octet2 = (data.length - 192) & 0xff; out.write(((data.length - 192 - octet2) >> 8) + 192); out.write(octet2); } else { // We don't support more as it makes little sense to have an oversized certificate throw new IllegalArgumentException("Packet data size too big: " + data.length); } out.writeBytes(data); } else { log.warn("Trying to write certificate tag {} with empty data. Skipping...", pTag); } } static void addCrcPacket(int pTag, ByteArrayOutputStream out) { var data = out.toByteArray(); var crc = RSIdCrc.calculate24bitsCrc(data, data.length); // Perform byte swapping var le = new byte[3]; le[0] = (byte) (crc & 0xff); le[1] = (byte) ((crc >> 8) & 0xff); le[2] = (byte) ((crc >> 16) & 0xff); addPacket(pTag, le, out); } static String wrapWithBase64(byte[] data, WrapMode wrapMode) { var base64 = Base64.getEncoder().encode(data); try (var out = new ByteArrayOutputStream()) { for (var i = 0; i < base64.length; i++) { out.write(base64[i]); if (wrapMode == WrapMode.SLICED && i % 64 == 64 - 1) { out.write('\n'); } } return out.toString(StandardCharsets.US_ASCII); } catch (IOException e) { throw new IllegalStateException(e); } } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsid/RSIdBuilder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.rsid.Type; import java.util.ArrayList; import java.util.List; import java.util.Objects; public class RSIdBuilder { private final Type type; private byte[] name; private LocationIdentifier locationIdentifier; private Profile profile; private byte[] pgpFingerprint; private final List locators = new ArrayList<>(); private String externalLocator; private String lanLocator; private String dnsLocator; public RSIdBuilder(Type type) { this.type = type; } public RSIdBuilder setName(byte[] name) { this.name = name; return this; } public RSIdBuilder setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; return this; } public RSIdBuilder setProfile(Profile profile) { this.profile = profile; return this; } public RSIdBuilder setPgpFingerprint(byte[] pgpFingerprint) { this.pgpFingerprint = pgpFingerprint; return this; } public RSIdBuilder addLocator(Connection connection) { if (externalLocator == null && connection.isExternal()) { externalLocator = connection.getAddress(); } else if (lanLocator == null && !connection.isExternal()) { lanLocator = connection.getAddress(); } else if (dnsLocator == null && connection.getType() == PeerAddress.Type.HOSTNAME) { dnsLocator = connection.getAddress(); } else { locators.add(connection.getType().scheme() + connection.getAddress()); } return this; } public RSId build() { var rsId = switch (type) { case SHORT_INVITE, ANY -> { var si = new ShortInvite(); Objects.requireNonNull(name); Objects.requireNonNull(locationIdentifier); Objects.requireNonNull(pgpFingerprint); si.setName(name); si.setLocationIdentifier(locationIdentifier); si.setPgpFingerprint(pgpFingerprint); if (externalLocator != null) { si.setExt4Locator(externalLocator); } if (lanLocator != null) { si.setLoc4Locator(lanLocator); } if (dnsLocator != null) { si.setDnsName(dnsLocator); } locators.forEach(si::addLocator); yield si; } case CERTIFICATE -> { var cert = new RSCertificate(); Objects.requireNonNull(name); Objects.requireNonNull(locationIdentifier); Objects.requireNonNull(profile); cert.setName(name); cert.setLocationIdentifier(locationIdentifier); cert.setVerifiedPgpPublicKey(profile.getPgpPublicKeyData()); if (externalLocator != null) { cert.setExternalIp(externalLocator); } if (lanLocator != null) { cert.setInternalIp(lanLocator); } if (dnsLocator != null) { cert.setDnsName(dnsLocator); } locators.forEach(cert::addLocator); yield cert; } }; rsId.checkRequiredFields(); return rsId; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsid/RSIdCrc.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; final class RSIdCrc { private RSIdCrc() { throw new UnsupportedOperationException("Utility class"); } static int calculate24bitsCrc(byte[] data, int length) { var crc = 0xb704ce; for (var i = 0; i < length; i++) { crc ^= data[i] << 16; for (var j = 0; j < 8; j++) { crc <<= 1; if ((crc & 0x1000000) != 0) { crc ^= 0x1864cfb; } } } return crc & 0xffffff; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsid/RSSerialVersion.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import java.math.BigInteger; public enum RSSerialVersion { V06_0000("60000", "Retroshare 0.6.4 or earlier"), // RS 0.6.4 and earlier, before November 2017 (note that the version which is in the cert's serial number can be random) V06_0001("60001", "Retroshare 0.6.5"), // RS 0.6.5 after November 2017 V07_0001("70001", "Retroshare 0.6.6"); // RS 0.6.6 private final String versionString; private final String description; RSSerialVersion(String versionString, String description) { this.versionString = versionString; this.description = description; } public BigInteger serialNumber() { return new BigInteger(versionString, 16); } public String versionString() { return versionString; } @Override public String toString() { return description + " (" + versionString + ")"; } public static RSSerialVersion getFromSerialNumber(BigInteger serialNumber) { for (var value : values()) { if (value.serialNumber().equals(serialNumber)) { return value; } } return V06_0000; // old versions used a random serial number } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/rsid/ShortInvite.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.common.dto.profile.ProfileConstants; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import org.apache.commons.lang3.StringUtils; import org.bouncycastle.openpgp.PGPPublicKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.cert.CertificateParsingException; import java.util.*; import static io.xeres.app.crypto.rsid.RSIdArmor.*; class ShortInvite extends RSId { private static final Logger log = LoggerFactory.getLogger(ShortInvite.class); static final int SSL_ID = 0x0; static final int NAME = 0x1; static final int LOCATOR = 0x2; static final int PGP_FINGERPRINT = 0x3; static final int CHECKSUM = 0x4; static final int HIDDEN_LOCATOR = 0x90; static final int DNS_LOCATOR = 0x91; static final int EXT4_LOCATOR = 0x92; static final int LOC4_LOCATOR = 0x93; private String name; private LocationIdentifier locationIdentifier; private ProfileFingerprint pgpFingerprint; private PeerAddress hiddenLocator; private PeerAddress ext4Locator; private PeerAddress loc4Locator; private PeerAddress hostnameLocator; private final Set locators = new HashSet<>(); ShortInvite() { } @SuppressWarnings("DuplicatedCode") @Override void parseInternal(String data) throws CertificateParsingException { try { var shortInviteBytes = Base64.getDecoder().decode(cleanupInput(data.getBytes())); var checksum = RSIdCrc.calculate24bitsCrc(shortInviteBytes, shortInviteBytes.length - 5); // ignore the checksum PTAG which is 5 bytes in total and at the end var in = new ByteArrayInputStream(shortInviteBytes); Boolean checksumPassed = null; while (in.available() > 0) { var pTag = in.read(); var size = getPacketSize(in); if (size == 0) { continue; // not seen in the wild yet but just skip them in any case } var buf = new byte[size]; if (in.readNBytes(buf, 0, size) != size) { throw new IllegalArgumentException("Packet " + pTag + " is shorter than its advertised size"); } switch (pTag) { case PGP_FINGERPRINT -> setPgpFingerprint(buf); case NAME -> setName(buf); case SSL_ID -> setLocationIdentifier(new LocationIdentifier(buf)); case DNS_LOCATOR -> setDnsName(buf); case HIDDEN_LOCATOR -> setHiddenNodeAddress(buf); case EXT4_LOCATOR -> setExt4Locator(buf); case LOC4_LOCATOR -> setLoc4Locator(buf); case LOCATOR -> addLocator(new String(buf)); case CHECKSUM -> { if (buf.length != 3) { throw new IllegalArgumentException("Checksum corrupted"); } checksumPassed = checksum == (Byte.toUnsignedInt(buf[2]) << 16 | Byte.toUnsignedInt(buf[1]) << 8 | Byte.toUnsignedInt(buf[0])); // little endian } default -> log.trace("Unhandled tag {}, ignoring.", pTag); } } if (checksumPassed == null) { throw new IllegalArgumentException("Missing checksum packet"); } else if (!checksumPassed) { throw new IllegalArgumentException("Wrong checksum"); } } catch (IllegalArgumentException | IOException e) { throw new CertificateParsingException("Parse error: " + e.getMessage(), e); } } @Override void checkRequiredFields() { if (getLocationIdentifier() == null) { throw new IllegalArgumentException("Missing location id"); } if (getName() == null) { throw new IllegalArgumentException("Missing name"); } if (getPgpFingerprint() == null) { throw new IllegalArgumentException("Missing PGP fingerprint"); } } void setExt4Locator(byte[] data) { ext4Locator = PeerAddress.fromByteArray(swapBytes(data)); } void setExt4Locator(String ipAndPort) { ext4Locator = PeerAddress.fromIpAndPort(ipAndPort); } void setLoc4Locator(byte[] data) { loc4Locator = PeerAddress.fromByteArray(swapBytes(data)); } void setLoc4Locator(String ipAndPort) { loc4Locator = PeerAddress.fromIpAndPort(ipAndPort); } @Override public Optional getInternalIp() { if (loc4Locator != null && loc4Locator.isValid()) { return Optional.of(loc4Locator); } return Optional.empty(); } @Override public Optional getExternalIp() { if (ext4Locator != null && ext4Locator.isValid()) { return Optional.of(ext4Locator); } return Optional.empty(); } void setPgpFingerprint(byte[] pgpFingerprint) { this.pgpFingerprint = new ProfileFingerprint(pgpFingerprint); } @Override public ProfileFingerprint getPgpFingerprint() { return pgpFingerprint; } @Override public Optional getPgpPublicKey() { return Optional.empty(); } @Override public String getName() { return name; } void setName(byte[] name) { this.name = StringUtils.substring(new String(name, StandardCharsets.UTF_8), 0, ProfileConstants.NAME_LENGTH_MAX); } @Override public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } void setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; } @Override public Optional getDnsName() { return Optional.ofNullable(hostnameLocator); } void setDnsName(String dnsName) { hostnameLocator = PeerAddress.fromHostnameAndPort(dnsName); } private void setDnsName(byte[] portAndDns) { if (portAndDns == null || portAndDns.length <= 3 || portAndDns.length > 255) { throw new IllegalArgumentException("DNS name format is wrong"); } var port = Byte.toUnsignedInt(portAndDns[0]) << 8 | Byte.toUnsignedInt(portAndDns[1]); var hostname = new String(Arrays.copyOfRange(portAndDns, 2, portAndDns.length), StandardCharsets.US_ASCII); hostnameLocator = PeerAddress.fromHostname(hostname, port); } @Override public Optional getHiddenNodeAddress() { if (hiddenLocator != null && hiddenLocator.isValid()) { return Optional.of(hiddenLocator); } return Optional.empty(); } private void setHiddenNodeAddress(String hiddenNodeAddress) { hiddenLocator = PeerAddress.fromHidden(hiddenNodeAddress); } private void setHiddenNodeAddress(byte[] hiddenNodeAddress) { if (hiddenNodeAddress != null && hiddenNodeAddress.length >= 11 && hiddenNodeAddress.length <= 255) { var port = Byte.toUnsignedInt(hiddenNodeAddress[4]) << 8 | Byte.toUnsignedInt(hiddenNodeAddress[5]); setHiddenNodeAddress(new String(Arrays.copyOfRange(hiddenNodeAddress, 6, hiddenNodeAddress.length), StandardCharsets.US_ASCII) + ":" + port); } else { hiddenLocator = PeerAddress.fromInvalid(); } } void addLocator(String locator) { var peerAddress = PeerAddress.fromUrl(locator); if (peerAddress.isValid()) { locators.add(peerAddress); } } @Override public Set getLocators() { return locators; } @Override public String getArmored() { var out = new ByteArrayOutputStream(); addPacket(SSL_ID, getLocationIdentifier().getBytes(), out); addPacket(NAME, getName().getBytes(), out); addPacket(PGP_FINGERPRINT, getPgpFingerprint().getBytes(), out); if (getHiddenNodeAddress().isPresent()) { addPacket(HIDDEN_LOCATOR, getHiddenNodeAddress().get().getAddressAsBytes().orElseThrow(), out); } else { getDnsName().ifPresent(peerAddress -> addPacket(DNS_LOCATOR, swapDnsBytes(peerAddress.getAddressAsBytes().orElseThrow()), out)); getExternalIp().ifPresent(peerAddress -> addPacket(EXT4_LOCATOR, swapBytes(peerAddress.getAddressAsBytes().orElseThrow()), out)); getInternalIp().ifPresent(peerAddress -> addPacket(LOC4_LOCATOR, swapBytes(peerAddress.getAddressAsBytes().orElseThrow()), out)); // Use one locator. Ideally, the first one should be the most recent address getLocators().stream() .findFirst() .ifPresent(peerAddress -> addPacket(LOCATOR, peerAddress.getUrl().getBytes(StandardCharsets.US_ASCII), out)); } addCrcPacket(CHECKSUM, out); return wrapWithBase64(out.toByteArray(), RSIdArmor.WrapMode.CONTINUOUS); } /** * Retroshare puts IP addresses in big-endian in certificates, but when it comes * to short invites, a mistake was made and, while the port is in big-endian, the * IP address is not. Since the mistake is done on output and input, it works fine * within Retroshare so a workaround has to be implemented here. * * @param data the IP address + port * @return the IP address in swapped endian + port left alone */ static byte[] swapBytes(byte[] data) { if (data == null || data.length != 6) { return data; // don't touch anything, input is bad } var bytes = new byte[6]; bytes[0] = data[3]; bytes[1] = data[2]; bytes[2] = data[1]; bytes[3] = data[0]; bytes[4] = data[4]; bytes[5] = data[5]; return bytes; } private static byte[] swapDnsBytes(byte[] data) { if (data == null || data.length < 4) { return data; // don't touch anything, input is bad } var bytes = new byte[data.length]; System.arraycopy(data, 0, bytes, 2, data.length - 2); bytes[0] = data[data.length - 2]; bytes[1] = data[data.length - 1]; return bytes; } } ================================================ FILE: app/src/main/java/io/xeres/app/crypto/x509/X509.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.x509; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.crypto.hash.sha256.Sha256MessageDigest; import io.xeres.app.crypto.pgp.PGPSigner; import io.xeres.app.crypto.rsid.RSSerialVersion; import io.xeres.common.id.LocationIdentifier; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.cert.X509v1CertificateBuilder; import org.bouncycastle.openpgp.PGPSecretKey; import java.io.ByteArrayInputStream; import java.io.IOException; import java.math.BigInteger; import java.security.PublicKey; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Date; import java.util.Optional; /** * Implements all X509 certificate functions. Used to create an SSL certificate for the location. */ public final class X509 { private static final String CERTIFICATE_TYPE = "X.509"; private X509() { throw new UnsupportedOperationException("Utility class"); } /** * Generates a certificate. * * @param pgpSecretKey a PGP secret key * @param rsaPublicKey an RSA public key * @param issuer the issuer * @param subject the subject * @param dateOfIssue the date of certificate validity * @param dateOfExpiry the date of certificate expiration * @param serial the serial number * @return a {@link X509Certificate} * @throws IOException if there's an I/O error * @throws CertificateException if there's a certificate error */ public static X509Certificate generateCertificate(PGPSecretKey pgpSecretKey, PublicKey rsaPublicKey, String issuer, String subject, Date dateOfIssue, Date dateOfExpiry, BigInteger serial) throws IOException, CertificateException { var certificateBuilder = new X509v1CertificateBuilder( new X500Name(issuer), serial, dateOfIssue, dateOfExpiry, new X500Name(subject), SubjectPublicKeyInfo.getInstance(rsaPublicKey.getEncoded()) ); var pgpSigner = new PGPSigner(pgpSecretKey); var certificateBytes = certificateBuilder.build(pgpSigner).getEncoded(); return (X509Certificate) CertificateFactory.getInstance(CERTIFICATE_TYPE).generateCertificate(new ByteArrayInputStream(certificateBytes)); } /** * Gets the certificate from its encoded form. * * @param data a byte array with the encoded certificate * @return a X509 certificate * @throws CertificateException if there's a parse error */ public static X509Certificate getCertificate(byte[] data) throws CertificateException { return (X509Certificate) CertificateFactory.getInstance(CERTIFICATE_TYPE).generateCertificate(new ByteArrayInputStream(data)); } /** * Gets the SSL ID of the certificate. * * @param certificate the X509 certificate * @return the ID that can be used as SSL ID */ public static LocationIdentifier getLocationIdentifier(X509Certificate certificate) throws CertificateException { var serialNumber = Optional.ofNullable(certificate.getSerialNumber()).orElseThrow(() -> new CertificateException("Missing serial number")); var out = new byte[LocationIdentifier.LENGTH]; // There are several certificate versions if (serialNumber.equals(RSSerialVersion.V07_0001.serialNumber())) { // RS 0.6.6. ID is SHA-256 of signature (16 first bytes) var md = new Sha256MessageDigest(); md.update(certificate.getSignature()); System.arraycopy(md.getBytes(), 0, out, 0, out.length); } else if (serialNumber.equals(RSSerialVersion.V06_0001.serialNumber())) { // RS 0.6.5 after November 2017, ID is SHA-1 of signature (16 first bytes) var md = new Sha1MessageDigest(); md.update(certificate.getSignature()); System.arraycopy(md.getBytes(), 0, out, 0, out.length); } else { // The serial number here is either "60000" or a totally random string. // RS < November 2017. ID is the last 16 bytes of the signature. System.arraycopy(certificate.getSignature(), certificate.getSignature().length - out.length, out, 0, out.length); } return new LocationIdentifier(out); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/DatabaseSession.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database; /** * Allows using transactions from outside spring controllers, while still allowing the controller * to call such methods directly. For example: * {@snippet : * try (var session = new DatabaseSession(databaseSessionManager)) * { * doStuff(); * } *} */ public class DatabaseSession implements AutoCloseable { private final DatabaseSessionManager databaseSessionManager; private final boolean isBound; public DatabaseSession(DatabaseSessionManager databaseSessionManager) { this.databaseSessionManager = databaseSessionManager; isBound = databaseSessionManager.bindSession(); } @Override public void close() { if (isBound) { databaseSessionManager.unbindSession(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/database/DatabaseSessionManager.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.PersistenceUnit; import org.springframework.orm.jpa.EntityManagerFactoryUtils; import org.springframework.orm.jpa.EntityManagerHolder; import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionSynchronizationManager; /** * Allows using @Transaction from outside Spring Boot threads. Prefer using {@link DatabaseSession} which implements * an AutoCloseable interface. */ @Component public class DatabaseSessionManager { @PersistenceUnit private EntityManagerFactory entityManagerFactory; public boolean bindSession() { if (!TransactionSynchronizationManager.hasResource(entityManagerFactory)) { var entityManager = entityManagerFactory.createEntityManager(); TransactionSynchronizationManager.bindResource(entityManagerFactory, new EntityManagerHolder(entityManager)); return true; } return false; } public void unbindSession() { var emHolder = (EntityManagerHolder) TransactionSynchronizationManager.unbindResource(entityManagerFactory); EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager()); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/AvailabilityConverter.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.common.location.Availability; import jakarta.persistence.Converter; @Converter(autoApply = true) public class AvailabilityConverter extends EnumConverter { @Override Class getEnumClass() { return Availability.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/EnumConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import jakarta.persistence.AttributeConverter; /** * This class is needed because Hibernate uses the ordinal value of enums to save them in the database and * some smartass changed enums in H2 to start from 1 instead of 0. Of course this breaks everything. *

* Don't forget to annotate your subclass with @Converter(autoApply = true)! */ @SuppressWarnings("ConverterNotAnnotatedInspection") public abstract class EnumConverter> implements AttributeConverter { abstract Class getEnumClass(); @Override public Integer convertToDatabaseColumn(E attribute) { if (attribute == null) { return null; } return attribute.ordinal() + 1; } @Override public E convertToEntityAttribute(Integer value) { if (value == null) { return null; } var e = getEnumClass(); for (var enumConstant : e.getEnumConstants()) { if (value == enumConstant.ordinal() + 1) { return enumConstant; } } throw new IllegalArgumentException("Ordinal value " + value + " doesn't exist for enum " + e); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/EnumSetConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import jakarta.persistence.AttributeConverter; import java.util.EnumSet; import java.util.Set; /** * This class is needed because Hibernate uses the ordinal value of enums to save them in the database and * some smartass changed enums in H2 to start from 1 instead of 0. Of course this breaks everything. *

* Don't forget to annotate your subclass with @Converter(autoApply = true)! */ @SuppressWarnings("ConverterNotAnnotatedInspection") public abstract class EnumSetConverter> implements AttributeConverter, Integer> { abstract Class getEnumClass(); @Override public Integer convertToDatabaseColumn(Set enumSet) { var value = 0; if (enumSet != null) { for (Enum anEnum : enumSet) { value |= 1 << anEnum.ordinal(); } } return value; } @Override public Set convertToEntityAttribute(Integer value) { var e = getEnumClass(); var enumSet = EnumSet.noneOf(e); for (var enumConstant : e.getEnumConstants()) { if ((value & (1 << enumConstant.ordinal())) != 0) { enumSet.add(enumConstant); } } return enumSet; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/FileTypeConverter.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.common.file.FileType; import jakarta.persistence.Converter; @Converter(autoApply = true) public class FileTypeConverter extends EnumConverter { @Override Class getEnumClass() { return FileType.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/GxsCircleTypeConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.app.database.model.gxs.GxsCircleType; import jakarta.persistence.Converter; @Converter(autoApply = true) public class GxsCircleTypeConverter extends EnumConverter { @Override Class getEnumClass() { return GxsCircleType.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/GxsPrivacyFlagsConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.app.database.model.gxs.GxsPrivacyFlags; import jakarta.persistence.Converter; @Converter(autoApply = true) public class GxsPrivacyFlagsConverter extends EnumSetConverter { @Override Class getEnumClass() { return GxsPrivacyFlags.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/GxsSignatureFlagsConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.app.database.model.gxs.GxsSignatureFlags; import jakarta.persistence.Converter; @Converter(autoApply = true) public class GxsSignatureFlagsConverter extends EnumSetConverter { @Override Class getEnumClass() { return GxsSignatureFlags.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/IdentityTypeConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.common.identity.Type; import jakarta.persistence.Converter; @Converter(autoApply = true) public class IdentityTypeConverter extends EnumConverter { @Override Class getEnumClass() { return Type.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/NetModeConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.common.protocol.NetMode; import jakarta.persistence.Converter; @Converter(autoApply = true) public class NetModeConverter extends EnumConverter { @Override Class getEnumClass() { return NetMode.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/PeerAddressTypeConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.app.net.protocol.PeerAddress; import jakarta.persistence.Converter; @Converter(autoApply = true) public class PeerAddressTypeConverter extends EnumConverter { @Override Class getEnumClass() { return PeerAddress.Type.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/SecurityKeyFlagsConverter.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.app.xrs.common.SecurityKey; import jakarta.persistence.Converter; @Converter(autoApply = true) public class SecurityKeyFlagsConverter extends EnumSetConverter { @Override Class getEnumClass() { return SecurityKey.Flags.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/SignatureTypeConverter.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.app.xrs.common.Signature; import jakarta.persistence.Converter; @Converter(autoApply = true) public class SignatureTypeConverter extends EnumConverter { @Override Class getEnumClass() { return Signature.Type.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/TrustConverter.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.common.pgp.Trust; import jakarta.persistence.Converter; @Converter(autoApply = true) public class TrustConverter extends EnumConverter { @Override Class getEnumClass() { return Trust.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/converter/VoteTypeConverter.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.converter; import io.xeres.app.xrs.common.VoteMessageItem; import jakarta.persistence.Converter; @Converter(autoApply = true) public class VoteTypeConverter extends EnumConverter { @Override Class getEnumClass() { return VoteMessageItem.Type.class; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/board/BoardMapper.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.board; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.board.item.BoardGroupItem; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.dto.board.BoardGroupDTO; import io.xeres.common.dto.board.BoardMessageDTO; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.domain.Page; import java.util.List; import java.util.Map; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class BoardMapper { private BoardMapper() { throw new UnsupportedOperationException("Utility class"); } public static BoardGroupDTO toDTO(BoardGroupItem item) { if (item == null) { return null; } return new BoardGroupDTO( item.getId(), item.getGxsId(), item.getName(), item.getDescription(), item.hasImage(), item.isSubscribed(), item.isExternal(), item.getVisibleMessageCount(), item.getLastActivity() ); } public static List toDTOs(List items) { return emptyIfNull(items).stream() .map(BoardMapper::toDTO) .toList(); } public static BoardMessageDTO toDTO(UnHtmlService unHtmlService, BoardMessageItem item, String authorName, long originalId, long parentId) { if (item == null) { return null; } return new BoardMessageDTO( item.getId(), item.getGxsId(), item.getMsgId(), originalId, parentId, item.getAuthorGxsId(), authorName, item.getName(), item.getPublished(), item.getLink(), unHtmlService.cleanupMessage(item.getContent()), item.hasImage(), item.getImageWidth(), item.getImageHeight(), item.isRead() ); } public static List toBoardMessageDTOs(UnHtmlService unHtmlService, Page items, Map authorsMap, Map messagesMap) { return items.stream() .map(item -> toDTO(unHtmlService, item, authorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(), messagesMap.getOrDefault(item.getOriginalMsgId(), BoardMessageItem.EMPTY).getId(), messagesMap.getOrDefault(item.getParentMsgId(), BoardMessageItem.EMPTY).getId() )) .toList(); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/channel/ChannelMapper.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.channel; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.common.FileItem; import io.xeres.app.xrs.service.channel.item.ChannelGroupItem; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.dto.channel.ChannelFileDTO; import io.xeres.common.dto.channel.ChannelGroupDTO; import io.xeres.common.dto.channel.ChannelMessageDTO; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.domain.Page; import java.util.List; import java.util.Map; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class ChannelMapper { private ChannelMapper() { throw new UnsupportedOperationException("Utility class"); } public static ChannelGroupDTO toDTO(ChannelGroupItem item) { if (item == null) { return null; } return new ChannelGroupDTO( item.getId(), item.getGxsId(), item.getName(), item.getDescription(), item.hasImage(), item.isSubscribed(), item.isExternal(), item.getVisibleMessageCount(), item.getLastActivity() ); } public static List toDTOs(List items) { return emptyIfNull(items).stream() .map(ChannelMapper::toDTO) .toList(); } public static ChannelMessageDTO toDTO(ChannelMessageItem item, String authorName, long originalId, long parentId) { if (item == null) { return null; } return new ChannelMessageDTO( item.getId(), item.getGxsId(), item.getMsgId(), originalId, parentId, item.getAuthorGxsId(), authorName, item.getName(), item.getPublished(), null, item.hasImage(), item.getImageWidth(), item.getImageHeight(), item.hasFiles(), List.of(), item.isRead() ); } public static List toSummaryMessageDTOs(Page items, Map authorsMap, Map messagesMap) { return items.stream() .map(item -> toDTO(item, authorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(), messagesMap.getOrDefault(item.getOriginalMsgId(), ChannelMessageItem.EMPTY).getId(), messagesMap.getOrDefault(item.getParentMsgId(), ChannelMessageItem.EMPTY).getId() )) .toList(); } public static ChannelMessageDTO toDTO(UnHtmlService unHtmlService, ChannelMessageItem item, String authorName, long originalId, long parentId, boolean withMessageContent) { if (item == null) { return null; } return new ChannelMessageDTO( item.getId(), item.getGxsId(), item.getMsgId(), originalId, parentId, item.getAuthorGxsId(), authorName, item.getName(), item.getPublished(), withMessageContent ? unHtmlService.cleanupMessage(item.getContent()) : "", item.hasImage(), item.getImageWidth(), item.getImageHeight(), item.hasFiles(), withMessageContent ? toChannelFileDTOs(item.getFiles()) : List.of(), item.isRead() ); } private static List toChannelFileDTOs(List files) { return emptyIfNull(files).stream() .map(ChannelMapper::toChannelFileDTO) .toList(); } private static ChannelFileDTO toChannelFileDTO(FileItem item) { if (item == null) { return null; } return new ChannelFileDTO( item.size(), item.hash(), item.name(), item.path(), item.age() ); } public static List toChannelMessageDTOs(UnHtmlService unHtmlService, List items, Map authorsMap, Map messagesMap, boolean withMessageContent) { return emptyIfNull(items).stream() .map(item -> toDTO(unHtmlService, item, authorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(), messagesMap.getOrDefault(item.getOriginalMsgId(), ChannelMessageItem.EMPTY).getId(), messagesMap.getOrDefault(item.getParentMsgId(), ChannelMessageItem.EMPTY).getId(), withMessageContent )) .toList(); } public static List toFileItems(List dtos) { return emptyIfNull(dtos).stream() .map(ChannelMapper::toFileItem) .toList(); } public static FileItem toFileItem(ChannelFileDTO dto) { if (dto == null) { return null; } return new FileItem(dto.size(), dto.hash(), dto.name(), dto.path(), dto.age()); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/chat/ChatBacklog.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.chat; import io.xeres.app.database.model.location.Location; import jakarta.persistence.*; import org.hibernate.annotations.CreationTimestamp; import java.time.Instant; @Entity public class ChatBacklog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "location_id", nullable = false) private Location location; @CreationTimestamp private Instant created; private boolean own; private String message; protected ChatBacklog() { } public ChatBacklog(Location location, boolean own, String message) { this.location = location; this.own = own; this.message = message; } public long getId() { return id; } public void setId(long id) { this.id = id; } public Location getLocation() { return location; } public void setLocation(Location location) { this.location = location; } public Instant getCreated() { return created; } public void setCreated(Instant created) { this.created = created; } public boolean isOwn() { return own; } public void setOwn(boolean own) { this.own = own; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/chat/ChatMapper.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.chat; import io.xeres.common.dto.chat.*; import io.xeres.common.message.chat.ChatRoomContext; import io.xeres.common.message.chat.ChatRoomInfo; import java.util.List; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class ChatMapper { private ChatMapper() { throw new UnsupportedOperationException("Utility class"); } public static ChatRoomContextDTO toDTO(ChatRoomContext chatRoomContext) { if (chatRoomContext == null) { return null; } return new ChatRoomContextDTO( new ChatRoomsDTO(toDTOs(chatRoomContext.chatRoomLists().getSubscribedRooms()), toDTOs(chatRoomContext.chatRoomLists().getAvailableRooms())), new ChatIdentityDTO(chatRoomContext.ownUser().nickname(), chatRoomContext.ownUser().gxsId(), chatRoomContext.ownUser().identityId()) ); } public static List toDTOs(List chatRoomInfoList) { return emptyIfNull(chatRoomInfoList).stream() .map(ChatMapper::toDTO) .toList(); } public static ChatRoomDTO toDTO(ChatRoomInfo chatRoomInfo) { return new ChatRoomDTO( chatRoomInfo.getId(), chatRoomInfo.getName(), chatRoomInfo.getRoomType(), chatRoomInfo.getTopic(), chatRoomInfo.getCount(), chatRoomInfo.isSigned() ); } public static List toChatRoomBacklogDTOs(List chatRoomBacklogList) { return emptyIfNull(chatRoomBacklogList).stream() .map(ChatMapper::toDTO) .toList(); } public static ChatRoomBacklogDTO toDTO(ChatRoomBacklog chatRoomBackLog) { return new ChatRoomBacklogDTO( chatRoomBackLog.getCreated(), chatRoomBackLog.getGxsId(), chatRoomBackLog.getNickname(), chatRoomBackLog.getMessage() ); } public static List toChatBacklogDTOs(List chatBacklogList) { return emptyIfNull(chatBacklogList).stream() .map(ChatMapper::toDTO) .toList(); } public static ChatBacklogDTO toDTO(ChatBacklog chatBacklog) { return new ChatBacklogDTO( chatBacklog.getCreated(), chatBacklog.isOwn(), chatBacklog.getMessage() ); } public static List fromDistantChatBacklogToChatBacklogDTOs(List distantChatBacklogList) { return emptyIfNull(distantChatBacklogList).stream() .map(ChatMapper::toDTO) .toList(); } public static ChatBacklogDTO toDTO(DistantChatBacklog distantChatBacklog) { return new ChatBacklogDTO( distantChatBacklog.getCreated(), distantChatBacklog.isOwn(), distantChatBacklog.getMessage() ); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/chat/ChatRoom.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.chat; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.service.chat.RoomFlags; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import jakarta.persistence.*; import org.apache.commons.lang3.EnumUtils; import java.util.HashSet; import java.util.Set; @Entity public class ChatRoom { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; private long roomId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "identity_group_id") private IdentityGroupItem identityGroupItem; private String name; private String topic; private int flags; private boolean subscribed; private boolean joined; /** * Locations that are participating in the chat room. */ @OneToMany private final Set locations = new HashSet<>(); protected ChatRoom() { } protected ChatRoom(long roomId, IdentityGroupItem identityGroupItem, String name, String topic, int flags) { this.roomId = roomId; this.identityGroupItem = identityGroupItem; this.name = name; this.topic = topic; this.flags = flags; } public static ChatRoom createChatRoom(io.xeres.app.xrs.service.chat.ChatRoom serviceChatRoom, IdentityGroupItem identityGroupItem) { return new ChatRoom(serviceChatRoom.getId(), identityGroupItem, serviceChatRoom.getName(), serviceChatRoom.getTopic(), (int) EnumUtils.generateBitVector(RoomFlags.class, serviceChatRoom.getRoomFlags())); } public long getId() { return id; } public void setId(long id) { this.id = id; } public long getRoomId() { return roomId; } public void setRoomId(long roomId) { this.roomId = roomId; } public IdentityGroupItem getGxsIdGroupItem() { return identityGroupItem; } public void setGxsIdGroupItem(IdentityGroupItem identityGroupItem) { this.identityGroupItem = identityGroupItem; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getTopic() { return topic; } public void setTopic(String topic) { this.topic = topic; } public Set getFlags() { return EnumUtils.processBitVector(RoomFlags.class, flags); } public void setFlags(Set flags) { this.flags = (int) EnumUtils.generateBitVector(RoomFlags.class, flags); } public boolean isSubscribed() { return subscribed; } public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } public boolean isJoined() { return joined; } public void setJoined(boolean joined) { this.joined = joined; } public Set getLocations() { return locations; } public void addLocation(Location location) { locations.add(location); } public void removeLocation(Location location) { locations.remove(location); } public void clearLocations() { locations.clear(); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/chat/ChatRoomBacklog.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.chat; import io.xeres.common.id.GxsId; import jakarta.persistence.*; import org.hibernate.annotations.CreationTimestamp; import java.time.Instant; @Entity public class ChatRoomBacklog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "room_id", nullable = false) private ChatRoom room; @CreationTimestamp private Instant created; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "gxs_id")) private GxsId gxsId; private String nickname; private String message; protected ChatRoomBacklog() { } public ChatRoomBacklog(ChatRoom room, GxsId gxsId, String nickname, String message) { this.room = room; this.gxsId = gxsId; this.nickname = nickname; this.message = message; } public ChatRoomBacklog(ChatRoom room, String nickname, String message) { this.room = room; this.nickname = nickname; this.message = message; } public long getId() { return id; } public void setId(long id) { this.id = id; } public ChatRoom getRoom() { return room; } public void setRoom(ChatRoom room) { this.room = room; } public Instant getCreated() { return created; } public void setCreated(Instant timestamp) { created = timestamp; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public String getNickname() { return nickname; } public void setNickname(String nickname) { this.nickname = nickname; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/chat/DistantChatBacklog.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.chat; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import jakarta.persistence.*; import org.hibernate.annotations.CreationTimestamp; import java.time.Instant; @Entity public class DistantChatBacklog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "identity_id", nullable = false) private IdentityGroupItem identityGroupItem; @CreationTimestamp private Instant created; private boolean own; private String message; protected DistantChatBacklog() { } public DistantChatBacklog(IdentityGroupItem identityGroupItem, boolean own, String message) { this.identityGroupItem = identityGroupItem; this.own = own; this.message = message; } public long getId() { return id; } public void setId(long id) { this.id = id; } public IdentityGroupItem getIdentityGroupItem() { return identityGroupItem; } public void setIdentityGroupItem(IdentityGroupItem identityGroupItem) { this.identityGroupItem = identityGroupItem; } public Instant getCreated() { return created; } public void setCreated(Instant created) { this.created = created; } public boolean isOwn() { return own; } public void setOwn(boolean own) { this.own = own; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/connection/Connection.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.connection; import com.fasterxml.jackson.annotation.JsonIgnore; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.common.protocol.ip.IP; import jakarta.persistence.*; import java.time.Instant; import java.util.Objects; import static io.xeres.app.net.protocol.PeerAddress.Type.HOSTNAME; import static io.xeres.app.net.protocol.PeerAddress.Type.IPV4; @Entity public class Connection { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "location_id", nullable = false) private Location location; private PeerAddress.Type type; private String address; private Instant lastConnected; private boolean external; protected Connection() { } public static Connection from(PeerAddress peerAddress) { return new Connection(peerAddress); } private Connection(PeerAddress peerAddress) { type = peerAddress.getType(); address = peerAddress.getAddress().orElseThrow(); external = peerAddress.isExternal(); } long getId() { return id; } void setId(long id) { this.id = id; } public Location getLocation() { return location; } public void setLocation(Location location) { this.location = location; } public PeerAddress.Type getType() { return type; } public void setType(PeerAddress.Type type) { this.type = type; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public Instant getLastConnected() { return lastConnected; } public void setLastConnected(Instant lastConnected) { this.lastConnected = lastConnected; } public boolean isExternal() { return external; } public void setExternal(boolean external) { this.external = external; } @JsonIgnore public boolean isLan() { return type == IPV4 && !isExternal() && IP.isLanIp(address.split(":")[0]); } public int getPort() { if (!(type == IPV4 || type == HOSTNAME)) { throw new IllegalArgumentException("Trying to get port from a non ipv4 address"); } var tokens = address.split(":"); return Integer.parseInt(tokens[1]); } public String getIp() { if (type != IPV4) { throw new IllegalArgumentException("Trying to get ip from a non ipv4 address"); } var tokens = address.split(":"); return tokens[0]; } public String getHostname() { if (type != HOSTNAME) { throw new IllegalArgumentException("Trying to get a hostname from a non hostname address"); } var tokens = address.split(":"); return tokens[0]; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (Connection) o; return external == that.external && type == that.type && address.equals(that.address); } @Override public int hashCode() { return Objects.hash(type, address, external); } @Override public String toString() { return "Connection{" + "type=" + type + ", address='" + address + '\'' + ", external=" + external + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/connection/ConnectionMapper.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.connection; import io.xeres.common.dto.connection.ConnectionDTO; @SuppressWarnings("DuplicatedCode") public final class ConnectionMapper { private ConnectionMapper() { throw new UnsupportedOperationException("Utility class"); } public static ConnectionDTO toDTO(Connection connection) { if (connection == null) { return null; } return new ConnectionDTO( connection.getId(), connection.getAddress(), connection.getLastConnected(), connection.isExternal()); } public static Connection fromDTO(ConnectionDTO dto) { if (dto == null) { return null; } var connection = new Connection(); connection.setId(dto.id()); connection.setAddress(dto.address()); connection.setExternal(dto.external()); connection.setLastConnected(dto.lastConnected()); return connection; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/file/File.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.file; import io.xeres.common.file.FileType; import io.xeres.common.id.Sha1Sum; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.file.Path; import java.time.Instant; import java.util.HashSet; import java.util.Set; @Entity public class File { private static final Logger log = LoggerFactory.getLogger(File.class); private static final int NAME_SIZE_MIN = 1; private static final int NAME_SIZE_MAX = 255; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") private File parent; @OneToMany(cascade = CascadeType.ALL, mappedBy = "parent", orphanRemoval = true) private Set children = new HashSet<>(); @NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) private String name; private long size; private FileType type; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "hash")) private Sha1Sum hash; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "encrypted_hash")) private Sha1Sum encryptedHash; private Instant modified; public static File createDirectory(File parent, String name, Instant modified) { var file = new File(); file.setParent(parent); file.setName(name); file.setType(FileType.DIRECTORY); file.setModified(modified); return file; } public static File createFile(File parent, String name, long size, Instant modified) { var file = new File(); file.setParent(parent); file.setName(name); file.setSize(size); file.setType(FileType.getTypeByExtension(name)); file.setModified(modified); return file; } public static File createFile(Path path) { path = getCanonicalPath(path); var file = createFile(path.getRoot().toString(), null); file.setType(FileType.DIRECTORY); for (Path component : path) { file = createFile(component.getFileName().toString(), file); file.setType(FileType.DIRECTORY); } return file; } private static Path getCanonicalPath(Path path) { try { return Path.of(path.toFile().getCanonicalPath()); } catch (IOException _) { log.error("Failed to get canonical path: {}, using absolute path instead", path); return path.toAbsolutePath(); } } private static File createFile(String name, File parent) { var file = new File(); file.setName(name); if (parent != null) { file.setParent(parent); } return file; } public long getId() { return id; } public void setId(long id) { this.id = id; } public boolean hasParent() { return parent != null; } public File getParent() { return parent; } public void setParent(File parent) { this.parent = parent; parent.getChildren().add(this); } public Set getChildren() { return children; } public void setChildren(Set children) { this.children = children; } public @NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String getName() { return name; } public void setName(@NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String name) { this.name = name; } public long getSize() { return size; } public void setSize(long size) { this.size = size; } public FileType getType() { return type; } public void setType(FileType type) { this.type = type; } public Sha1Sum getHash() { return hash; } public void setHash(Sha1Sum hash) { this.hash = hash; } public Sha1Sum getEncryptedHash() { return encryptedHash; } public void setEncryptedHash(Sha1Sum encryptedHash) { this.encryptedHash = encryptedHash; } public Instant getModified() { return modified; } public void setModified(Instant modified) { this.modified = modified; } @Override public String toString() { return "File{" + "id=" + id + ", name='" + name + '\'' + ", type=" + type + ", hash=" + hash + ", encryptedHash=" + encryptedHash + ", modified=" + modified + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/file/FileDownload.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.file; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.util.BitSet; @Entity public class FileDownload { private static final int NAME_SIZE_MIN = 1; private static final int NAME_SIZE_MAX = 255; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) private String name; private long size; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "hash")) private Sha1Sum hash; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "location_id") private Location location; private BitSet chunkMap = new BitSet(); private boolean completed; public long getId() { return id; } public void setId(long id) { this.id = id; } public @NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String getName() { return name; } public void setName(@NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String name) { this.name = name; } public long getSize() { return size; } public void setSize(long size) { this.size = size; } public Sha1Sum getHash() { return hash; } public void setHash(Sha1Sum hash) { this.hash = hash; } public BitSet getChunkMap() { return chunkMap; } public void setChunkMap(BitSet chunkMap) { this.chunkMap = chunkMap; } public boolean hasLocation() { return location != null; } public Location getLocation() { return location; } public void setLocation(Location location) { this.location = location; } public boolean isCompleted() { return completed; } public void setCompleted(boolean completed) { this.completed = completed; } @Override public String toString() { return "FileDownload{" + "id=" + id + ", name='" + name + '\'' + ", size=" + size + ", hash=" + hash + ", chunkMap=" + chunkMap + ", location=" + location + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/forum/ForumMapper.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.forum; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.forum.item.ForumGroupItem; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.dto.forum.ForumGroupDTO; import io.xeres.common.dto.forum.ForumMessageDTO; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.domain.Page; import java.util.List; import java.util.Map; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class ForumMapper { private ForumMapper() { throw new UnsupportedOperationException("Utility class"); } public static ForumGroupDTO toDTO(ForumGroupItem item) { if (item == null) { return null; } return new ForumGroupDTO( item.getId(), item.getGxsId(), item.getName(), item.getDescription(), item.isSubscribed(), item.isExternal(), item.getVisibleMessageCount(), item.getLastActivity() ); } public static List toDTOs(List items) { return emptyIfNull(items).stream() .map(ForumMapper::toDTO) .toList(); } public static ForumMessageDTO toDTO(ForumMessageItemSummary item, String authorName, long originalId, long parentId) { if (item == null) { return null; } return new ForumMessageDTO( item.getId(), item.getGxsId(), item.getMsgId(), originalId, parentId, item.getAuthorGxsId(), authorName, item.getName(), item.getPublished(), null, item.isRead() ); } public static List toSummaryMessageDTOs(Page items, Map authorsMap, Map messagesMap) { return items.stream() .map(item -> toDTO(item, authorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(), messagesMap.getOrDefault(item.getOriginalMsgId(), ForumMessageItem.EMPTY).getId(), messagesMap.getOrDefault(item.getParentMsgId(), ForumMessageItem.EMPTY).getId() )) .toList(); } public static ForumMessageDTO toDTO(UnHtmlService unHtmlService, ForumMessageItem item, String authorName, long originalId, long parentId, boolean withMessageContent) { if (item == null) { return null; } return new ForumMessageDTO( item.getId(), item.getGxsId(), item.getMsgId(), originalId, parentId, item.getAuthorGxsId(), authorName, item.getName(), item.getPublished(), withMessageContent ? unHtmlService.cleanupMessage(item.getContent()) : "", item.isRead() ); } public static List toForumMessageDTOs(UnHtmlService unHtmlService, List items, Map authorsMap, Map messagesMap, boolean withMessageContent) { return emptyIfNull(items).stream() .map(item -> toDTO(unHtmlService, item, authorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(), messagesMap.getOrDefault(item.getOriginalMsgId(), ForumMessageItem.EMPTY).getId(), messagesMap.getOrDefault(item.getParentMsgId(), ForumMessageItem.EMPTY).getId(), withMessageContent )) .toList(); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/forum/ForumMessageItemSummary.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.forum; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import java.time.Instant; /** * A summary of message items. *

* Caution: the method names must match the ones in ForumMessageItem! */ public interface ForumMessageItemSummary { long getId(); GxsId getGxsId(); MsgId getMsgId(); MsgId getOriginalMsgId(); MsgId getParentMsgId(); GxsId getAuthorGxsId(); String getName(); Instant getPublished(); boolean isRead(); } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsCircleType.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; public enum GxsCircleType { /** * Uninitialized value. For example, Identities are left at that state. */ UNKNOWN, /** * Public distribution. Not restricted to a circle. */ PUBLIC, /** * Restricted to an external circle, based on GxsIds. */ EXTERNAL, /** * Restricted to a group of friend nodes. The administrator of the circle behaves as a controlling hub * for them. Based on PGP ids. */ YOUR_FRIENDS_ONLY, /** * Not distributed at all. */ LOCAL, /** * Self-restricted. Used only at creation time of self-restricted circles, when the * circle ID isn't known yet. Once the circle ID is known, the type * is set to EXTERNAL, and the external circle ID is set to the ID of the circle itself. * Based on GxsIds. */ EXTERNAL_SELF, /** * Distributed to locations signed by own profile only. */ YOUR_EYES_ONLY } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsClientUpdate.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.GxsId; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import java.time.Instant; import java.util.HashMap; import java.util.Map; @Entity public class GxsClientUpdate { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @NotNull @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "location_id", nullable = false) private Location location; private int serviceType; private Instant lastSynced; @ElementCollection @Column(name = "updated") private final Map messages = new HashMap<>(); public GxsClientUpdate() { // Needed } public GxsClientUpdate(Location location, int serviceType, Instant lastSynced) { this.location = location; this.serviceType = serviceType; this.lastSynced = lastSynced; } public GxsClientUpdate(Location location, int serviceType, GxsId gxsId, Instant lastSynced) { this.location = location; this.serviceType = serviceType; messages.put(gxsId, lastSynced); } public long getId() { return id; } public void setId(long id) { this.id = id; } public Location getLocation() { return location; } public void setLocation(Location location) { this.location = location; } public int getServiceType() { return serviceType; } public void setServiceType(int serviceType) { this.serviceType = serviceType; } public Instant getLastSynced() { return lastSynced; } public void setLastSynced(Instant lastSynced) { this.lastSynced = lastSynced; } public Instant getMessageUpdate(GxsId gxsId) { return messages.get(gxsId); } public void putMessageUpdate(GxsId gxsId, Instant lastSynced) { messages.put(gxsId, lastSynced); } public void removeMessageUpdate(GxsId gxsId) { messages.remove(gxsId); } @Override public String toString() { return "GxsClientUpdate{" + "location=" + location + ", serviceType=" + serviceType + ", lastSynced=" + lastSynced + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsConstants.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; final class GxsConstants { static final int GXS_ITEM_MAX_SIZE = 1_572_864; // 1.5 MB private GxsConstants() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsGroupItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.netty.buffer.ByteBuf; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.xrs.common.SecurityKey; import io.xeres.app.xrs.common.Signature; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.FieldSize; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.app.xrs.service.gxs.item.DynamicServiceType; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.time.Instant; import java.util.EnumSet; import java.util.HashSet; import java.util.Objects; import java.util.Set; import static io.xeres.app.database.model.gxs.GxsConstants.GXS_ITEM_MAX_SIZE; import static io.xeres.app.xrs.common.SecurityKey.Flags.*; import static io.xeres.app.xrs.serialization.Serializer.*; @Entity(name = "gxs_group") @Inheritance(strategy = InheritanceType.JOINED) public abstract class GxsGroupItem extends Item implements GxsMetaAndData, DynamicServiceType { private static final Logger log = LoggerFactory.getLogger(GxsGroupItem.class); private static final int API_VERSION_1 = 0x0000; private static final int API_VERSION_2 = 0xaf01; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "gxs_id")) private GxsId gxsId; @NotNull private String name; private Set diffusionFlags = EnumSet.noneOf(GxsPrivacyFlags.class); private Set signatureFlags = EnumSet.noneOf(GxsSignatureFlags.class); // what signatures are required for parent and child messages /** * The last time the group was updated. This only concerns the group's data (name, image), * not its children (messages). For example when a new message arrives, this field is not * updated. */ private Instant published; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "author")) private GxsId authorGxsId; // author of the group, null if anonymous @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "circle_id")) private GxsId circleGxsId; // id of the circle to which the group is restricted private GxsCircleType circleType = GxsCircleType.UNKNOWN; private int authenticationFlags; // not used yet? /** * Not used by RS currently. */ @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "parent_id")) private GxsId parentGxsId; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "originator")) private LocationIdentifier originator; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "internal_circle")) private GxsId internalCircleGxsId; @ElementCollection private final Set privateKeys = HashSet.newHashSet(2); @ElementCollection private final Set publicKeys = HashSet.newHashSet(2); @ElementCollection private final Set signatures = HashSet.newHashSet(2); // Below is local data (stored in the database only, not synced) /** * If we are subscribed to that group. */ private boolean subscribed; /** * When the group's children were updated, which means a sync is needed. */ private Instant lastUpdated; // The following is handled by the group statistics system /** * Number of friends that are subscribed. */ private int popularity; /** * Maximum messages reported by friends. */ private int visibleMessageCount; /** * Last activity reported by the friends. */ private Instant lastActivity = Instant.EPOCH; /** * When the last statistics request was sent. */ private Instant lastStatistics = Instant.EPOCH; /** * Retains the values from a group we're upgrading. * * @param oldGroup the group to keep the values from */ public void retainValues(GxsGroupItem oldGroup) { setSubscribed(oldGroup.isSubscribed()); setLastUpdated(oldGroup.getLastUpdated()); setPopularity(oldGroup.getPopularity()); setVisibleMessageCount(oldGroup.getVisibleMessageCount()); setLastActivity(oldGroup.getLastActivity()); setLastStatistics(oldGroup.getLastStatistics()); } @Transient private int serviceType; @Override public int getServiceType() { return serviceType; } @Override public void setServiceType(int serviceType) { this.serviceType = serviceType; } public long getId() { return id; } public void setId(long id) { this.id = id; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public @NotNull String getName() { return name; } public void setName(@NotNull String name) { this.name = name; } public Set getDiffusionFlags() { return diffusionFlags; } public void setDiffusionFlags(Set diffusionFlags) { this.diffusionFlags = diffusionFlags; } public Set getSignatureFlags() { return signatureFlags; } public void setSignatureFlags(Set signatureFlags) { this.signatureFlags = signatureFlags; } public Instant getPublished() { return published; } public void updatePublished() { published = Instant.now(); } public GxsId getAuthorGxsId() { return authorGxsId; } public void setAuthorGxsId(GxsId authorGxsId) { this.authorGxsId = authorGxsId; } public GxsId getCircleGxsId() { return circleGxsId; } public void setCircleGxsId(GxsId circleGxsId) { this.circleGxsId = circleGxsId; } public GxsCircleType getCircleType() { return circleType; } public void setCircleType(GxsCircleType circleType) { this.circleType = circleType; } public int getAuthenticationFlags() { return authenticationFlags; } public void setAuthenticationFlags(int authenticationFlags) { this.authenticationFlags = authenticationFlags; } public GxsId getParentGxsId() { return parentGxsId; } public void setParentGxsId(GxsId parentGxsId) { this.parentGxsId = parentGxsId; } public boolean isSubscribed() { return subscribed; } public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } public int getPopularity() { return popularity; } public void setPopularity(int popularity) { this.popularity = popularity; } public int getVisibleMessageCount() { return visibleMessageCount; } public void setVisibleMessageCount(int visibleMessageCount) { this.visibleMessageCount = visibleMessageCount; } public Instant getLastUpdated() { return lastUpdated; } public void setLastUpdated(Instant lastUpdated) { this.lastUpdated = lastUpdated; } public Instant getLastActivity() { return lastActivity; } public void setLastActivity(Instant lastActivity) { this.lastActivity = lastActivity; } public Instant getLastStatistics() { return lastStatistics; } public void setLastStatistics(Instant lastStatistics) { this.lastStatistics = lastStatistics; } public LocationIdentifier getOriginator() { return originator; } public void setOriginator(LocationIdentifier originator) { this.originator = originator; } public GxsId getInternalCircleGxsId() { return internalCircleGxsId; } public void setInternalCircleGxsId(GxsId internalCircleGxsId) { this.internalCircleGxsId = internalCircleGxsId; } /** * Checks if it comes from an external source (meaning: not our own). * * @return true if coming from someone else */ public boolean isExternal() { return privateKeys.stream() .noneMatch(securityKey -> securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_ADMIN, TYPE_FULL))); } public PrivateKey getAdminPrivateKey() { var privateKey = privateKeys.stream() .filter(securityKey -> securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_ADMIN, TYPE_FULL))) .findFirst().orElseThrow(); try { return RSA.getPrivateKeyFromPkcs1(privateKey.getData()); } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { throw new IllegalArgumentException("Cannot read admin private key from database: " + e.getMessage(), e); } } public void setAdminKeys(PrivateKey privateKey, PublicKey publicKey, Instant validFrom, Instant validTo) { Objects.requireNonNull(validFrom); var keyId = getGxsId(); if (keyId == null) { throw new IllegalStateException("GxsGroupItem has no GxsId for the admin private key"); } try { var privateData = RSA.getPrivateKeyAsPkcs1(privateKey); var publicData = RSA.getPublicKeyAsPkcs1(publicKey); privateKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_ADMIN, TYPE_FULL), validFrom, validTo, privateData)); publicKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_ADMIN, TYPE_PUBLIC_ONLY), validFrom, validTo, publicData)); } catch (IOException e) { throw new IllegalArgumentException("Cannot read admin private key from database: " + e.getMessage(), e); } } public PublicKey getAdminPublicKey() { var publicKey = publicKeys.stream() .filter(securityKey -> isAdminKey(securityKey) && isValidKey(securityKey)) .findFirst().orElse(null); if (publicKey == null) { return null; } try { return RSA.getPublicKeyFromPkcs1(publicKey.getData()); } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { throw new IllegalArgumentException("Cannot read admin public key from database: " + e.getMessage(), e); } } private static boolean isAdminKey(SecurityKey securityKey) { return securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_ADMIN, TYPE_PUBLIC_ONLY)); } public PrivateKey getPublishPrivateKey() { var privateKey = privateKeys.stream() .filter(securityKey -> securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_PUBLISHING, TYPE_FULL))) .findFirst().orElse(null); if (privateKey == null) { return null; } try { return RSA.getPrivateKeyFromPkcs1(privateKey.getData()); } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { throw new IllegalArgumentException("Cannot read publish private key from database: " + e.getMessage(), e); } } public void setPublishKeys(GxsId keyId, PrivateKey privateKey, PublicKey publicKey, Instant validFrom, Instant validTo) { Objects.requireNonNull(validFrom); if (keyId == null) { throw new IllegalStateException("GxsGroupItem has no GxsId for the publish private key"); } try { var privateData = RSA.getPrivateKeyAsPkcs1(privateKey); var publicData = RSA.getPublicKeyAsPkcs1(publicKey); privateKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_PUBLISHING, TYPE_FULL), validFrom, validTo, privateData)); publicKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_PUBLISHING, TYPE_PUBLIC_ONLY), validFrom, validTo, publicData)); } catch (IOException e) { throw new IllegalArgumentException("Cannot read publish private key from database: " + e.getMessage(), e); } } public PublicKey getPublishPublicKey() { var publicKey = publicKeys.stream() .filter(securityKey -> isPublishKey(securityKey) && isValidKey(securityKey)) .findFirst().orElse(null); if (publicKey == null) { return null; } try { return RSA.getPublicKeyFromPkcs1(publicKey.getData()); } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { throw new IllegalArgumentException("Cannot read publish public key from database: " + e.getMessage(), e); } } private static boolean isPublishKey(SecurityKey securityKey) { return securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_PUBLISHING, TYPE_PUBLIC_ONLY)); } private boolean isValidKey(SecurityKey securityKey) { if (securityKey.getValidFrom().isAfter(getPublished())) { log.warn("Key {} has an invalid creation date that is less recent than the group's creation", securityKey); return false; } return true; } public byte[] getAdminSignature() { return signatures.stream() .filter(signature -> signature.getType() == Signature.Type.ADMIN) .findFirst() .map(Signature::getData).orElse(null); } public void setAdminSignature(byte[] adminSignature) { Objects.requireNonNull(gxsId); signatures.stream() .filter(signature -> signature.getType() == Signature.Type.ADMIN) .findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with! var signature = new Signature(Signature.Type.ADMIN, gxsId, adminSignature); signatures.add(signature); } public byte[] getAuthorSignature() { return signatures.stream() .filter(signature -> signature.getType() == Signature.Type.AUTHOR) .findFirst() .map(Signature::getData).orElse(null); } public void setAuthorSignature(byte[] authorSignature) { Objects.requireNonNull(authorSignature); signatures.stream() .filter(signature -> signature.getType() == Signature.Type.AUTHOR) .findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with! var signature = new Signature(Signature.Type.AUTHOR, authorGxsId, authorSignature); signatures.add(signature); } @Override public int writeMetaObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, API_VERSION_2); // current RS API var sizeOffset = buf.writerIndex(); size += serialize(buf, 0); // write size at the end size += serialize(buf, gxsId, GxsId.class); size += serialize(buf, (GxsId) null, GxsId.class); // This is wrongly sent, it's not used at all size += serialize(buf, parentGxsId, GxsId.class); size += serialize(buf, TlvType.STR_NONE, name); size += serialize(buf, diffusionFlags, FieldSize.INTEGER); size += serialize(buf, (int) published.getEpochSecond()); size += serialize(buf, circleType); size += serialize(buf, authenticationFlags); size += serialize(buf, authorGxsId, GxsId.class); size += serialize(buf, TlvType.STR_NONE, ""); // This is wrongly sent, it's supposed to be local storage size += serialize(buf, circleGxsId, GxsId.class); size += serialize(buf, TlvType.SIGNATURE_SET, serializationFlags.contains(SerializationFlags.SIGNATURE) ? new HashSet<>() : signatures); size += serialize(buf, TlvType.SECURITY_KEY_SET, publicKeys); size += serialize(buf, signatureFlags, FieldSize.INTEGER); buf.setInt(sizeOffset, size); // write total size return size; } @Override public void readMetaObject(ByteBuf buf) { var apiVersion = deserializeInt(buf); if (apiVersion != API_VERSION_1 && apiVersion != API_VERSION_2) { throw new IllegalArgumentException("Unsupported API version " + apiVersion); } var size = deserializeInt(buf); // the size if (size > GXS_ITEM_MAX_SIZE) { throw new IllegalArgumentException("Gxs group meta size " + size + " is bigger than the maximum of " + GXS_ITEM_MAX_SIZE); } gxsId = (GxsId) deserializeIdentifier(buf, GxsId.class); deserializeIdentifier(buf, GxsId.class); parentGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class); name = (String) deserialize(buf, TlvType.STR_NONE); diffusionFlags = deserializeEnumSet(buf, GxsPrivacyFlags.class, FieldSize.INTEGER); published = Instant.ofEpochSecond(deserializeInt(buf)); circleType = deserializeEnum(buf, GxsCircleType.class); authenticationFlags = deserializeInt(buf); authorGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class); deserialize(buf, TlvType.STR_NONE); // RS leaks storage strings there circleGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class); deserializeSignatures(buf); deserializeSecurityKeySet(buf); if (apiVersion == API_VERSION_2) { signatureFlags = deserializeEnumSet(buf, GxsSignatureFlags.class, FieldSize.INTEGER); } } private void deserializeSecurityKeySet(ByteBuf buf) { @SuppressWarnings("unchecked") var securityKeys = (Set) deserialize(buf, TlvType.SECURITY_KEY_SET); securityKeys.forEach(securityKey -> { if (securityKey.getFlags().contains(TYPE_PUBLIC_ONLY)) { publicKeys.add(securityKey); } else { log.warn("Peer tried to send a private key, ignoring"); } }); } private void deserializeSignatures(ByteBuf buf) { @SuppressWarnings("unchecked") var signatureSet = (Set) deserialize(buf, TlvType.SIGNATURE_SET); signatures.clear(); signatureSet.forEach(signature -> { if (signature.getType() == Signature.Type.ADMIN || signature.getType() == Signature.Type.AUTHOR) { signatures.add(signature); } else { log.warn("Unknown signature type: {}", signature.getType()); } }); } @Override public GxsGroupItem clone() { return (GxsGroupItem) super.clone(); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } GxsGroupItem that = (GxsGroupItem) o; return Objects.equals(gxsId, that.gxsId); } @Override public int hashCode() { return Objects.hash(gxsId); } @Override public String toString() { return "id=" + id + ", gxsId=" + gxsId + ", name='" + StringUtils.truncate(name, 64) + '\'' + ", flags=" + diffusionFlags + ", signatureFlags=" + signatureFlags + ", published=" + published + ", authorGxsId=" + authorGxsId + ", circleGxsId=" + circleGxsId + ", circleType=" + circleType + ", authenticationFlags=" + authenticationFlags + ", parentGxsId=" + parentGxsId + ", isSubscribed=" + subscribed + ", popularity=" + popularity + ", visibleMessageCount=" + visibleMessageCount + ", lastPosted=" + lastUpdated + ", originator=" + originator + ", internalCircleGxsId=" + internalCircleGxsId; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsMessageItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.Signature; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.app.xrs.service.gxs.item.DynamicServiceType; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import jakarta.persistence.*; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; import java.util.HashSet; import java.util.Objects; import java.util.Set; import static io.xeres.app.database.model.gxs.GxsConstants.GXS_ITEM_MAX_SIZE; import static io.xeres.app.xrs.serialization.Serializer.*; @Entity(name = "gxs_message") @Inheritance(strategy = InheritanceType.JOINED) public abstract class GxsMessageItem extends Item implements GxsMetaAndData, DynamicServiceType { private static final Logger log = LoggerFactory.getLogger(GxsMessageItem.class); private static final int API_VERSION_1 = 0x0000; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "gxs_id")) private GxsId gxsId; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "message_id")) private MsgId msgId; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "thread_id")) private MsgId threadMsgId; // Used for comments and votes (attaches them to a message) @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "parent_id")) private MsgId parentMsgId; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "original_message_id")) private MsgId originalMsgId; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "author_id")) private GxsId authorGxsId; private String name; private Instant published; // publishts (32-bits) // Lower 16-bits are available for services, higher is reserved. Forums and wiki use it. private int flags; // Local storage only, sets the message as hidden because it was superseded by another message (edited) private boolean hidden; @ElementCollection private final Set signatures = HashSet.newHashSet(2); @Transient private int serviceType; @Override public int getServiceType() { return serviceType; } @Override public void setServiceType(int serviceType) { this.serviceType = serviceType; } public long getId() { return id; } public void setId(long id) { this.id = id; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public MsgId getMsgId() { return msgId; } public void setMsgId(MsgId msgId) { this.msgId = msgId; } public MsgId getOriginalMsgId() { return msgId.equals(originalMsgId) ? null : originalMsgId; // Shouldn't happen anymore but older versions might have saved the message with the format used by RS } public void setOriginalMsgId(MsgId originalMsgId) { this.originalMsgId = originalMsgId; } public MsgId getParentMsgId() { return parentMsgId; } public void setParentMsgId(MsgId parentMsgId) { this.parentMsgId = parentMsgId; } public boolean isChild() { return parentMsgId != null; } public GxsId getAuthorGxsId() { return authorGxsId; } public void setAuthorGxsId(GxsId authorGxsId) { this.authorGxsId = authorGxsId; } public boolean hasAuthor() { return authorGxsId != null; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Instant getPublished() { return published; } public void updatePublished() { published = Instant.now(); } public boolean isHidden() { return hidden; } public void setHidden(boolean hidden) { this.hidden = hidden; } public byte[] getPublishSignature() { return signatures.stream() .filter(signature -> signature.getType() == Signature.Type.PUBLISH) .findFirst().orElseThrow().getData(); } public void setPublishSignature(byte[] publishSignature) { signatures.stream() .filter(signature -> signature.getType() == Signature.Type.PUBLISH) .findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with! var signature = new Signature(Signature.Type.PUBLISH, gxsId, publishSignature); signatures.add(signature); } public byte[] getAuthorSignature() { return signatures.stream() .filter(signature -> signature.getType() == Signature.Type.AUTHOR) .findFirst().orElseThrow().getData(); } public void setAuthorSignature(byte[] authorSignature) { Objects.requireNonNull(authorGxsId); signatures.stream() .filter(signature -> signature.getType() == Signature.Type.AUTHOR) .findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with! var signature = new Signature(Signature.Type.AUTHOR, authorGxsId, authorSignature); signatures.add(signature); } @Override public int writeMetaObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, API_VERSION_1); // API version var sizeOffset = buf.writerIndex(); size += serialize(buf, 0); // write size at the end size += serialize(buf, gxsId, GxsId.class); size += serialize(buf, serializationFlags.contains(SerializationFlags.SIGNATURE) ? null : msgId, MsgId.class); size += serialize(buf, threadMsgId, MsgId.class); size += serialize(buf, parentMsgId, MsgId.class); size += serialize(buf, serializationFlags.contains(SerializationFlags.SIGNATURE) && Objects.equals(msgId, originalMsgId) ? null : originalMsgId, MsgId.class); size += serialize(buf, authorGxsId, GxsId.class); size += serialize(buf, TlvType.SIGNATURE_SET, serializationFlags.contains(SerializationFlags.SIGNATURE) ? new HashSet<>() : signatures); size += serialize(buf, TlvType.STR_NONE, name); size += serialize(buf, (int) published.getEpochSecond()); size += serialize(buf, flags); buf.setInt(sizeOffset, size); // write total size return size; } @Override public void readMetaObject(ByteBuf buf) { var apiVersion = deserializeInt(buf); if (apiVersion != API_VERSION_1) { throw new IllegalArgumentException("Unsupported API version " + apiVersion); } var size = deserializeInt(buf); // the size if (size > GXS_ITEM_MAX_SIZE) { throw new IllegalArgumentException("Gxs message meta size " + size + " is bigger than the maximum of " + GXS_ITEM_MAX_SIZE); } gxsId = (GxsId) deserializeIdentifier(buf, GxsId.class); msgId = (MsgId) deserializeIdentifier(buf, MsgId.class); threadMsgId = (MsgId) deserializeIdentifier(buf, MsgId.class); parentMsgId = (MsgId) deserializeIdentifier(buf, MsgId.class); originalMsgId = (MsgId) deserializeIdentifier(buf, MsgId.class); if (msgId.equals(originalMsgId)) { // RS does this weird thing, we get rid of it and use null instead. originalMsgId = null; } authorGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class); deserializeSignature(buf); name = (String) deserialize(buf, TlvType.STR_NONE); published = Instant.ofEpochSecond(deserializeInt(buf)); flags = deserializeInt(buf); } private void deserializeSignature(ByteBuf buf) { @SuppressWarnings("unchecked") var signatureSet = (Set) deserialize(buf, TlvType.SIGNATURE_SET); signatures.clear(); signatureSet.forEach(signature -> { if (signature.getType() == Signature.Type.PUBLISH || signature.getType() == Signature.Type.AUTHOR) { signatures.add(signature); } else { log.warn("Unknown signature type: {}", signature.getType()); } }); } @Override public GxsMessageItem clone() { return (GxsMessageItem) super.clone(); } @Override public String toString() { return "id=" + id + ", gxsId=" + gxsId + ", msgId=" + msgId + ", name='" + StringUtils.truncate(name, 64) + '\'' + ", threadMsgId=" + threadMsgId + ", parentMsgId=" + parentMsgId + ", originalMsgId=" + originalMsgId + ", authorGxsId=" + authorGxsId + ", published=" + published + ", flags=" + flags + ", hidden=" + hidden; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsMetaAndData.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.serialization.SerializationFlags; import java.util.Set; public interface GxsMetaAndData { int writeDataObject(ByteBuf buf, Set serializationFlags); void readDataObject(ByteBuf buf); int writeMetaObject(ByteBuf buf, Set serializationFlags); void readMetaObject(ByteBuf buf); } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsPrivacyFlags.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; public enum GxsPrivacyFlags { PRIVATE, // Key needed to decrypt the publish key RESTRICTED, // Publish private key needed to publish (eg. channels) PUBLIC, // Anyone can publish (eg. forums) UNUSED_4, UNUSED_5, UNUSED_6, UNUSED_7, UNUSED_8, SIGNED_ID // ID backed by Profile } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsServiceSetting.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import jakarta.persistence.Entity; import jakarta.persistence.Id; import java.time.Instant; @Entity public class GxsServiceSetting { @Id private int id; private Instant lastUpdated; public GxsServiceSetting() { // Needed } public GxsServiceSetting(int id, Instant lastUpdated) { this.id = id; this.lastUpdated = lastUpdated; } public int getId() { return id; } public void setId(int id) { this.id = id; } public Instant getLastUpdated() { return lastUpdated; } public void setLastUpdated(Instant lastUpdated) { this.lastUpdated = lastUpdated; } @Override public String toString() { return "GxsServiceSetting{" + "lastUpdated=" + lastUpdated + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/gxs/GxsSignatureFlags.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; public enum GxsSignatureFlags { ENCRYPTED, // unused? ALL_SIGNED, // unused? THREAD_HEAD, // unused? NONE_REQUIRED, // set for all services but never checked UNUSED_1, UNUSED_2, UNUSED_3, UNUSED_4, ANTI_SPAM, AUTHENTICATION_REQUIRED, // unused? IF_NO_PUB_SIGN, // unused TRACK_MESSAGES, // unused ANTI_SPAM_2 } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/identity/IdentityMapper.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.identity; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.dto.identity.IdentityDTO; import java.util.List; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class IdentityMapper { private IdentityMapper() { throw new UnsupportedOperationException("Utility class"); } public static IdentityDTO toDTO(IdentityGroupItem identityGroupItem) { if (identityGroupItem == null) { return null; } return new IdentityDTO( identityGroupItem.getId(), identityGroupItem.getName(), identityGroupItem.getGxsId(), identityGroupItem.getPublished(), identityGroupItem.getType(), identityGroupItem.hasImage() ); } public static List toDTOs(List identityGroupItems) { return emptyIfNull(identityGroupItems).stream() .map(IdentityMapper::toDTO) .toList(); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/location/Location.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.location; import io.xeres.app.crypto.rsid.RSId; import io.xeres.app.crypto.rsid.RSIdBuilder; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.gxs.GxsClientUpdate; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.backup.LocationIdentifierXmlAdapter; import io.xeres.app.service.backup.RSIdXmlAdapter; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.location.Availability; import io.xeres.common.protocol.NetMode; import io.xeres.common.rsid.Type; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlAttribute; import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.stream.Stream; import static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID; import static java.util.Comparator.*; @Entity @XmlAccessorType(XmlAccessType.NONE) public class Location implements Comparable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "profile_id", nullable = false) private Profile profile; private String name; @Embedded @NotNull @AttributeOverride(name = "identifier", column = @Column(name = "location_identifier")) private LocationIdentifier locationIdentifier; @OneToMany(cascade = CascadeType.ALL, mappedBy = "location", orphanRemoval = true) private final List connections = new ArrayList<>(); @OneToMany(cascade = CascadeType.ALL, mappedBy = "location", orphanRemoval = true) private final List clientUpdates = new ArrayList<>(); private boolean connected; private Instant lastConnected; private boolean discoverable = true; private boolean dht = true; private String version; private NetMode netMode = NetMode.UNKNOWN; private Availability availability = Availability.AVAILABLE; // Do NOT use Availability.OFFLINE, use isConnected() for that protected Location() { } protected Location(String name) { this.name = name; } protected Location(long id, String name, Profile profile, LocationIdentifier locationIdentifier) { this.id = id; this.name = name; this.profile = profile; this.locationIdentifier = locationIdentifier; } protected Location(String name, Profile profile, LocationIdentifier locationIdentifier) { this.name = name; this.profile = profile; this.locationIdentifier = locationIdentifier; } public static Location createLocation(RSId rsId) { return new Location(rsId); } public static Location createLocation(String name) { return new Location(name); } public static Location createLocation(String name, Profile profile, LocationIdentifier locationIdentifier) { return new Location(name, profile, locationIdentifier); } public static Location createLocation(String name, LocationIdentifier locationIdentifier) { var location = new Location(name); location.setLocationIdentifier(locationIdentifier); return location; } public static void addOrUpdateLocations(Profile profile, Location newLocation) { if (newLocation == null) { return; } profile.getLocations().removeIf(oldLocation -> oldLocation.getLocationIdentifier().equals(newLocation.getLocationIdentifier())); // XXX: don't remove but update if there are additional fields that were gathered before an update (ie. additional IPs) profile.addLocation(newLocation); } public Location(RSId rsId) { setName(rsId.getName()); setLocationIdentifier(rsId.getLocationIdentifier()); rsId.getDnsName().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress))); rsId.getInternalIp().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress))); rsId.getExternalIp().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress))); rsId.getHiddenNodeAddress().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress))); rsId.getLocators().forEach(peerAddress -> addConnection(Connection.from(peerAddress))); } @XmlAttribute @XmlJavaTypeAdapter(RSIdXmlAdapter.class) @Transient public RSId getCertificate() { return getRsId(Type.CERTIFICATE); } public RSId getRsId(Type type) { var builder = new RSIdBuilder(type); builder.setName(getProfile().getName().getBytes()) .setProfile((getProfile())) .setLocationIdentifier(getLocationIdentifier()) .setPgpFingerprint(getProfile().getProfileFingerprint().getBytes()); // Sort the connections with the most recently connected address first getConnections().stream() .sorted(Comparator.comparing(Connection::getLastConnected, Comparator.nullsFirst(Comparator.naturalOrder())).reversed()) .forEach(builder::addLocator); return builder.build(); } /** * Add a connection while avoiding duplicates. * * @param connection the connection to add */ public void addConnection(Connection connection) { var connectionAlreadyExists = getConnections().stream() .filter(existingConnection -> existingConnection.equals(connection)) .findFirst(); if (connectionAlreadyExists.isEmpty()) { connection.setLocation(this); getConnections().add(connection); } } public Profile getProfile() { return profile; } public void setProfile(Profile profile) { this.profile = profile; } public long getId() { return id; } void setId(long id) { this.id = id; } public void setName(String name) { this.name = name; } public String getSafeName() { return name == null ? "[Unknown]" : name; } /** * Gets the location name. * * @return the location name. Can be null if it was auto created from a profile and is not updated by discovery yet */ @XmlAttribute public String getName() { return name; } public boolean isConnected() { return connected; } public void setConnected(boolean connected) { this.connected = connected; setLastConnected(Instant.now()); } public boolean isDiscoverable() { return discoverable; } public void setDiscoverable(boolean discoverable) { this.discoverable = discoverable; } public boolean isDht() { return dht; } public void setDht(boolean dht) { this.dht = dht; } public String getVersion() { return version; } public void setVersion(String version) { this.version = version; } public NetMode getNetMode() { return netMode; } public void setNetMode(NetMode netMode) { this.netMode = netMode; } public Availability getAvailability() { return availability; } public void setAvailability(Availability availability) { this.availability = availability; } public void setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; } @XmlAttribute(name = "locationId") @XmlJavaTypeAdapter(LocationIdentifierXmlAdapter.class) public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } public List getConnections() { return connections; } public List getClientUpdates() { return clientUpdates; } public Instant getLastConnected() { return lastConnected; } public void setLastConnected(Instant lastConnected) { this.lastConnected = lastConnected; } public boolean isOwn() { return id == OWN_LOCATION_ID; } /** * Returns the best connection. Prefers connections most recently connected to and prefers the LAN * address if the external address is the same as the host. * * @param index index of the connection, is supposed to always increment so that a different connection is returned * @param ipToAvoid the IP to put last * @return a connection or empty if none */ public Stream getBestConnection(int index, String ipToAvoid) { if (connections.isEmpty()) { return Stream.empty(); } var connectionsSortedByMostReliable = connections.stream() .sorted(comparing(Location::getConnectionAsIpv4, new OwnIpComparator<>(ipToAvoid)) .thenComparing(Connection::getLastConnected, nullsLast(reverseOrder()))) .toList(); return Stream.of(connectionsSortedByMostReliable.get(index % connectionsSortedByMostReliable.size())); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var location = (Location) o; return locationIdentifier.equals(location.locationIdentifier); } @Override public int hashCode() { return Objects.hash(locationIdentifier); } @Override public String toString() { return name + " (" + locationIdentifier + ")"; } private static String getConnectionAsIpv4(Connection connection) { if (connection.getType() == PeerAddress.Type.IPV4) { return connection.getIp(); } return ""; } @Override public int compareTo(Location o) { return locationIdentifier.compareTo(o.locationIdentifier); } /** * Comparator that puts connections with our own IP as last. * * @param */ static final class OwnIpComparator implements Comparator { private final String ipToAvoid; OwnIpComparator(String ipToAvoid) { this.ipToAvoid = ipToAvoid; } @Override public int compare(T o1, T o2) { if (o1.equals(ipToAvoid)) { if (o2.equals(ipToAvoid)) { return 0; } else { return 1; } } else if (o2.equals(ipToAvoid)) { return -1; } else { return 0; } } } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/location/LocationMapper.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.location; import io.xeres.app.database.model.connection.ConnectionMapper; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.id.LocationIdentifier; import java.util.ArrayList; @SuppressWarnings("DuplicatedCode") public final class LocationMapper { private LocationMapper() { throw new UnsupportedOperationException("Utility class"); } public static LocationDTO toDTO(Location location) { if (location == null) { return null; } return new LocationDTO( location.getId(), location.getName(), location.getLocationIdentifier().getBytes(), null, new ArrayList<>(), location.isConnected(), location.getLastConnected(), location.getAvailability(), location.getVersion() ); } public static LocationDTO toDeepDTO(Location location) { if (location == null) { return null; } var locationDTO = toDTO(location); locationDTO.connections().addAll(location.getConnections().stream() .map(ConnectionMapper::toDTO) .toList()); return locationDTO; } public static Location fromDTO(LocationDTO dto) { if (dto == null) { return null; } var location = new Location(); location.setId(dto.id()); location.setName(dto.name()); location.setLocationIdentifier(new LocationIdentifier(dto.locationIdentifier())); location.setConnected(dto.connected()); location.setLastConnected(dto.lastConnected()); location.setAvailability(dto.availability()); location.setVersion(dto.version()); return location; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/profile/Profile.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.profile; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.ProfileFingerprint; import io.xeres.common.location.Availability; import io.xeres.common.pgp.Trust; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlAttribute; import jakarta.xml.bind.annotation.XmlElement; import org.bouncycastle.openpgp.PGPPublicKey; import org.bouncycastle.util.encoders.Hex; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import static io.xeres.common.dto.profile.ProfileConstants.*; @Entity @XmlAccessorType(XmlAccessType.NONE) public class Profile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @NotNull @Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX) private String name; private long pgpIdentifier; private Instant created; @Embedded @NotNull @AttributeOverride(name = "identifier", column = @Column(name = "pgp_fingerprint")) private ProfileFingerprint profileFingerprint; private byte[] pgpPublicKeyData; // if null, this is not a valid profile yet private boolean accepted; private Trust trust = Trust.UNKNOWN; @OneToMany(cascade = CascadeType.ALL, mappedBy = "profile", orphanRemoval = true) private final List locations = new ArrayList<>(); protected Profile() { } // This is only used for unit tests protected Profile(long id, String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { this(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData); this.id = id; } public static Profile createOwnProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { var profile = new Profile(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData); profile.setTrust(Trust.ULTIMATE); profile.setAccepted(true); return profile; } public static Profile createProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint pgpFingerprint, PGPPublicKey pgpPublicKey) { try { return createProfile(name, pgpIdentifier, created, pgpFingerprint, pgpPublicKey.getEncoded()); } catch (IOException e) { throw new RuntimeException(e); } } public static Profile createProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { return new Profile(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData); } public static Profile createEmptyProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint) { return new Profile(name, pgpIdentifier, null, profileFingerprint, null); } private Profile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData) { this.name = sanitizeProfileName(name); this.pgpIdentifier = pgpIdentifier; this.created = created; this.profileFingerprint = profileFingerprint; this.pgpPublicKeyData = pgpPublicKeyData; } private static String sanitizeProfileName(String profileName) { var index = profileName.indexOf(" (Generated by"); if (index == -1) { index = profileName.indexOf(" (generated by"); // Workaround for some user who had this somehow } if (index > 0) { return profileName.substring(0, index); } return profileName; } public Profile updateWith(Profile other) { if (isPartial() && other.isComplete()) { setPgpPublicKeyData(other.getPgpPublicKeyData()); // Promote to full profile } Location.addOrUpdateLocations(this, other.getLocations().stream().findFirst().orElse(null)); return this; } public void addLocation(Location location) { location.setProfile(this); getLocations().add(location); } public long getId() { return id; } void setId(long id) { this.id = id; } @XmlAttribute public String getName() { return name; } void setName(String name) { this.name = name; } @XmlAttribute public long getPgpIdentifier() { return pgpIdentifier; } void setPgpIdentifier(long pgpIdentifier) { this.pgpIdentifier = pgpIdentifier; } public Instant getCreated() { return created; } public void setCreated(Instant created) { this.created = created; } public ProfileFingerprint getProfileFingerprint() { return profileFingerprint; } public void setProfileFingerprint(ProfileFingerprint profileFingerprint) { this.profileFingerprint = profileFingerprint; } @XmlAttribute public byte[] getPgpPublicKeyData() { return pgpPublicKeyData; } public void setPgpPublicKeyData(byte[] pgpPublicKeyData) { this.pgpPublicKeyData = pgpPublicKeyData; } public boolean isAccepted() { return accepted; } public void setAccepted(boolean accepted) { this.accepted = accepted; } @XmlAttribute public Trust getTrust() { return trust; } public void setTrust(Trust trust) { this.trust = trust; } @XmlElement(name = "location") public List getLocations() { return locations; } public static boolean isOwn(long id) { return id == OWN_PROFILE_ID; } public boolean isOwn() { return id == OWN_PROFILE_ID; } public boolean isComplete() { return pgpPublicKeyData != null; } public boolean isPartial() { return pgpPublicKeyData == null; } public boolean isConnected() { return getLocations().stream().anyMatch(Location::isConnected); } public Availability getBestAvailability() { return getLocations().stream() .filter(Location::isConnected) .min(Comparator.comparing(location -> location.getAvailability().ordinal())) .map(Location::getAvailability) .orElse(Availability.OFFLINE); } @Override public String toString() { return "Profile{" + "id=" + id + ", name='" + name + '\'' + ", pgpIdentifier=" + io.xeres.common.id.Id.toString(pgpIdentifier) + ", profileFingerprint=" + profileFingerprint + ", pgpPublicKeyData=" + new String(Hex.encode(pgpPublicKeyData)) + ", accepted=" + accepted + ", trust=" + trust + ", locations=" + locations + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/profile/ProfileMapper.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.profile; import io.xeres.app.database.model.location.LocationMapper; import io.xeres.common.dto.profile.ProfileDTO; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import java.util.ArrayList; import java.util.List; import static org.apache.commons.collections4.ListUtils.emptyIfNull; @SuppressWarnings("DuplicatedCode") public final class ProfileMapper { private ProfileMapper() { throw new UnsupportedOperationException("Utility class"); } public static ProfileDTO toDTO(Profile profile) { if (profile == null) { return null; } return new ProfileDTO( profile.getId(), profile.getName(), Long.toString(profile.getPgpIdentifier()), profile.getCreated(), profile.getProfileFingerprint().getBytes(), profile.getPgpPublicKeyData(), profile.isAccepted(), profile.getTrust(), new ArrayList<>()); } public static ProfileDTO toDeepDTO(Profile profile) { return toDeepDTO(profile, null); } public static ProfileDTO toDeepDTO(Profile profile, LocationIdentifier locationIdentifier) { if (profile == null) { return null; } var profileDTO = toDTO(profile); profileDTO.locations().addAll(profile.getLocations().stream() .sorted((o1, o2) -> { // Return the passed location identifier as first. We don't care about the rest if (locationIdentifier != null) { if (o1.getLocationIdentifier().equals(locationIdentifier)) { return -1; } else if (o2.getLocationIdentifier().equals(locationIdentifier)) { return 1; } } return 0; }) .map(LocationMapper::toDeepDTO) .toList()); return profileDTO; } public static List toDTOs(List profiles) { return emptyIfNull(profiles).stream() .map(ProfileMapper::toDTO) .toList(); } public static List toDeepDTOs(List profiles) { return emptyIfNull(profiles).stream() .map(ProfileMapper::toDeepDTO) .toList(); } public static Profile fromDTO(ProfileDTO dto) { if (dto == null) { return null; } var profile = new Profile(); profile.setId(dto.id()); profile.setName(dto.name()); profile.setPgpIdentifier(Long.parseLong(dto.pgpIdentifier())); profile.setCreated(dto.created()); profile.setProfileFingerprint(new ProfileFingerprint(dto.pgpFingerprint())); profile.setPgpPublicKeyData(dto.pgpPublicKeyData()); profile.setAccepted(dto.accepted()); profile.setTrust(dto.trust()); return profile; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/settings/Settings.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.settings; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlAttribute; @Entity @XmlAccessorType(XmlAccessType.NONE) public class Settings { @SuppressWarnings("unused") @Id private final byte lock = 1; private int version; // The following 5 should not be exposed by JSON. The mapper must ignore them. private byte[] pgpPrivateKeyData; private byte[] locationPrivateKeyData; private byte[] locationPublicKeyData; private byte[] locationCertificate; private int localPort; private String torSocksHost; private int torSocksPort; private String i2pSocksHost; private int i2pSocksPort; private boolean upnpEnabled; private boolean broadcastDiscoveryEnabled; private boolean dhtEnabled; private boolean autoStartEnabled; private String incomingDirectory; private String remotePassword; private boolean remoteEnabled; private boolean upnpRemoteEnabled; private int remotePort; public Settings() { } public int getVersion() { return version; } public void setVersion(int version) { this.version = version; } @XmlAttribute public byte[] getPgpPrivateKeyData() { return pgpPrivateKeyData; } public void setPgpPrivateKeyData(byte[] keyData) { pgpPrivateKeyData = keyData; } @XmlAttribute public byte[] getLocationPrivateKeyData() { return locationPrivateKeyData; } public void setLocationPrivateKeyData(byte[] keyData) { locationPrivateKeyData = keyData; } @XmlAttribute public byte[] getLocationPublicKeyData() { return locationPublicKeyData; } public void setLocationPublicKeyData(byte[] keyData) { locationPublicKeyData = keyData; } @XmlAttribute public byte[] getLocationCertificate() { return locationCertificate; } public void setLocationCertificate(byte[] certificate) { locationCertificate = certificate; } public boolean hasLocationCertificate() { return locationCertificate != null; } public String getTorSocksHost() { return torSocksHost; } public void setTorSocksHost(String torSocksHost) { this.torSocksHost = torSocksHost; } public int getTorSocksPort() { return torSocksPort; } public void setTorSocksPort(int torSocksPort) { this.torSocksPort = torSocksPort; } public String getI2pSocksHost() { return i2pSocksHost; } public void setI2pSocksHost(String i2pSocksHost) { this.i2pSocksHost = i2pSocksHost; } public int getI2pSocksPort() { return i2pSocksPort; } public void setI2pSocksPort(int i2pSocksPort) { this.i2pSocksPort = i2pSocksPort; } public boolean isUpnpEnabled() { return upnpEnabled; } public void setUpnpEnabled(boolean enabled) { upnpEnabled = enabled; } public boolean isBroadcastDiscoveryEnabled() { return broadcastDiscoveryEnabled; } public void setBroadcastDiscoveryEnabled(boolean enabled) { broadcastDiscoveryEnabled = enabled; } public boolean isDhtEnabled() { return dhtEnabled; } public void setDhtEnabled(boolean dhtEnabled) { this.dhtEnabled = dhtEnabled; } @XmlAttribute public int getLocalPort() { return localPort; } public void setLocalPort(int localPort) { this.localPort = localPort; } public boolean isAutoStartEnabled() { return autoStartEnabled; } public void setAutoStartEnabled(boolean autoStartEnabled) { this.autoStartEnabled = autoStartEnabled; } public String getIncomingDirectory() { return incomingDirectory; } public void setIncomingDirectory(String incomingDirectory) { this.incomingDirectory = incomingDirectory; } public String getRemotePassword() { return remotePassword; } public void setRemotePassword(String remotePassword) { this.remotePassword = remotePassword; } public boolean isRemoteEnabled() { return remoteEnabled; } public void setRemoteEnabled(boolean remoteEnabled) { this.remoteEnabled = remoteEnabled; } public boolean isUpnpRemoteEnabled() { return upnpRemoteEnabled; } public void setUpnpRemoteEnabled(boolean upnpRemoteEnabled) { this.upnpRemoteEnabled = upnpRemoteEnabled; } public int getRemotePort() { return remotePort; } public void setRemotePort(int remotePort) { this.remotePort = remotePort; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/settings/SettingsMapper.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.settings; import io.xeres.common.dto.settings.SettingsDTO; public final class SettingsMapper { private SettingsMapper() { throw new UnsupportedOperationException("Utility class"); } public static SettingsDTO toDTO(Settings settings) { if (settings == null) { return null; } return new SettingsDTO( settings.getTorSocksHost(), settings.getTorSocksPort(), settings.getI2pSocksHost(), settings.getI2pSocksPort(), settings.isUpnpEnabled(), settings.isBroadcastDiscoveryEnabled(), settings.isDhtEnabled(), settings.isAutoStartEnabled(), settings.getIncomingDirectory(), settings.getRemotePassword(), settings.isRemoteEnabled(), settings.isUpnpRemoteEnabled(), settings.getRemotePort() ); } public static Settings fromDTO(SettingsDTO dto) { if (dto == null) { return null; } var settings = new Settings(); settings.setTorSocksHost(dto.torSocksHost()); settings.setTorSocksPort(dto.torSocksPort()); settings.setI2pSocksHost(dto.i2pSocksHost()); settings.setI2pSocksPort(dto.i2pSocksPort()); settings.setUpnpEnabled(dto.upnpEnabled()); settings.setBroadcastDiscoveryEnabled(dto.broadcastDiscoveryEnabled()); settings.setDhtEnabled(dto.dhtEnabled()); settings.setAutoStartEnabled(dto.autoStartEnabled()); settings.setIncomingDirectory(dto.incomingDirectory()); settings.setRemotePassword(dto.remotePassword()); settings.setRemoteEnabled(dto.remoteEnabled()); settings.setUpnpRemoteEnabled(dto.upnpRemoteEnabled()); settings.setRemotePort(dto.remotePort()); return settings; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/share/Share.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.share; import io.xeres.app.database.model.file.File; import io.xeres.common.pgp.Trust; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.Instant; import static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MAX; import static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MIN; @Entity public class Share { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) @JoinColumn(name = "file_id", nullable = false) private File file; @NotNull @Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX) private String name; private boolean searchable; private Trust browsable = Trust.UNKNOWN; private Instant lastScanned = Instant.EPOCH; public static Share createShare(String name, File directory, boolean searchable, Trust browsable) { var share = new Share(); share.setName(name); share.setFile(directory); share.setSearchable(searchable); share.setBrowsable(browsable); return share; } protected Share() { } public long getId() { return id; } public void setId(long id) { this.id = id; } public File getFile() { return file; } public void setFile(File file) { this.file = file; } public String getName() { return name; } public void setName(String name) { this.name = name; } public boolean isSearchable() { return searchable; } public void setSearchable(boolean searchable) { this.searchable = searchable; } public Trust getBrowsable() { return browsable; } public void setBrowsable(Trust browsable) { this.browsable = browsable; } public Instant getLastScanned() { return lastScanned; } public void setLastScanned(Instant lastScanned) { this.lastScanned = lastScanned; } @Override public String toString() { return "Share{" + "id=" + id + ", file=" + file + ", name='" + name + '\'' + ", searchable=" + searchable + ", browsable=" + browsable + ", lastScanned=" + lastScanned + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/database/model/share/ShareMapper.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.share; import io.xeres.app.database.model.file.File; import io.xeres.common.dto.share.ShareDTO; import java.nio.file.Paths; import java.util.List; import java.util.Map; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class ShareMapper { private ShareMapper() { throw new UnsupportedOperationException("Utility class"); } public static ShareDTO toDTO(Share share, String path) { if (share == null) { return null; } return new ShareDTO( share.getId(), share.getName(), path, share.isSearchable(), share.getBrowsable(), share.getLastScanned() ); } public static List toDTOs(List shares, Map filesMap) { return emptyIfNull(shares).stream() .map(share -> toDTO(share, filesMap.get(share.getId()))) .toList(); } public static Share fromDTO(ShareDTO shareDTO) { if (shareDTO == null) { return null; } var share = new Share(); share.setId(shareDTO.id()); share.setName(shareDTO.name()); share.setFile(File.createFile(Paths.get(shareDTO.path()))); share.setSearchable(shareDTO.searchable()); share.setBrowsable(shareDTO.browsable()); share.setLastScanned(shareDTO.lastScanned()); return share; } public static List fromDTOs(List shares) { return shares.stream() .map(ShareMapper::fromDTO) .toList(); } } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/ChatBacklogRepository.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.chat.ChatBacklog; import io.xeres.app.database.model.location.Location; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; @Transactional(readOnly = true) public interface ChatBacklogRepository extends JpaRepository { List findAllByLocationAndCreatedAfterOrderByCreatedDesc(Location location, Instant from, Limit limit); @Transactional void deleteAllByCreatedBefore(Instant before); @Transactional void deleteAllByLocation(Location location); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/ChatRoomBacklogRepository.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.chat.ChatRoom; import io.xeres.app.database.model.chat.ChatRoomBacklog; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; @Transactional(readOnly = true) public interface ChatRoomBacklogRepository extends JpaRepository { List findAllByRoomAndCreatedAfterOrderByCreatedDesc(ChatRoom chatRoom, Instant from, Limit limit); @Transactional void deleteAllByCreatedBefore(Instant before); @Transactional void deleteAllByRoom(ChatRoom chatRoom); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/ChatRoomRepository.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.chat.ChatRoom; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface ChatRoomRepository extends JpaRepository { Optional findByRoomIdAndIdentityGroupItem(long roomId, IdentityGroupItem identityGroupItem); Optional findByRoomId(long roomId); // Should eventually go away when we have multiple identities but... not sure yet List findAllBySubscribedTrueAndJoinedFalse(); @Modifying @Transactional @Query("UPDATE ChatRoom c SET c.joined = false WHERE c.joined = true") void putAllJoinedToFalse(); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/DistantChatBacklogRepository.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.chat.DistantChatBacklog; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; @Transactional(readOnly = true) public interface DistantChatBacklogRepository extends JpaRepository { List findAllByIdentityGroupItemAndCreatedAfterOrderByCreatedDesc(IdentityGroupItem identityGroupItem, Instant from, Limit limit); @Transactional void deleteAllByCreatedBefore(Instant from); @Transactional void deleteAllByIdentityGroupItem(IdentityGroupItem identityGroupItem); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/FileDownloadRepository.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.file.FileDownload; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface FileDownloadRepository extends JpaRepository { Optional findByHash(Sha1Sum hash); List findAllByLocationIsNull(); List findAllByLocation(Location location); @Transactional void deleteAllByCompletedTrue(); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/FileRepository.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.file.File; import io.xeres.common.id.Sha1Sum; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface FileRepository extends JpaRepository { List findAllByName(String name); List findAllByNameContainingIgnoreCase(String name); Optional findByNameAndParent(String name, File parent); Optional findByNameAndParentName(String name, String parentName); int countByParent(File parent); List findByHash(Sha1Sum hash); List findByEncryptedHash(Sha1Sum encryptedHash); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsBoardGroupRepository.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.service.board.item.BoardGroupItem; import io.xeres.common.id.GxsId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface GxsBoardGroupRepository extends JpaRepository { Optional findByGxsId(GxsId gxsId); List findAllByGxsIdIn(Set gxsIds); List findAllBySubscribedIsTrue(); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsBoardMessageRepository.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface GxsBoardMessageRepository extends JpaRepository { Optional findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId); Page findAllByGxsIdAndHiddenFalse(GxsId gxsId, Pageable pageable); List findAllByGxsIdAndPublishedAfterAndHiddenFalse(GxsId gxsId, Instant since); List findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set msgIds); List findAllByGxsIdAndMsgIdInAndHiddenFalse(GxsId gxsId, Set msgIds); List findAllByMsgIdInAndHiddenFalse(Set msgIds); @Query("SELECT COUNT(m.id) FROM board_message m WHERE m.gxsId = :gxsId AND m.read = false AND m.hidden = false") int countUnreadMessages(GxsId gxsId); @Modifying @Transactional @Query("UPDATE board_message m SET m.read = :read WHERE m.gxsId = :gxsId AND m.read != :read") void setAllGroupMessagesReadState(GxsId gxsId, boolean read); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsChannelGroupRepository.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.service.channel.item.ChannelGroupItem; import io.xeres.common.id.GxsId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface GxsChannelGroupRepository extends JpaRepository { Optional findByGxsId(GxsId gxsId); List findAllByGxsIdIn(Set gxsIds); List findAllBySubscribedIsTrue(); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsChannelMessageRepository.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface GxsChannelMessageRepository extends JpaRepository { Optional findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId); Page findAllByGxsIdAndHiddenFalse(GxsId gxsId, Pageable pageable); List findAllByGxsIdAndPublishedAfterAndHiddenFalse(GxsId gxsId, Instant since); List findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set msgIds); List findAllByGxsIdAndMsgIdInAndHiddenFalse(GxsId gxsId, Set msgIds); List findAllByMsgIdInAndHiddenFalse(Set msgIds); @Query("SELECT COUNT(m.id) FROM channel_message m WHERE m.gxsId = :gxsId AND m.read = false AND m.hidden = false") int countUnreadMessages(GxsId gxsId); @Modifying @Transactional @Query("UPDATE channel_message m SET m.read = :read WHERE m.gxsId = :gxsId AND m.read != :read") void setAllGroupMessagesReadState(GxsId gxsId, boolean read); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsClientUpdateRepository.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.gxs.GxsClientUpdate; import io.xeres.app.database.model.location.Location; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @Transactional(readOnly = true) public interface GxsClientUpdateRepository extends JpaRepository { Optional findByLocationAndServiceType(Location location, int serviceType); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsCommentMessageRepository.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.common.CommentMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Set; @Transactional(readOnly = true) public interface GxsCommentMessageRepository extends JpaRepository { List findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set msgIds); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsForumGroupRepository.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.service.forum.item.ForumGroupItem; import io.xeres.common.id.GxsId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface GxsForumGroupRepository extends JpaRepository { Optional findByGxsId(GxsId gxsId); List findAllByGxsIdIn(Set gxsIds); List findAllBySubscribedIsTrue(); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsForumMessageRepository.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.forum.ForumMessageItemSummary; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface GxsForumMessageRepository extends JpaRepository { Optional findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId); List findAllByGxsIdAndPublishedAfterAndHiddenFalse(GxsId gxsId, Instant since); List findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set msgIds); List findAllByGxsIdAndMsgIdInAndHiddenFalse(GxsId gxsId, Set msgIds); Page findSummaryAllByGxsIdAndHiddenFalse(GxsId gxsId, Pageable pageable); List findAllByMsgIdInAndHiddenFalse(Set msgIds); List findAllByMsgIdInAndHiddenTrue(Set msgIds); @Query("SELECT COUNT(m.id) FROM forum_message m WHERE m.gxsId = :gxsId AND m.read = false AND m.hidden = false") int countUnreadMessages(GxsId gxsId); @Modifying @Transactional @Query("UPDATE forum_message m SET m.read = :read WHERE m.gxsId = :gxsId AND m.read != :read") void setAllGroupMessagesReadState(GxsId gxsId, boolean read); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsGroupItemRepository.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.common.id.GxsId; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface GxsGroupItemRepository extends JpaRepository { Optional findByGxsId(GxsId gxsId); Optional findByGxsIdAndSubscribedIsTrue(GxsId gxsId); List findByOrderByLastStatistics(Limit limit); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsIdentityRepository.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.identity.Type; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface GxsIdentityRepository extends JpaRepository { Optional findByGxsId(GxsId gxsId); List findAllByGxsIdIn(Set gxsIds); List findAllByName(String name); List findAllByType(Type type); List findAllBySubscribedIsTrue(); List findAllByNextValidationNotNullAndNextValidationBeforeOrderByNextValidationDesc(Instant now, Limit limit); List findAllByProfileId(long profileId); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsMessageItemRepository.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.Optional; @Transactional(readOnly = true) public interface GxsMessageItemRepository extends JpaRepository { Optional findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId); int countByGxsId(GxsId gxsId); /** * If messages are received out of order, it's possible that we receive a message that replace another (so nothing is done), then we receive that message afterwards. * We have to check for that our of order message and mark it as hidden. * * @param gxsId the message group * @param since since when to consider the messages */ @Modifying @Transactional @Query("UPDATE gxs_message m SET m.hidden = true WHERE m.gxsId = :gxsId AND m.hidden = false AND m.published >= :since AND EXISTS (SELECT 1 FROM gxs_message m2 WHERE m2.gxsId = :gxsId AND m2.msgId != m.msgId AND m2.originalMsgId = m.msgId)") void fixIntervalDuplicates(GxsId gxsId, Instant since); /** * Retroshare can branch from a message that is not the latest. We check if there exists another message with the same originalMsgId but with a * later published timestamp, if so, mark it as hidden because it's not the latest. * * @param gxsId the message group * @param since since when to consider the messages */ @Modifying @Transactional @Query("UPDATE gxs_message m SET m.hidden = true WHERE m.gxsId = :gxsId AND m.hidden = false AND m.published >= :since AND m.originalMsgId IS NOT NULL AND EXISTS (SELECT 1 FROM gxs_message m2 WHERE m2.gxsId = :gxsId AND m2.msgId != m.msgId AND m2.originalMsgId = m.originalMsgId AND m2.published > m.published)") void hideOldDuplicates(GxsId gxsId, Instant since); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsServiceSettingRepository.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.gxs.GxsServiceSetting; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) public interface GxsServiceSettingRepository extends JpaRepository { } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/GxsVoteMessageRepository.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.xrs.common.VoteMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Set; @Transactional(readOnly = true) public interface GxsVoteMessageRepository extends JpaRepository { List findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set msgIds); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/LocationRepository.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.LocationIdentifier; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface LocationRepository extends JpaRepository { Optional findByLocationIdentifier(LocationIdentifier locationIdentifier); Slice findAllByConnectedFalse(Pageable pageable); Slice findAllByConnectedFalseAndDhtTrue(Pageable pageable); List findAllByConnectedTrue(); @Modifying @Transactional @Query("UPDATE Location l SET l.connected = false WHERE l.connected = true") void putAllConnectedToFalse(); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/ProfileRepository.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.profile.Profile; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface ProfileRepository extends JpaRepository { Optional findByName(String name); List findAllByNameContaining(String name); Optional findByProfileFingerprint(ProfileFingerprint profileFingerprint); Optional findByPgpIdentifier(long pgpIdentifier); @Query("SELECT p FROM Profile p, IN(p.locations) l WHERE l.locationIdentifier = :locationIdentifier") Optional findProfileByLocationIdentifier(@Param("locationIdentifier") LocationIdentifier locationIdentifier); @Query("SELECT p FROM Profile p, IN(p.locations) l WHERE p.pgpIdentifier = :pgpIdentifier AND p.accepted = true AND p.pgpPublicKeyData is not null AND l.discoverable = true") Optional findDiscoverableProfileByPgpIdentifier(@Param("pgpIdentifier") long pgpIdentifier); @Query("SELECT p FROM Profile p, IN(p.locations) l WHERE p.pgpIdentifier IN (:ids) AND p.accepted = true AND p.pgpPublicKeyData is not null AND l.discoverable = true") List findAllDiscoverableProfilesByPgpIdentifiers(@Param("ids") Iterable ids); @Query("SELECT p FROM Profile p, IN(p.locations) l WHERE p.accepted = true AND p.pgpPublicKeyData is not null AND l.discoverable = true") List getAllDiscoverableProfiles(); @Query("SELECT p FROM Profile p WHERE p.pgpIdentifier IN (:ids) AND p.pgpPublicKeyData is not null") List findAllCompleteByPgpIdentifiers(@Param("ids") Iterable ids); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/SettingsRepository.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.settings.Settings; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) public interface SettingsRepository extends JpaRepository { @Modifying @Transactional @Query(value = "BACKUP TO :file", nativeQuery = true) void backupDatabase(@Param("file") String file); } ================================================ FILE: app/src/main/java/io/xeres/app/database/repository/ShareRepository.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.file.File; import io.xeres.app.database.model.share.Share; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; import java.util.Set; @Transactional(readOnly = true) public interface ShareRepository extends JpaRepository { Optional findByName(String name); Optional findShareByFileIdIn(Set fileId); Optional findShareByFile(File file); } ================================================ FILE: app/src/main/java/io/xeres/app/job/DhtFinderJob.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.job; import io.xeres.app.application.events.DhtNodeFoundEvent; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.dht.DhtService; import io.xeres.app.net.peer.bootstrap.PeerTcpClient; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.LocationService; import io.xeres.app.service.PeerService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; import static java.util.function.Predicate.not; /** * Finds users in the DHT. */ @Component public class DhtFinderJob { private static final Logger log = LoggerFactory.getLogger(DhtFinderJob.class); private static final int SIMULTANEOUS_DHT_LOOKUPS = 1; private final LocationService locationService; private final PeerService peerService; private final DhtService dhtService; private final PeerTcpClient peerTcpClient; private Slice locations; private int pageIndex; public DhtFinderJob(LocationService locationService, PeerService peerService, DhtService dhtService, PeerTcpClient peerTcpClient) { this.locationService = locationService; this.peerService = peerService; this.dhtService = dhtService; this.peerTcpClient = peerTcpClient; } /** * After 2 minutes of runtime (which should be enough to get the DHT going), try finding * unconnected hosts in the DHT, each after 15 seconds. */ @Scheduled(initialDelay = 120, fixedDelay = 15, timeUnit = TimeUnit.SECONDS) void checkDht() { if (JobUtils.canRun(peerService) && dhtService.isReady()) { findInDht(); } } private void findInDht() { locations = locationService.getUnconnectedLocationsWithDht(PageRequest.of(getPageIndex(), SIMULTANEOUS_DHT_LOOKUPS, Sort.by("lastConnected"))); locations.stream() .filter(not(Location::isOwn)) .forEach(location -> dhtService.search(location.getLocationIdentifier())); } @EventListener public void dhtNodeFoundEvent(DhtNodeFoundEvent event) { var peerAddress = PeerAddress.from(event.hostPort()); log.debug("Trying to connect to location identifier: {} using {} from DHT lookup", event.locationIdentifier(), event.hostPort()); // We don't update the connection table of the location here because there's no guarantee that the DHT node that answered is // the right one (could be fake), but discovery will be able to update it. peerTcpClient.connect(peerAddress); } private int getPageIndex() { if (locations == null || locations.isLast()) { pageIndex = 0; } else { pageIndex++; } return pageIndex; } } ================================================ FILE: app/src/main/java/io/xeres/app/job/FileIndexingJob.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.job; import io.xeres.app.service.PeerService; import io.xeres.app.service.file.FileService; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; @Component public class FileIndexingJob { private final PeerService peerService; private final FileService fileService; public FileIndexingJob(PeerService peerService, FileService fileService) { this.peerService = peerService; this.fileService = fileService; } @Scheduled(initialDelay = 60, fixedDelay = 30, timeUnit = TimeUnit.SECONDS) void checkFilesToIndex() { if (JobUtils.canRun(peerService)) { fileService.checkForSharesToScan(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/job/IdleDetectionJob.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.job; import io.xeres.app.service.PeerService; import io.xeres.app.xrs.service.status.IdleChecker; import io.xeres.app.xrs.service.status.StatusRsService; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; import static io.xeres.common.location.Availability.AVAILABLE; import static io.xeres.common.location.Availability.AWAY; /** * This job changes the status of the user to away or online depending on * if he's idle or not. */ @Component public class IdleDetectionJob { private static final long IDLE_TIME_MINUTES = 5; private final StatusRsService statusRsService; private final PeerService peerService; private final IdleChecker idleChecker; public IdleDetectionJob(StatusRsService statusRsService, PeerService peerService, IdleChecker idleChecker) { this.statusRsService = statusRsService; this.peerService = peerService; this.idleChecker = idleChecker; } @Scheduled(initialDelay = 5 * 60, fixedDelay = 5, timeUnit = TimeUnit.SECONDS) void checkIdle() { if (!JobUtils.canRun(peerService)) { return; } var idleTime = idleChecker.getIdleTime(); if (idleTime < TimeUnit.MINUTES.toSeconds(IDLE_TIME_MINUTES)) { statusRsService.changeAvailabilityAutomatically(AVAILABLE); } else { statusRsService.changeAvailabilityAutomatically(AWAY); } } } ================================================ FILE: app/src/main/java/io/xeres/app/job/JobUtils.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.job; import io.xeres.app.service.PeerService; import io.xeres.common.util.RemoteUtils; final class JobUtils { private JobUtils() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("RedundantIfStatement") static boolean canRun(PeerService peerService) { // Do not execute if we're only a remote UI client if (RemoteUtils.isRemoteUiClient()) { return false; } // Do not execute if there's no network or if we're shutting down if (!peerService.isRunning()) { return false; } return true; } } ================================================ FILE: app/src/main/java/io/xeres/app/job/PeerConnectionJob.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.job; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.bootstrap.PeerI2pClient; import io.xeres.app.net.peer.bootstrap.PeerTcpClient; import io.xeres.app.net.peer.bootstrap.PeerTorClient; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.LocationService; import io.xeres.app.service.PeerService; import io.xeres.common.properties.StartupProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.Comparator; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import static io.xeres.common.properties.StartupProperties.Property.SERVER_ONLY; /** * Handles automatic outgoing connections to peers. */ @Component public class PeerConnectionJob { private static final Logger log = LoggerFactory.getLogger(PeerConnectionJob.class); private static final int SIMULTANEOUS_CONNECTIONS = 10; // number of locations to connect at once private final LocationService locationService; private final PeerTcpClient peerTcpClient; private final PeerTorClient peerTorClient; private final PeerI2pClient peerI2pClient; private final PeerService peerService; public PeerConnectionJob(LocationService locationService, PeerTcpClient peerTcpClient, PeerTorClient peerTorClient, PeerI2pClient peerI2pClient, PeerService peerService) { this.locationService = locationService; this.peerTcpClient = peerTcpClient; this.peerTorClient = peerTorClient; this.peerI2pClient = peerI2pClient; this.peerService = peerService; } @Scheduled(initialDelay = 5, fixedDelay = 60, timeUnit = TimeUnit.SECONDS) void checkConnections() { connectToPeers(); } private boolean canRun() { // Also do not execute if we're in server mode (i.e. only accepting connections) return JobUtils.canRun(peerService) && !StartupProperties.getBoolean(SERVER_ONLY, false); } private void connectToPeers() { if (!canRun()) { return; } synchronized (PeerConnectionJob.class) { var connections = locationService.getConnectionsToConnectTo(SIMULTANEOUS_CONNECTIONS); for (var connection : connections) { connect(connection); } } } public void connectImmediately(Location location, int connectionIndex) { if (!canRun()) { return; } synchronized (PeerConnectionJob.class) { var connections = location.getConnections().stream() .sorted(Comparator.comparing(Connection::isExternal).reversed()) .toList(); if (!connections.isEmpty()) { if (connectionIndex == -1) { connect(connections.get(ThreadLocalRandom.current().nextInt(connections.size()))); } else if (connectionIndex < connections.size()) { connect(connections.get(connectionIndex)); } else { log.error("Connection index is out of bounds, size: {}, index: {}", connections.size(), connectionIndex); } } } } private void connect(Connection connection) { log.debug("Attempting to connect to {} ...", connection.getAddress()); var peerAddress = PeerAddress.fromAddress(connection.getAddress()); if (peerAddress.isValid()) { if (peerAddress.isHidden()) { switch (peerAddress.getType()) { case TOR -> peerTorClient.connect(peerAddress); case I2P -> peerI2pClient.connect(peerAddress); default -> throw new IllegalArgumentException("Wrong type " + peerAddress.getType() + " for hidden address"); } } else { peerTcpClient.connect(peerAddress); } } else { log.error("Automatic connection: invalid address for {}", connection.getAddress()); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/bdisc/BroadcastDiscoveryService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.bdisc; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.LocationService; import io.xeres.app.service.UiBridgeService; import io.xeres.common.util.ThreadUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.IOException; import java.net.*; import java.nio.ByteBuffer; import java.nio.channels.ClosedByInterruptException; import java.nio.channels.DatagramChannel; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.time.Duration; import java.time.Instant; import java.util.*; import static io.xeres.common.tray.TrayNotificationType.DISCOVERY; /** * This service periodically sends a UDP broadcast packet to find out * if other Retroshare clients are on the LAN. It implements more or * less the same protocol as found in the project udp-discovery-cpp * (which is what Retroshare uses). */ @Service public class BroadcastDiscoveryService implements Runnable { private static final Logger log = LoggerFactory.getLogger(BroadcastDiscoveryService.class); private static final int PORT = 36405; private static final int APP_ID = 904_571; private static final int BROADCAST_BUFFER_SEND_SIZE_MAX = 512; private static final int BROADCAST_BUFFER_RECV_SIZE = 512; private static final Duration LAST_SEEN_TIMEOUT = Duration.ofMinutes(1); private static final Duration BROADCAST_MAX_WAIT_TIME = Duration.ofSeconds(5); private enum State { BROADCASTING, WAITING, INTERRUPTED } private final DatabaseSessionManager databaseSessionManager; private final LocationService locationService; private final UiBridgeService uiBridgeService; private InetSocketAddress localAddress; private InetSocketAddress sendAddress; private Thread thread; private SocketAddress broadcastSocketAddress; private ByteBuffer sendBuffer; private ByteBuffer receiveBuffer; private State state; private Instant sent = Instant.EPOCH; private int ownPeerId; private final int counter = 1; private final Map peers = new HashMap<>(); public BroadcastDiscoveryService(DatabaseSessionManager databaseSessionManager, LocationService locationService, UiBridgeService uiBridgeService) { this.databaseSessionManager = databaseSessionManager; this.locationService = locationService; this.uiBridgeService = uiBridgeService; } public void start(String localIpAddress, int localPort) { log.info("Starting Broadcast Discovery service..."); localAddress = new InetSocketAddress(localIpAddress, localPort); thread = Thread.ofVirtual() .name("Broadcast Discovery Service") .start(this); } public void stop() { if (thread != null) { log.info("Stopping Broadcast Discovery..."); thread.interrupt(); } } public boolean isRunning() { return thread.isAlive(); } public void waitForTermination() { ThreadUtils.waitForThread(thread); } private static String getBroadcastAddress(String ipAddress) { List broadcastList = new ArrayList<>(); Iterator interfaces; try { interfaces = NetworkInterface.getNetworkInterfaces().asIterator(); while (interfaces.hasNext()) { var networkInterface = interfaces.next(); if (networkInterface.isUp() && !networkInterface.isLoopback()) { broadcastList.addAll(networkInterface.getInterfaceAddresses().stream() .filter(interfaceAddress -> interfaceAddress.getAddress().getHostAddress().equals(ipAddress)) .map(InterfaceAddress::getBroadcast) .filter(Objects::nonNull) .toList()); } } } catch (SocketException e) { log.error("Failed to get broadcast address: {}", e.getMessage(), e); return null; } if (broadcastList.isEmpty()) { return null; } return broadcastList.getFirst().getHostAddress(); } private void setupOwnInfo() { try (var ignored = new DatabaseSession(databaseSessionManager)) { var ownLocation = locationService.findOwnLocation().orElseThrow(); ownPeerId = ownLocation.getLocationIdentifier().hashCode(); sendBuffer = UdpDiscoveryProtocol.createPacket( BROADCAST_BUFFER_SEND_SIZE_MAX, UdpDiscoveryPeer.Status.PRESENT, APP_ID, ownPeerId, counter, ownLocation.getProfile().getProfileFingerprint(), ownLocation.getLocationIdentifier(), localAddress.getPort(), ownLocation.getProfile().getName()); sendBuffer.flip(); } } @SuppressWarnings("EmptyMethod") private void updateOwnInfo() { // For now, we do nothing; but we could implement something better if for // example there's a change of IP or port. Remember to increase the // counter for each update otherwise it won't be taken into account. // But see https://github.com/truvorskameikin/udp-discovery-cpp/issues/18 } @Override public void run() { var broadcastAddress = getBroadcastAddress(localAddress.getHostString()); if (broadcastAddress == null) { log.error("Couldn't get broadcast address, disabling broadcast discovery service"); return; } broadcastSocketAddress = new InetSocketAddress(broadcastAddress, PORT); receiveBuffer = ByteBuffer.allocate(BROADCAST_BUFFER_RECV_SIZE); setupOwnInfo(); try (var selector = Selector.open(); var receiveChannel = DatagramChannel.open(StandardProtocolFamily.INET) .setOption(StandardSocketOptions.SO_REUSEADDR, true) .bind(new InetSocketAddress(localAddress.getHostString(), PORT)); var sendChannel = DatagramChannel.open(StandardProtocolFamily.INET) .setOption(StandardSocketOptions.SO_BROADCAST, true) .bind(new InetSocketAddress(localAddress.getHostString(), 0)) ) { sendAddress = (InetSocketAddress) sendChannel.getLocalAddress(); receiveChannel.configureBlocking(false); receiveChannel.register(selector, SelectionKey.OP_READ); state = State.BROADCASTING; while (true) { if (state == State.BROADCASTING) { updateOwnInfo(); sendBroadcast(sendChannel); } selector.select(getSelectorTimeout()); if (Thread.interrupted()) { setState(State.INTERRUPTED); break; } handleSelection(selector); } } catch (ClosedByInterruptException _) { log.debug("Interrupted, bailing out..."); } catch (IOException e) { log.error("Error: ", e); } } private void handleSelection(Selector selector) { var selectedKeys = selector.selectedKeys().iterator(); if (!selectedKeys.hasNext()) { // This was a timeout setState(State.BROADCASTING); return; } while (selectedKeys.hasNext()) { try { var key = selectedKeys.next(); selectedKeys.remove(); if (!key.isValid()) { continue; } if (key.isReadable()) { read(key); } } catch (IOException e) { log.warn("Glitch, continuing...", e); } } // We're past the timeout so send again if (Duration.between(sent, Instant.now()).compareTo(BROADCAST_MAX_WAIT_TIME) > 0) { setState(State.BROADCASTING); } } private long getSelectorTimeout() { return switch (state) { case WAITING -> Math.max(BROADCAST_MAX_WAIT_TIME.toMillis() - Duration.between(sent, Instant.now()).toMillis(), 0L); default -> 0L; }; } private void setState(State newState) { state = newState; } private void read(SelectionKey key) throws IOException { assert state == State.WAITING; @SuppressWarnings("resource") var channel = (DatagramChannel) key.channel(); var peerAddress = (InetSocketAddress) channel.receive(receiveBuffer); receiveBuffer.flip(); if (!peerAddress.equals(sendAddress)) // ignore our own packets { try { var peer = UdpDiscoveryProtocol.parsePacket(receiveBuffer, peerAddress); log.debug("Got peer: {}", peer); var now = Instant.now(); if (isValidPeer(peer)) { var lastSeenPeer = peers.get(peer.getPeerId()); if (lastSeenPeer != null) { // If a client is missing for a minute, remove it if (lastSeenPeer.getLastSeen().plus(LAST_SEEN_TIMEOUT).isBefore(now)) { log.debug("Removing peer {} because it hasn't been seen for more than a minute", peer); peers.remove(peer.getPeerId()); } else { lastSeenPeer.setLastSeen(now); } } else { // Add. Currently, the protocol's packet index is always incremented so there can't be an optimization to see if there's new data, so we can't update. peers.put(peer.getPeerId(), peer); peer.setLastSeen(now); try (var ignored = new DatabaseSession(databaseSessionManager)) { log.debug("Trying to update friend's IP"); locationService.findLocationByLocationIdentifier(peer.getLocationIdentifier()).ifPresentOrElse(location -> { if (!location.isConnected()) { var lanConnection = Connection.from(PeerAddress.from(peer.getIpAddress(), peer.getLocalPort())); log.debug("Updating friend {} with ip {}", location, lanConnection); location.addConnection(lanConnection); } }, () -> uiBridgeService.showTrayNotification(DISCOVERY, "Detected client on LAN: " + peer.getProfileName() + " at " + peer.getIpAddress())); } } } } catch (RuntimeException e) { log.debug("Failed to parse packet: {}", e.getMessage()); } } receiveBuffer.clear(); } private void sendBroadcast(DatagramChannel channel) throws IOException { assert state == State.BROADCASTING; channel.send(sendBuffer, broadcastSocketAddress); sent = Instant.now(); setState(State.WAITING); sendBuffer.rewind(); } private boolean isValidPeer(UdpDiscoveryPeer peer) { return peer != null && peer.getAppId() == APP_ID && peer.getStatus() == UdpDiscoveryPeer.Status.PRESENT && peer.getPeerId() != ownPeerId; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/bdisc/ProtocolVersion.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.bdisc; enum ProtocolVersion { VERSION_0, VERSION_1, VERSION_2 } ================================================ FILE: app/src/main/java/io/xeres/app/net/bdisc/UdpDiscoveryPeer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.bdisc; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import java.time.Instant; public class UdpDiscoveryPeer { public enum Status { PRESENT, LEAVING // Not implemented. I don't see the point } private Status status; private int appId; private int peerId; private long packetIndex; private String ipAddress; private ProfileFingerprint fingerprint; private LocationIdentifier locationIdentifier; private int localPort; private String profileName; private Instant lastSeen; public Status getStatus() { return status; } public void setStatus(Status status) { this.status = status; } public int getAppId() { return appId; } public void setAppId(int appId) { this.appId = appId; } public int getPeerId() { return peerId; } public void setPeerId(int peerId) { this.peerId = peerId; } public long getPacketIndex() { return packetIndex; } public void setPacketIndex(long packetIndex) { this.packetIndex = packetIndex; } public String getIpAddress() { return ipAddress; } public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; } public ProfileFingerprint getFingerprint() { return fingerprint; } public void setFingerprint(ProfileFingerprint fingerprint) { this.fingerprint = fingerprint; } public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } public void setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; } public int getLocalPort() { return localPort; } public void setLocalPort(int localPort) { this.localPort = localPort; } public String getProfileName() { return profileName; } public void setProfileName(String profileName) { this.profileName = profileName; } public Instant getLastSeen() { return lastSeen; } public void setLastSeen(Instant lastSeen) { this.lastSeen = lastSeen; } @Override public String toString() { return "UdpDiscoveryPeer{" + "status=" + status + ", AppId=" + appId + ", peerId=" + peerId + ", packetIndex=" + packetIndex + ", fingerprint=" + fingerprint + ", locationIdentifier=" + locationIdentifier + ", ipAddress='" + ipAddress + '\'' + ", localPort=" + localPort + ", profileName='" + profileName + '\'' + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/bdisc/UdpDiscoveryProtocol.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.bdisc; import io.netty.buffer.Unpooled; import io.xeres.app.net.bdisc.UdpDiscoveryPeer.Status; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import static io.xeres.app.net.bdisc.ProtocolVersion.*; public final class UdpDiscoveryProtocol { private static final Logger log = LoggerFactory.getLogger(UdpDiscoveryProtocol.class); private static final int MAGIC_HEADER_OLD = 0x524e3655; // RN6U private static final int MAGIC_HEADER_VERSIONED = 0x534f3756; // SO7V private UdpDiscoveryProtocol() { throw new UnsupportedOperationException("Utility class"); } public static UdpDiscoveryPeer parsePacket(ByteBuffer buffer, InetSocketAddress peerAddress) { if (buffer.limit() < 29) { throw new IllegalArgumentException("Buffer is too small: " + buffer.limit()); } var magicHeader = buffer.getInt(); ProtocolVersion protocolVersion; switch (magicHeader) { case MAGIC_HEADER_OLD -> { buffer.get(); // reserved protocolVersion = VERSION_0; } case MAGIC_HEADER_VERSIONED -> { var versionNum = buffer.get(); if (versionNum > VERSION_2.ordinal()) { log.warn("Unsupported protocol version: {}", versionNum); return null; } protocolVersion = ProtocolVersion.values()[versionNum]; } default -> { log.warn("Unsupported magic header: {}", magicHeader); return null; } } buffer.get(); // reserved buffer.get(); // reserved buffer.get(); // reserved var peer = new UdpDiscoveryPeer(); peer.setIpAddress(peerAddress.getAddress().getHostAddress()); var packetStatusNum = buffer.get(); if (packetStatusNum > Status.LEAVING.ordinal()) { log.warn("Unknown packet status: {}", packetStatusNum); return null; } peer.setStatus(Status.values()[packetStatusNum]); peer.setAppId(buffer.getInt()); peer.setPeerId(buffer.getInt()); if (protocolVersion == VERSION_0) { peer.setPacketIndex(buffer.getInt()); buffer.get(); // packet index reset "overflow" flag } else { peer.setPacketIndex(buffer.getLong()); } int userDataSize = buffer.getShort(); if (protocolVersion == VERSION_0) { buffer.getShort(); // padding size } if (userDataSize > buffer.remaining()) { throw new IllegalArgumentException("Userdata size of " + userDataSize + " is too big (" + buffer.remaining() + " remaining)"); } var buf = Unpooled.wrappedBuffer(buffer); if (protocolVersion != VERSION_2) { peer.setFingerprint((ProfileFingerprint) Serializer.deserializeIdentifierWithSize(buf, ProfileFingerprint.class, ProfileFingerprint.V4_LENGTH)); } else { var fingerPrintSize = buffer.get(); switch (fingerPrintSize) { case ProfileFingerprint.V4_LENGTH -> peer.setFingerprint((ProfileFingerprint) Serializer.deserializeIdentifierWithSize(buf, ProfileFingerprint.class, ProfileFingerprint.V4_LENGTH)); case ProfileFingerprint.LENGTH -> peer.setFingerprint((ProfileFingerprint) Serializer.deserializeIdentifierWithSize(buf, ProfileFingerprint.class, ProfileFingerprint.LENGTH)); default -> throw new IllegalArgumentException("Unknown fingerprint size:" + fingerPrintSize); } } peer.setLocationIdentifier((LocationIdentifier) Serializer.deserializeIdentifier(buf, LocationIdentifier.class)); peer.setLocalPort(Serializer.deserializeShort(buf)); peer.setProfileName(Serializer.deserializeString(buf)); return peer; } public static ByteBuffer createPacket(int maxSize, Status status, int appId, int peerId, int counter, ProfileFingerprint fingerprint, LocationIdentifier locationIdentifier, int localPort, String profileName) { var buffer = ByteBuffer.allocate(maxSize); buffer.putInt(MAGIC_HEADER_VERSIONED); if (fingerprint.getLength() == ProfileFingerprint.LENGTH) { buffer.put((byte) VERSION_2.ordinal()); // protocol version } else if (fingerprint.getLength() == ProfileFingerprint.V4_LENGTH) { buffer.put((byte) VERSION_1.ordinal()); // protocol version } else { throw new IllegalArgumentException("Unknown fingerprint size:" + fingerprint.getLength()); } buffer.put((byte) 0); buffer.put((byte) 0); buffer.put((byte) 0); buffer.put((byte) status.ordinal()); buffer.putInt(appId); buffer.putInt(peerId); buffer.putLong(counter); var buf = Unpooled.buffer(); Serializer.serialize(buf, fingerprint, ProfileFingerprint.class); Serializer.serialize(buf, locationIdentifier, LocationIdentifier.class); Serializer.serialize(buf, (short) localPort); Serializer.serialize(buf, profileName); buffer.putShort((short) buf.writerIndex()); buffer.put(buf.nioBuffer()); buf.release(); return buffer; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/dht/DHTSpringLog.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.dht; import lbms.plugins.mldht.kad.DHT.LogLevel; import lbms.plugins.mldht.kad.DHTLogger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class DHTSpringLog implements DHTLogger { private static final Logger log = LoggerFactory.getLogger(DHTSpringLog.class); private static final String EXCEPTION_HEADING = "Exception : "; @Override public void log(String s, LogLevel logLevel) { switch (logLevel) { case Fatal -> log.error(s); case Error -> log.warn(s); case Info -> log.info(s); case Debug -> log.debug(s); case Verbose -> log.trace(s); } } @Override public void log(Throwable throwable, LogLevel logLevel) { switch (logLevel) { case Fatal -> log.error(EXCEPTION_HEADING, throwable); case Error -> log.warn(EXCEPTION_HEADING, throwable); case Info -> log.info(EXCEPTION_HEADING, throwable); case Debug -> log.debug(EXCEPTION_HEADING, throwable); case Verbose -> log.trace(EXCEPTION_HEADING, throwable); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/dht/DhtService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.dht; import io.xeres.app.application.events.DhtNodeFoundEvent; import io.xeres.app.configuration.DataDirConfiguration; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.HostPort; import io.xeres.common.protocol.ip.IP; import io.xeres.common.rest.notification.status.DhtInfo; import io.xeres.common.rest.notification.status.DhtStatus; import io.xeres.common.util.ByteUnitUtils; import lbms.plugins.mldht.DHTConfiguration; import lbms.plugins.mldht.kad.*; import lbms.plugins.mldht.kad.messages.MessageBase; import lbms.plugins.mldht.kad.tasks.NodeLookup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.InetAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.Collections; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import static lbms.plugins.mldht.kad.DHT.DHTtype.IPV4_DHT; import static lbms.plugins.mldht.kad.DHT.LogLevel.Fatal; @Service public class DhtService implements DHTStatusListener, DHTConfiguration, DHTStatsListener, DHT.IncomingMessageListener { private static final Logger log = LoggerFactory.getLogger(DhtService.class); private static final String DHT_DATA_DIR = "dht"; // That file name must not be changed as it's what mldht uses internally private static final String DHT_FILE_NAME = "baseID.config"; private static final Duration STATS_DELAY = Duration.ofMinutes(1); private DHT dht; private LocationIdentifier locationIdentifier; private int localPort; private Instant lastStats; private final Map searchedKeys = new ConcurrentHashMap<>(); private final AtomicBoolean isReady = new AtomicBoolean(); private final DataDirConfiguration dataDirConfiguration; private final ApplicationEventPublisher publisher; private final StatusNotificationService statusNotificationService; public DhtService(DataDirConfiguration dataDirConfiguration, ApplicationEventPublisher publisher, StatusNotificationService statusNotificationService) { this.dataDirConfiguration = dataDirConfiguration; this.publisher = publisher; this.statusNotificationService = statusNotificationService; } public void start(LocationIdentifier locationIdentifier, int localPort) { if (dht != null && dht.isRunning()) { return; } this.locationIdentifier = locationIdentifier; this.localPort = localPort; DHT.setLogger(new DHTSpringLog()); DHT.setLogLevel(Fatal); dht = new DHT(IPV4_DHT); dht.addStatusListener(this); dht.addStatsListener(this); dht.addIncomingMessageListener(this); lastStats = Instant.now(); try { dht.start(this); dht.bootstrap(); if (dht.getNode().getNumEntriesInRoutingTable() < 10) { addBootstrappingNodes(); // help the bootstrapping process, in case nothing resolves } dht.getServerManager().awaitActiveServer().get(); } catch (IOException | ExecutionException | InterruptedException | IllegalStateException e) { log.error("Error while setting up DHT: {}", e.getMessage()); dht.stop(); if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } } } public void stop() { if (dht != null && dht.isRunning()) { try { dht.stop(); } catch (RuntimeException e) { // Sometimes DHT fails to shut down cleanly, but // it shouldn't disrupt the rest of the shutdown // process. log.error("DHT error: {}", e.getMessage(), e); } } } public void search(LocationIdentifier locationIdentifier) { if (dht == null || !dht.isRunning()) { log.warn("Search is not available yet, DHT is not ready"); return; } var key = new Key(NodeId.create(locationIdentifier)); log.debug("Searching LocationIdentifier {} -> node id: {}", locationIdentifier, key); searchedKeys.put(key, locationIdentifier); var rpcServer = dht.getServerManager().getRandomActiveServer(false); if (rpcServer == null) { log.debug("No RPC server, cannot perform DHT search"); return; } var nodeLookupTask = new NodeLookup(key, rpcServer, dht.getNode(), false); nodeLookupTask.setInfo(locationIdentifier.toString()); nodeLookupTask.addListener(task -> log.debug("Task finished: {}", task.getInfo())); dht.getTaskManager().addTask(nodeLookupTask); } @Override public void statusChanged(DHTStatus newStatus, DHTStatus oldStatus) { switch (newStatus) { case Running -> { log.info("DHT status -> running"); isReady.set(true); statusNotificationService.setDhtInfo(DhtInfo.fromStatus(DhtStatus.RUNNING)); } case Stopped -> { log.info("DHT status -> stopped"); isReady.set(false); statusNotificationService.setDhtInfo(DhtInfo.fromStatus(DhtStatus.OFF)); } case Initializing -> { log.info("DHT status -> initializing"); isReady.set(false); statusNotificationService.setDhtInfo(DhtInfo.fromStatus(DhtStatus.INITIALIZING)); } } } @Override public boolean isPersistingID() { return true; } @Override public Path getStoragePath() { var directoryPath = Path.of(dataDirConfiguration.getDataDir(), DHT_DATA_DIR); var filePath = directoryPath.resolve(DHT_FILE_NAME); if (Files.notExists(directoryPath) || Files.notExists(filePath)) { try { Files.createDirectory(directoryPath); var nodeId = Id.toString(NodeId.create(locationIdentifier)).toUpperCase(Locale.ROOT); log.debug("Storing own NodeID: {}", nodeId); Files.createFile(filePath); Files.write(filePath, Collections.singleton(nodeId), StandardCharsets.ISO_8859_1); } catch (IOException e) { throw new IllegalStateException("Failed to create DHT data storage: " + e.getMessage(), e); } } return directoryPath; } @Override public int getListeningPort() { return localPort; } @Override public boolean noRouterBootstrap() { return false; } @Override public boolean allowMultiHoming() { return false; } @Override public Predicate filterBindAddress() { return address -> IP.isRoutableIp(address.getHostAddress()); } @Override public void statsUpdated(DHTStats dhtStats) { var now = Instant.now(); if (Duration.between(lastStats, now).compareTo(STATS_DELAY) > 0) { traceDhtStats(dhtStats); if (dht.getStatus() == DHTStatus.Running) { statusNotificationService.setDhtInfo(DhtInfo.fromStats( dhtStats.getNumPeers(), dhtStats.getNumReceivedPackets(), dhtStats.getRpcStats().getReceivedBytes(), dhtStats.getNumSentPackets(), dhtStats.getRpcStats().getSentBytes(), dhtStats.getDbStats().getKeyCount(), dhtStats.getDbStats().getItemCount())); } lastStats = now; } } @Override public void received(DHT dht, MessageBase messageBase) { if (messageBase.getType() == MessageBase.Type.RSP_MSG && messageBase.getMethod() == MessageBase.Method.FIND_NODE) { var foundLocationIdentifier = searchedKeys.remove(messageBase.getID()); if (foundLocationIdentifier != null) { log.debug("Found node for id {}, IP: {}", foundLocationIdentifier, messageBase.getOrigin()); publisher.publishEvent(new DhtNodeFoundEvent(foundLocationIdentifier, new HostPort(messageBase.getOrigin().getAddress().getHostAddress(), messageBase.getOrigin().getPort()))); } } } public boolean isReady() { return isReady.get(); } private void addBootstrappingNodes() { var line = ""; try (var reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(DhtService.class.getResourceAsStream("/bdboot.txt"))))) { while (reader.ready()) { line = reader.readLine(); var tokens = line.split(" "); var ip = tokens[0]; var port = Integer.parseInt(tokens[1]); if (!IP.isRoutableIp(ip)) { throw new IllegalArgumentException("IP is invalid"); } if (IP.isInvalidPort(port)) { throw new IllegalArgumentException("Port is invalid"); } log.debug("adding node {}:{}", ip, port); dht.addDHTNode(ip, port); } } catch (IOException | IllegalArgumentException e) { log.warn("Couldn't parse ipport of line: {} ({})", line, e.getMessage()); } } private static void traceDhtStats(DHTStats dhtStats) { if (log.isTraceEnabled()) { log.debug("Peers: {}, recv pkt: {} ({}), sent pkt: {} ({}), keys: {}, items: {}", dhtStats.getNumPeers(), dhtStats.getNumReceivedPackets(), ByteUnitUtils.fromBytes(dhtStats.getRpcStats().getReceivedBytes()), dhtStats.getNumSentPackets(), ByteUnitUtils.fromBytes(dhtStats.getRpcStats().getSentBytes()), dhtStats.getDbStats().getKeyCount(), dhtStats.getDbStats().getItemCount()); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/dht/NodeId.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.dht; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.common.id.LocationIdentifier; import java.nio.charset.StandardCharsets; final class NodeId { private static final String VERSION = "RS_VERSION_0.5.1\0"; // null terminator is included private NodeId() { throw new UnsupportedOperationException("Utility class"); } static byte[] create(LocationIdentifier locationIdentifier) { var md = new Sha1MessageDigest(); var version = VERSION.getBytes(StandardCharsets.US_ASCII); md.update(version); md.update(locationIdentifier.getBytes()); return md.getBytes(); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/dht/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * DHT implementation. Note: *

    *
  • RS uses the DHT of Bittorrent
  • *
  • it has some limitations regarding what can be put in the metadata
  • *
  • they want to switch to a better one at some point
  • *
*/ package io.xeres.app.net.dht; ================================================ FILE: app/src/main/java/io/xeres/app/net/external/ExternalIpResolver.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.external; import io.xeres.common.protocol.dns.DNS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.IOException; import java.net.InetAddress; import java.util.ArrayList; import java.util.Collections; import java.util.Map; /** * A service to find the external IP address. Currently, uses the DNS protocol. *

Note: this is only used if UPNPService fails to find it itself. */ @Service public class ExternalIpResolver { private static final Logger log = LoggerFactory.getLogger(ExternalIpResolver.class); private static final String OPENDNS_OWN_IP_HOST = "myip.opendns.com"; private static final String AKAMAI_OWN_IP_HOST = "whoami.akamai.net"; private static final Map RESOLVERS = Map.of( "208.67.222.222", OPENDNS_OWN_IP_HOST, // resolver1.opendns.com "208.67.220.220", OPENDNS_OWN_IP_HOST, // resolver2.opendns.com "208.67.222.220", OPENDNS_OWN_IP_HOST, // resolver3.opendns.com "208.67.220.222", OPENDNS_OWN_IP_HOST, // resolver4.opendns.com "193.108.88.1", AKAMAI_OWN_IP_HOST // ns1-1.akamaitech.net ); /** * Finds the external IP address. * * @return the IP address or null if not found */ public String find() { return findExternalIpAddressUsingDns(); } private String findExternalIpAddressUsingDns() { var keys = new ArrayList<>(RESOLVERS.keySet()); Collections.shuffle(keys); InetAddress externalIpAddress = null; for (String nameServer : keys) { try { externalIpAddress = DNS.resolve(RESOLVERS.get(nameServer), nameServer); } catch (IOException e) { // Log the error and try the next server log.error("Failed to resolve own IP using server {}: {}", nameServer, e.getMessage()); } } if (externalIpAddress == null) { return null; } return externalIpAddress.getHostAddress(); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/ConnectionType.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; public enum ConnectionType { TCP_INCOMING("incoming"), TCP_OUTGOING("outgoing"), TOR_OUTGOING("Tor"), I2P_OUTGOING("I2P"); private final String description; ConnectionType(String description) { this.description = description; } public String getDescription() { return description; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/DefaultItemFuture.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.GenericFutureListener; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; class DefaultItemFuture implements ItemFuture { private final Future future; private final int size; public DefaultItemFuture(Future future, int size) { this.future = future; this.size = size; } public DefaultItemFuture(Future future) { this.future = future; size = 0; } @Override public int getSize() { return size; } @Override public boolean isSuccess() { return future.isSuccess(); } @Override public boolean isCancellable() { return future.isCancellable(); } @Override public Throwable cause() { return future.cause(); } @Override public Future addListener(GenericFutureListener> genericFutureListener) { return future.addListener(genericFutureListener); } @SafeVarargs @Override public final Future addListeners(GenericFutureListener>... genericFutureListeners) { return future.addListeners(genericFutureListeners); } @Override public Future removeListener(GenericFutureListener> genericFutureListener) { return future.removeListener(genericFutureListener); } @SafeVarargs @Override public final Future removeListeners(GenericFutureListener>... genericFutureListeners) { return future.removeListeners(genericFutureListeners); } @Override public Future sync() throws InterruptedException { return future.sync(); } @Override public Future syncUninterruptibly() { return future.syncUninterruptibly(); } @Override public Future await() throws InterruptedException { return future.await(); } @Override public Future awaitUninterruptibly() { return future.awaitUninterruptibly(); } @Override public boolean await(long l, TimeUnit timeUnit) throws InterruptedException { return future.await(l, timeUnit); } @Override public boolean await(long l) throws InterruptedException { return future.await(l); } @Override public boolean awaitUninterruptibly(long l, TimeUnit timeUnit) { return future.awaitUninterruptibly(l, timeUnit); } @Override public boolean awaitUninterruptibly(long l) { return future.awaitUninterruptibly(l); } @Override public Void getNow() { return future.getNow(); } @Override public boolean cancel(boolean b) { return future.cancel(b); } @Override public boolean isCancelled() { return future.isCancelled(); } @Override public boolean isDone() { return future.isDone(); } @Override public Void get() throws InterruptedException, ExecutionException { return future.get(); } @Override public Void get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return future.get(timeout, unit); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/ItemFuture.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.util.concurrent.Future; public interface ItemFuture extends Future { /** * Gets the size of the item in its serialized form. * * @return the size of the item */ int getSize(); } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/PeerAttribute.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.util.AttributeKey; public final class PeerAttribute { public static final AttributeKey MULTI_PACKET = AttributeKey.valueOf("MULTI_PACKET"); public static final AttributeKey PEER_CONNECTION = AttributeKey.valueOf("PEER_CONNECTION"); private PeerAttribute() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/PeerConnection.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.channel.ChannelHandlerContext; import io.netty.util.concurrent.ScheduledFuture; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.service.RsService; import io.xeres.common.util.NoSuppressedRunnable; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.LongAdder; public class PeerConnection { /** * Gxs transaction ID (int). Must be incremented and unique for each new transaction. */ public static final int KEY_GXS_TRANSACTION_ID = 1; /** * The bandwidth advertised by the peer (long), in bytes/seconds. */ public static final int KEY_BANDWIDTH = 2; private Location location; private final ChannelHandlerContext ctx; private final Set services = new HashSet<>(); private final AtomicBoolean servicesSent = new AtomicBoolean(false); private final Map peerData = new HashMap<>(); private final Map> serviceData = new HashMap<>(); private final List> schedules = new ArrayList<>(); private final LongAdder sent = new LongAdder(); private final LongAdder received = new LongAdder(); public PeerConnection(Location location, ChannelHandlerContext ctx) { this.location = location; this.ctx = ctx; } public ChannelHandlerContext getCtx() { return ctx; } public Location getLocation() { return location; } public void updateLocation(Location location) { if (this.location.equals(location)) // Only update, don't change for another { this.location = location; } } public void addService(RsService service) { services.add(service); } public boolean isServiceSupported(RsService rsService) { return services.contains(rsService); } public boolean isServiceSupported(int serviceId) { return services.stream() .anyMatch(rsService -> rsService.getServiceType().getType() == serviceId); } public boolean canSendServices() { return servicesSent.compareAndSet(false, true); } /** * Puts data into a peer. * * @param key the key * @param data the data */ public void putPeerData(int key, Object data) { peerData.put(key, data); } /** * Gets data from a peer. * * @param key the key * @return the data */ public Optional getPeerData(int key) { return Optional.ofNullable(peerData.get(key)); } /** * Removes data from a peer. * * @param key the key */ public void removePeerData(int key) { peerData.remove(key); } /** * Adds data specific to a service. * * @param service the service to add data to * @param key the key * @param data the data */ public void putServiceData(RsService service, int key, Object data) { serviceData.computeIfAbsent(service.getServiceType().getType(), _ -> new HashMap<>()).put(key, data); } /** * Gets data specific to a service. * * @param service the service to get data from * @param key the key * @return the data or an empty optional if there was none */ public Optional getServiceData(RsService service, int key) { var serviceMap = serviceData.get(service.getServiceType().getType()); if (serviceMap == null) { return Optional.empty(); } return Optional.ofNullable(serviceMap.get(key)); } /** * Removes data associated with the service. * * @param service the service to remove data from * @param key the key */ public void removeServiceData(RsService service, int key) { var serviceMap = serviceData.get(service.getServiceType().getType()); if (serviceMap != null) { serviceMap.remove(key); } } public void scheduleAtFixedRate(NoSuppressedRunnable command, long initialDelay, long period, TimeUnit unit) { @SuppressWarnings("resource") var scheduledFuture = ctx.executor().scheduleAtFixedRate(command, initialDelay, period, unit); schedules.add(scheduledFuture); } public void scheduleWithFixedDelay(NoSuppressedRunnable command, long initialDelay, long delay, TimeUnit unit) { @SuppressWarnings("resource") var scheduledFuture = ctx.executor().scheduleWithFixedDelay(command, initialDelay, delay, unit); schedules.add(scheduledFuture); } /** * Schedules a one-shot command that becomes active after a defined delay. * * @param command the command to execute * @param delay the delay after which to execute the command * @param unit the unit of the delay */ public void schedule(NoSuppressedRunnable command, long delay, TimeUnit unit) { @SuppressWarnings("resource") var scheduledFuture = ctx.executor().schedule(command, delay, unit); schedules.add(scheduledFuture); } public void shutdown() { services.forEach(rsService -> rsService.shutdown(this)); } public void cleanup() { schedules.forEach(scheduledFuture -> scheduledFuture.cancel(false)); } public void incrementSentCounter(long value) { sent.add(value); } public void incrementReceivedCounter(long value) { received.add(value); } public long getSentCounter() { return sent.longValue(); } public long getReceivedCounter() { return received.longValue(); } public long getMaximumBandwidth() { return (long) getPeerData(KEY_BANDWIDTH).orElse(0L); } @Override public String toString() { return location + "@" + (ctx != null ? ctx.channel().remoteAddress() : ""); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/PeerConnectionManager.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.channel.ChannelHandlerContext; import io.netty.util.concurrent.FailedFuture; import io.xeres.app.application.events.PeerConnectedEvent; import io.xeres.app.application.events.PeerDisconnectedEvent; import io.xeres.app.database.model.location.Location; import io.xeres.app.service.notification.availability.AvailabilityNotificationService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.sliceprobe.item.SliceProbeItem; import io.xeres.common.location.Availability; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import java.util.EnumSet; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; import static io.xeres.app.net.peer.PeerAttribute.PEER_CONNECTION; /** * This component manages connected peers (addition, removals, writing data to them, etc...) */ @Component public class PeerConnectionManager { private static final Logger log = LoggerFactory.getLogger(PeerConnectionManager.class); private final StatusNotificationService statusNotificationService; private final AvailabilityNotificationService availabilityNotificationService; private final ApplicationEventPublisher publisher; private final Map peers = new ConcurrentHashMap<>(); PeerConnectionManager(StatusNotificationService statusNotificationService, AvailabilityNotificationService availabilityNotificationService, ApplicationEventPublisher publisher) { this.statusNotificationService = statusNotificationService; this.availabilityNotificationService = availabilityNotificationService; this.publisher = publisher; } /** * Adds a connected peer. * * @param location the location of the peer * @param ctx the context * @return a peer connection */ public PeerConnection addPeer(Location location, ChannelHandlerContext ctx) { var peerConnection = new PeerConnection(location, ctx); if (peers.put(location.getId(), peerConnection) != null) { throw new IllegalStateException("Location " + location + " added already"); } ctx.channel().attr(PEER_CONNECTION).set(peerConnection); availabilityNotificationService.changeAvailability(location, Availability.AVAILABLE); updateCurrentUsersCount(); publisher.publishEvent(new PeerConnectedEvent(location.getLocationIdentifier())); return peerConnection; } /** * Removes a peer because it disconnected. * * @param location the location of the peer */ public void removePeer(Location location) { if (peers.remove(location.getId()) == null) { throw new IllegalStateException("Location " + location + " is not in the list of peers"); } availabilityNotificationService.changeAvailability(location, Availability.OFFLINE); updateCurrentUsersCount(); publisher.publishEvent(new PeerDisconnectedEvent(location.getId(), location.getLocationIdentifier())); } /** * Gets a peer by its location id * * @param id the id of the location * @return the peer connection */ public PeerConnection getPeerByLocation(long id) { return peers.get(id); } /** * Gets a random peer. * * @return a random peer */ public synchronized PeerConnection getRandomPeer() { var size = peers.size(); return peers.values().stream() .skip(size > 0 ? ThreadLocalRandom.current().nextInt(size) : 0) .findFirst().orElse(null); } public void shutdown() { peers.forEach((_, peerConnection) -> peerConnection.shutdown()); availabilityNotificationService.shutdown(); } /** * Writes an item to a location. * * @param location the target location * @param item the item to write * @param rsService the service concerned * @return an ItemFuture containing the item's write state and item serialized state */ public ItemFuture writeItem(Location location, Item item, RsService rsService) { var peer = peers.get(location.getId()); if (peer != null) { return setOutgoingAndWriteItem(peer, item, rsService); } return new DefaultItemFuture(new FailedFuture<>(null, new IllegalStateException("Peer with connection " + location + " not found while trying to write item. User disconnected?"))); } /** * Writes an item to a peer. * * @param peerConnection the target peer * @param item the item to write * @param rsService the service concerned * @return an ItemFuture containing the item's write state and item serialized state */ public ItemFuture writeItem(PeerConnection peerConnection, Item item, RsService rsService) { var peer = peers.get(peerConnection.getLocation().getId()); if (peer != null) { return setOutgoingAndWriteItem(peer, item, rsService); } return new DefaultItemFuture(new FailedFuture<>(null, new IllegalStateException("Peer with connection " + peerConnection.getLocation() + " not found while trying to write item. User disconnected?"))); } /** * Executes an action for all peers. * * @param action the action to execute * @param rsService the service that has to be enabled for the peer as well. Can be null, in that case, all peers are considered for the action regardless of the service they're running */ public void doForAllPeers(Consumer action, RsService rsService) { peers.forEach((_, peerConnection) -> { if (rsService == null || peerConnection.isServiceSupported(rsService)) { action.accept(peerConnection); } }); } /** * Executes an action for all peers except the originator. * * @param action the action to execute * @param sender the originator of the action * @param rsService the service that has to be enabled for the peer as well. Can be null; in that case, all peers are considered for the action regardless of the service they're running */ public void doForAllPeersExceptSender(Consumer action, PeerConnection sender, RsService rsService) { peers.values().stream() .filter(peerConnection -> !peerConnection.equals(sender)) .filter(peerConnection -> rsService == null || peerConnection.isServiceSupported(rsService)) .forEach(action); } public boolean isServiceSupported(Location location, int serviceId) { var peer = peers.get(location.getId()); if (peer != null) { return peer.isServiceSupported(serviceId); } return false; } /** * Writes the slice probe item. This is only needed for very particular cases. * * @param ctx the context */ public static void writeSliceProbe(ChannelHandlerContext ctx) { var item = SliceProbeItem.from(ctx); var rawItem = item.serializeItem(EnumSet.noneOf(SerializationFlags.class)); ctx.writeAndFlush(rawItem); } /** * Returns the number of connected peers. * * @return the number of connected peers */ public int getNumberOfPeers() { return peers.size(); } private static ItemFuture setOutgoingAndWriteItem(PeerConnection peerConnection, Item item, RsService rsService) { item.setOutgoing(peerConnection.getCtx().alloc(), rsService); return writeItem(peerConnection, item); } private static ItemFuture writeItem(PeerConnection peerConnection, Item item) { var rawItem = item.serializeItem(EnumSet.noneOf(SerializationFlags.class)); var size = rawItem.getSize(); // get it before it's written log.debug("==> {}", item); log.trace("Message content: {}", rawItem); peerConnection.incrementSentCounter(size); return new DefaultItemFuture(peerConnection.getCtx().writeAndFlush(rawItem), size); } private void updateCurrentUsersCount() { statusNotificationService.setCurrentUsersCount(getNumberOfPeers()); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.bootstrap; import io.netty.bootstrap.Bootstrap; import io.netty.channel.EventLoopGroup; import io.netty.channel.MultiThreadIoEventLoopGroup; import io.netty.channel.nio.NioIoHandler; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.resolver.AddressResolverGroup; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import io.xeres.common.properties.StartupProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.SocketAddress; import static io.xeres.common.properties.StartupProperties.Property.FAST_SHUTDOWN; abstract class PeerClient { @SuppressWarnings("NonConstantLogger") protected final Logger log = LoggerFactory.getLogger(getClass().getName()); protected final SettingsService settingsService; protected final NetworkProperties networkProperties; protected final ProfileService profileService; protected final LocationService locationService; protected final PeerConnectionManager peerConnectionManager; protected final DatabaseSessionManager databaseSessionManager; protected final ServiceInfoRsService serviceInfoRsService; protected final UiBridgeService uiBridgeService; protected final RsServiceRegistry rsServiceRegistry; private Bootstrap bootstrap; private EventLoopGroup group; public abstract PeerInitializer getPeerInitializer(); public abstract AddressResolverGroup getAddressResolverGroup(); protected PeerClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { this.settingsService = settingsService; this.networkProperties = networkProperties; this.profileService = profileService; this.locationService = locationService; this.peerConnectionManager = peerConnectionManager; this.databaseSessionManager = databaseSessionManager; this.serviceInfoRsService = serviceInfoRsService; this.uiBridgeService = uiBridgeService; this.rsServiceRegistry = rsServiceRegistry; } public void start() { log.info("Starting peer client..."); group = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); bootstrap = new Bootstrap(); setAddressResolver(); bootstrap.group(group) .channel(NioSocketChannel.class) .handler(getPeerInitializer()); } private void setAddressResolver() { var addressResolverGroup = getAddressResolverGroup(); if (addressResolverGroup != null) { bootstrap.resolver(addressResolverGroup); } } public void stop() { if (group == null) { return; } if (StartupProperties.getBoolean(FAST_SHUTDOWN, false)) { log.debug("Shutting down peer client (fast)..."); group.shutdownGracefully(); } else { log.info("Shutting down peer client..."); try { group.shutdownGracefully().sync(); } catch (InterruptedException e) { log.error("Error while shutting down peer client: {}", e.getMessage()); Thread.currentThread().interrupt(); } } } public void connect(PeerAddress peerAddress) { if (group != null) { bootstrap.connect(peerAddress.getSocketAddress()); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerI2pClient.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.bootstrap; import io.netty.resolver.AddressResolverGroup; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import org.springframework.stereotype.Component; import java.net.SocketAddress; import static io.xeres.app.net.peer.ConnectionType.I2P_OUTGOING; @Component public class PeerI2pClient extends PeerClient { public PeerI2pClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { super(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry); } @Override public PeerInitializer getPeerInitializer() { return new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, I2P_OUTGOING, profileService, uiBridgeService, rsServiceRegistry); } @Override public AddressResolverGroup getAddressResolverGroup() { return null; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerInitializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.bootstrap; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelInitializer; import io.netty.channel.socket.SocketChannel; import io.netty.handler.proxy.Socks5ProxyHandler; import io.netty.handler.ssl.SslContext; import io.netty.handler.timeout.IdleStateHandler; import io.xeres.app.crypto.x509.X509; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.ConnectionType; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.net.peer.pipeline.*; import io.xeres.app.net.peer.ssl.SSL; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import javax.net.ssl.SSLException; import java.net.InetSocketAddress; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.spec.InvalidKeySpecException; import java.time.Duration; import static io.xeres.app.net.peer.ConnectionType.I2P_OUTGOING; import static io.xeres.app.net.peer.ConnectionType.TOR_OUTGOING; public class PeerInitializer extends ChannelInitializer { public static final Duration PEER_IDLE_TIMEOUT = Duration.ofMinutes(2); /* peers not responding during that time are considered dead */ public static final Duration ACTIVITY_PROD = Duration.ofMinutes(1); /* if idle, sends a prod activity after that time */ private final SslContext sslContext; private final ConnectionType connectionType; private final SettingsService settingsService; private final NetworkProperties networkProperties; private final ProfileService profileService; private final LocationService locationService; private final PeerConnectionManager peerConnectionManager; private final DatabaseSessionManager databaseSessionManager; private final ServiceInfoRsService serviceInfoRsService; private final UiBridgeService uiBridgeService; private final RsServiceRegistry rsServiceRegistry; private static final ChannelHandler SIMPLE_PACKET_ENCODER = new SimplePacketEncoder(); private static final ChannelHandler ITEM_ENCODER = new ItemEncoder(); private static final ChannelHandler IDLE_EVENT_HANDLER = new IdleEventHandler(PEER_IDLE_TIMEOUT); public PeerInitializer(PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, LocationService locationService, SettingsService settingsService, NetworkProperties networkProperties, ServiceInfoRsService serviceInfoRsService, ConnectionType connectionType, ProfileService profileService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { this.settingsService = settingsService; this.profileService = profileService; this.uiBridgeService = uiBridgeService; try { sslContext = SSL.createSslContext(settingsService.getLocationPrivateKeyData(), X509.getCertificate(settingsService.getLocationCertificate()), connectionType); } catch (SSLException | NoSuchAlgorithmException | InvalidKeySpecException | CertificateException e) { throw new IllegalStateException("Error setting up PeerClient: " + e.getMessage(), e); } this.networkProperties = networkProperties; this.serviceInfoRsService = serviceInfoRsService; this.rsServiceRegistry = rsServiceRegistry; this.locationService = locationService; this.peerConnectionManager = peerConnectionManager; this.databaseSessionManager = databaseSessionManager; this.connectionType = connectionType; } @Override protected void initChannel(SocketChannel channel) { var pipeline = channel.pipeline(); // Build the pipeline in order. // Inbound // vvvvvvv // add SOCKS5 connection if Tor or I2P if (connectionType == TOR_OUTGOING && settingsService.hasTorSocksConfigured()) { var hostPort = settingsService.getTorSocksHostPort(); pipeline.addLast(new Socks5ProxyHandler(new InetSocketAddress(hostPort.host(), hostPort.port()))); } else if (connectionType == I2P_OUTGOING && settingsService.hasI2pSocksConfigured()) { var hostPort = settingsService.getI2pSocksHostPort(); pipeline.addLast(new Socks5ProxyHandler(new InetSocketAddress(hostPort.host(), hostPort.port()))); } // add SSL to encrypt and decrypt everything pipeline.addLast(sslContext.newHandler(channel.alloc())); // decoder (inbound) pipeline.addLast(new PacketDecoder()); pipeline.addLast(new ItemDecoder()); // encoder (outbound) pipeline.addLast(networkProperties.isPacketSlicing() ? new MultiPacketEncoder() : SIMPLE_PACKET_ENCODER); pipeline.addLast(ITEM_ENCODER); // business logic pipeline.addLast(new IdleStateHandler((int) PEER_IDLE_TIMEOUT.toSeconds(), (int) ACTIVITY_PROD.toSeconds(), 0)); pipeline.addLast(IDLE_EVENT_HANDLER); // ^^^^^^^^ // Outbound pipeline.addLast(new PeerHandler(profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, connectionType, uiBridgeService, rsServiceRegistry)); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerServer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.bootstrap; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.MultiThreadIoEventLoopGroup; import io.netty.channel.nio.NioIoHandler; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.logging.LogLevel; import io.netty.handler.logging.LoggingHandler; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import io.xeres.common.properties.StartupProperties; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.net.peer.ConnectionType.TCP_INCOMING; import static io.xeres.common.properties.StartupProperties.Property.FAST_SHUTDOWN; abstract class PeerServer { @SuppressWarnings("NonConstantLogger") protected final Logger log = LoggerFactory.getLogger(getClass().getName()); private final SettingsService settingsService; private final NetworkProperties networkProperties; private final ProfileService profileService; private final LocationService locationService; private final PeerConnectionManager peerConnectionManager; private final DatabaseSessionManager databaseSessionManager; private final ServiceInfoRsService serviceInfoRsService; private final UiBridgeService uiBridgeService; private final RsServiceRegistry rsServiceRegistry; private EventLoopGroup bossGroup; private EventLoopGroup workerGroup; private ChannelFuture channel; protected PeerServer(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { this.settingsService = settingsService; this.networkProperties = networkProperties; this.profileService = profileService; this.locationService = locationService; this.peerConnectionManager = peerConnectionManager; this.databaseSessionManager = databaseSessionManager; this.serviceInfoRsService = serviceInfoRsService; this.uiBridgeService = uiBridgeService; this.rsServiceRegistry = rsServiceRegistry; } public void start(String host, int localPort) { var factory = NioIoHandler.newFactory(); bossGroup = new MultiThreadIoEventLoopGroup(1, factory); workerGroup = new MultiThreadIoEventLoopGroup(factory); try { var serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) // should be more .option(ChannelOption.SO_REUSEADDR, true) .handler(new LoggingHandler(LogLevel.DEBUG)) .childHandler(new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, TCP_INCOMING, profileService, uiBridgeService, rsServiceRegistry)); channel = StringUtils.isBlank(host) ? serverBootstrap.bind(localPort).sync() : serverBootstrap.bind(host, localPort).sync(); log.info("Listening on {}, port {}", channel.channel().localAddress(), localPort); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException("Interrupted: " + e.getMessage(), e); } } public void stop() { if (channel == null) { return; } if (StartupProperties.getBoolean(FAST_SHUTDOWN, false)) { log.debug("Shutting down peer server (fast)..."); workerGroup.shutdownGracefully(); bossGroup.shutdownGracefully(); } else { log.info("Shutting down peer server..."); try { workerGroup.shutdownGracefully().sync(); bossGroup.shutdownGracefully().sync(); } catch (InterruptedException e) { log.error("Error while shutting down peer server: {}", e.getMessage()); Thread.currentThread().interrupt(); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerTcpClient.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.bootstrap; import io.netty.resolver.AddressResolverGroup; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import org.springframework.stereotype.Component; import java.net.SocketAddress; import static io.xeres.app.net.peer.ConnectionType.TCP_OUTGOING; @Component public class PeerTcpClient extends PeerClient { public PeerTcpClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { super(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry); } @Override public PeerInitializer getPeerInitializer() { return new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, TCP_OUTGOING, profileService, uiBridgeService, rsServiceRegistry); } @Override public AddressResolverGroup getAddressResolverGroup() { return null; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerTcpServer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.bootstrap; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import org.springframework.stereotype.Component; @Component public class PeerTcpServer extends PeerServer { public PeerTcpServer(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { super(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerTorClient.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.bootstrap; import io.netty.resolver.AddressResolverGroup; import io.netty.resolver.NoopAddressResolverGroup; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import org.springframework.stereotype.Component; import java.net.SocketAddress; import static io.xeres.app.net.peer.ConnectionType.TOR_OUTGOING; @Component public class PeerTorClient extends PeerClient { public PeerTorClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { super(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry); } @Override public PeerInitializer getPeerInitializer() { return new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, TOR_OUTGOING, profileService, uiBridgeService, rsServiceRegistry); } @Override public AddressResolverGroup getAddressResolverGroup() { return NoopAddressResolverGroup.INSTANCE; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/packet/MultiPacket.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.packet; import io.netty.buffer.ByteBuf; import java.net.ProtocolException; /** * This packet supports slicing and grouping for a more efficient * transmission over an SSL link. */ public class MultiPacket extends Packet { /** * Maximum packet ID. Wraps around. */ public static final int MAXIMUM_ID = 16_777_216; /** * Flag set for starting packets and full packets * in the new format. */ public static final int SLICE_FLAG_START = 1; /** * Flag set for ending packets and full packets * in the new format. */ public static final int SLICE_FLAG_END = 2; private static final int HEADER_VERSION_INDEX = 0; private static final int HEADER_FLAG_INDEX = 1; private static final int HEADER_PACKET_ID_INDEX = 2; public static final int HEADER_SIZE_INDEX = 6; protected static boolean isNewPacket(ByteBuf in) throws ProtocolException { if (in.getUnsignedByte(HEADER_VERSION_INDEX) == SLICE_PROTOCOL_VERSION_ID_01) { var id = (int) in.getUnsignedInt(HEADER_PACKET_ID_INDEX); if (id >= MAXIMUM_ID || id < 0) { throw new ProtocolException("Illegal packet id (" + id + ")"); } return true; } return false; } protected MultiPacket(ByteBuf in) { buf = in.retain(); } public void setStart() { addFlags(SLICE_FLAG_START); } public boolean isStart() { return (getFlags() & SLICE_FLAG_START) == SLICE_FLAG_START; } public void setEnd() { addFlags(SLICE_FLAG_END); } public boolean isEnd() { return (getFlags() & SLICE_FLAG_END) == SLICE_FLAG_END; } public boolean isMiddle() { return !(isStart() || isEnd()); } public boolean isSlice() { return !isComplete(); } public void setId(int id) { buf.setInt(HEADER_PACKET_ID_INDEX, id); } public int getId() { return (int) buf.getUnsignedInt(HEADER_PACKET_ID_INDEX); } @Override public int getSize() { return buf.getUnsignedShort(HEADER_SIZE_INDEX); } @Override public ByteBuf getItemBuffer() { return getBuffer().slice(HEADER_SIZE, getSize()); } private int getFlags() { return buf.getUnsignedByte(HEADER_FLAG_INDEX); } private void addFlags(int newFlags) { int currentFlags = buf.getUnsignedByte(HEADER_FLAG_INDEX); currentFlags |= newFlags; buf.setByte(HEADER_FLAG_INDEX, currentFlags); } private void removeFlags(int newFlags) { int currentFlags = buf.getUnsignedByte(HEADER_FLAG_INDEX); currentFlags &= ~newFlags; buf.setByte(HEADER_FLAG_INDEX, currentFlags); } @Override public boolean isComplete() { return isStart() && isEnd(); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/packet/Packet.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.packet; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.item.RawItem; import java.net.ProtocolException; import java.util.Objects; public abstract class Packet implements Comparable { /** * Version of the packet protocol with slicing and grouping support. * Also referred as new format. */ public static final int SLICE_PROTOCOL_VERSION_ID_01 = 16; /** * Size of the header. Same for both packet protocols. */ public static final int HEADER_SIZE = 8; /** * Optimal packet size for the new format. It fits better * in the SSL encapsulation. */ public static final int OPTIMAL_PACKET_SIZE = 512; /** * The maximum packet size, which is the buffer size per connection * used by Retroshare, actually. */ public static final int MAXIMUM_PACKET_SIZE = 262_143; protected int priority = 3; private int sequence; protected ByteBuf buf; public static Packet fromItem(RawItem rawItem) { Packet packet; //if (rawItem.getPacketVersion() == 2) // this handles slice prods, which HAVE to use the old format, for now //{ // packet = new SimplePacket(rawItem.getBuffer()); //} //return new MultiPacket(item.getBuffer()); // XXX: when the encoder is ready packet = new SimplePacket(rawItem.getBuffer()); packet.setPriority(rawItem.getPriority()); return packet; } public static Packet fromBuffer(ByteBuf in) throws ProtocolException { return MultiPacket.isNewPacket(in) ? new MultiPacket(in) : new SimplePacket(in); } protected Packet() { } public boolean isMulti() { return this instanceof MultiPacket; } public abstract boolean isComplete(); public abstract int getSize(); public abstract ByteBuf getItemBuffer(); void setBuffer(ByteBuf buf) // XXX: for tests... check if it works well enough { this.buf = buf; } public ByteBuf getBuffer() { return buf; } public int getPriority() { return priority; } public void setPriority(int priority) { this.priority = priority; } public boolean isRealtimePriority() { return priority == 9; // XXX: make it nicer } public void setSequence(int sequence) // XXX: possibly in new packets only. not sure though { this.sequence = sequence; } public void dispose() { buf.release(); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var packet = (Packet) o; return priority == packet.priority && sequence == packet.sequence && buf.equals(packet.buf); } @Override public int hashCode() { return Objects.hash(priority, sequence, buf); } @Override public int compareTo(Packet o) { var res = getPriority() - o.getPriority(); if (res == 0 && o != this) { res = o.sequence - sequence; } return res; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/packet/SimplePacket.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.packet; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import java.util.List; /** * This is the old packet format of RS. It is still * used by RS in some cases (for example, transmission of a single small packet). */ public class SimplePacket extends Packet { public static final int HEADER_SIZE_INDEX = 4; protected SimplePacket(ByteBuf in) { buf = in.retain(); } public SimplePacket(ChannelHandlerContext ctx, List packets) { priority = packets.stream().findFirst().orElseThrow().getPriority(); buf = ctx.alloc().buffer(); packets.forEach(packet -> { buf.writeBytes(packet.getBuffer(), HEADER_SIZE, packet.getSize()); packet.dispose(); }); } @Override public int getSize() { return (int) buf.getUnsignedInt(HEADER_SIZE_INDEX); } @Override public ByteBuf getItemBuffer() { return getBuffer(); } @Override public boolean isComplete() { return true; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/packet/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * Packet format for sending and receiving data. *

There are 2 header formats for Packets: *

Old (describing a SimplePacket):
*

 * +---------+---------+-----------+--------------------------------------+
 * | version | service | subpacket | size, including header of 8 bytes    |
 * +---------+---------+-----------+--------------------------------------+
 * | 1 byte  | 2 bytes |   1 byte  |                4 bytes               |
 * +---------+---------+-----------+--------------------------------------+
 * 
*

New (describing a MultiPacket, version is always 16):
*

 * +---------+---------+------------+--------------------------------------+
 * | version |  flags  | packet id  | size, excluding header of 8 bytes    |
 * +---------+---------+------------+--------------------------------------+
 * | 1 byte  | 1 byte  |   4 bytes  |               2 bytes                |
 * +---------+---------+------------+--------------------------------------+
 * 
*

* Checking the protocol version (16) is enough to know if it's a new packet format. The simple packet * format is just the Item. The multi packet format is basically the slicing header and the Item as data. It * allows grouping and slicing to fit better into 512 bytes long data packets. */ package io.xeres.app.net.peer.packet; ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/IdleEventHandler.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.pipeline; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleUserEventChannelHandler; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; import io.xeres.app.net.peer.PeerConnectionManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; /** * Event handler that automatically closes the connection if the peer doesn't send anything * during a certain time. We also send a SliceProbeItem if we're idle ourselves (which is unlikely * to happen during normal operations (for example, RTT and heartbeat services)). */ @ChannelHandler.Sharable public class IdleEventHandler extends SimpleUserEventChannelHandler { private static final Logger log = LoggerFactory.getLogger(IdleEventHandler.class); private final Duration timeout; public IdleEventHandler(Duration timeout) { super(); this.timeout = timeout; } @Override protected void eventReceived(ChannelHandlerContext ctx, IdleStateEvent evt) { if (evt.state() == IdleState.READER_IDLE) { log.info("No activity for {} seconds, closing channel of {}", timeout.toSeconds(), ctx.channel().remoteAddress()); ctx.close(); } else if (evt.state() == IdleState.WRITER_IDLE) { log.info("Sending idle slicing probe"); PeerConnectionManager.writeSliceProbe(ctx); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/ItemDecoder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.pipeline; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToMessageDecoder; import io.xeres.app.net.peer.packet.MultiPacket; import io.xeres.app.net.peer.packet.Packet; import io.xeres.app.net.peer.packet.SimplePacket; import io.xeres.app.xrs.item.RawItem; import java.net.ProtocolException; import java.util.*; /** * Decodes RS Packets and produces a RawItem. */ public class ItemDecoder extends MessageToMessageDecoder { private static final int MAX_SLICES = 195_512; // maximum number of slices per packets (XXX: does RS have a limit there? I don't think so actually) private static final int MAX_CONCURRENT_PACKETS = 16; // maximum number of concurrent packets private final Map> accumulator = HashMap.newHashMap(MAX_CONCURRENT_PACKETS); @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws ProtocolException { var packet = Packet.fromBuffer(in); if (packet.isMulti()) { decodeNewPacket(ctx, (MultiPacket) packet, out); } else { out.add(new RawItem(packet)); } } // XXX: when an error happens (eg. slices exceeded), we should remove (dispose) all packets from the accumulator and refuse any further packet with such id because they're incomplete and will reach the decoding stage (and fail there) private void decodeNewPacket(ChannelHandlerContext ctx, MultiPacket packet, List out) throws ProtocolException { if (packet.isComplete()) { if (accumulator.containsKey(packet.getId())) { throw new ProtocolException("Start packet " + packet.getId() + " already received"); } out.add(new RawItem(packet)); } else if (packet.isStart()) { if (accumulator.containsKey(packet.getId())) { throw new ProtocolException("Start packet " + packet.getId() + " already received"); } if (accumulator.size() > MAX_CONCURRENT_PACKETS) { throw new ProtocolException("Too many concurrent packets (" + accumulator.size() + ")"); } var list = new ArrayList(); list.add(packet); accumulator.put(packet.getId(), list); } else if (packet.isMiddle()) { var list = Optional.ofNullable(accumulator.get(packet.getId())).orElseThrow(() -> new ProtocolException("Middle packet " + packet.getId() + " received without corresponding start packet")); if (list.size() > MAX_SLICES) { throw new ProtocolException("Packet " + packet.getId() + " has too many slices (" + list.size() + ")"); } list.add(packet); } else if (packet.isEnd()) { var list = Optional.ofNullable(accumulator.remove(packet.getId())).orElseThrow(() -> new ProtocolException("End packet " + packet.getId() + " received without corresponding start packet")); list.add(packet); out.add(new RawItem(new SimplePacket(ctx, list))); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/ItemEncoder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.pipeline; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToMessageEncoder; import io.xeres.app.net.peer.packet.Packet; import io.xeres.app.xrs.item.RawItem; import java.util.List; @ChannelHandler.Sharable public class ItemEncoder extends MessageToMessageEncoder { @Override protected void encode(ChannelHandlerContext ctx, RawItem msg, List out) { out.add(Packet.fromItem(msg)); msg.dispose(); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/MultiPacketEncoder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.pipeline; import io.netty.channel.ChannelOutboundHandlerAdapter; import org.apache.commons.lang3.NotImplementedException; public class MultiPacketEncoder extends ChannelOutboundHandlerAdapter { public MultiPacketEncoder() { throw new NotImplementedException("MultiPacketEncoder is not available yet, see #32"); } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/PacketDecoder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.pipeline; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; import io.netty.handler.codec.TooLongFrameException; import io.xeres.app.net.peer.packet.MultiPacket; import io.xeres.app.net.peer.packet.Packet; import io.xeres.app.net.peer.packet.SimplePacket; import java.net.ProtocolException; import java.util.List; import static io.xeres.app.net.peer.packet.MultiPacket.MAXIMUM_PACKET_SIZE; import static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE; /** * Decodes incoming frames into packets. */ public class PacketDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { if (in.readableBytes() >= HEADER_SIZE) { long size; if (in.getUnsignedByte(in.readerIndex()) == Packet.SLICE_PROTOCOL_VERSION_ID_01) { size = (long) in.getUnsignedShort(in.readerIndex() + MultiPacket.HEADER_SIZE_INDEX) + HEADER_SIZE; } else { size = in.getUnsignedInt(in.readerIndex() + SimplePacket.HEADER_SIZE_INDEX); } if (size >= MAXIMUM_PACKET_SIZE - HEADER_SIZE) { throw new TooLongFrameException("Frame is too long: " + size); } else if (size < HEADER_SIZE) { throw new ProtocolException("Packet size too small, size: " + size); } if (in.readableBytes() >= size) { out.add(in.readBytes((int) size)); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/PeerHandler.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.pipeline; import io.netty.channel.ChannelDuplexHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.TooLongFrameException; import io.netty.handler.ssl.SslHandler; import io.netty.handler.ssl.SslHandshakeCompletionEvent; import io.netty.util.ReferenceCountUtil; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.ConnectionType; import io.xeres.app.net.peer.PeerAttribute; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.net.peer.ssl.SSL; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.RawItem; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.transaction.annotation.Transactional; import javax.net.ssl.SSLPeerUnverifiedException; import java.io.IOException; import java.security.cert.CertificateException; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import static io.xeres.app.net.peer.ConnectionType.TCP_INCOMING; import static io.xeres.common.tray.TrayNotificationType.CONNECTION; public class PeerHandler extends ChannelDuplexHandler { private static final Logger log = LoggerFactory.getLogger(PeerHandler.class); private final ConnectionType connectionType; private final ProfileService profileService; private final LocationService locationService; private final PeerConnectionManager peerConnectionManager; private final DatabaseSessionManager databaseSessionManager; private final ServiceInfoRsService serviceInfoRsService; private final UiBridgeService uiBridgeService; private final RsServiceRegistry rsServiceRegistry; public PeerHandler(ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, ConnectionType connectionType, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry) { super(); this.profileService = profileService; this.locationService = locationService; this.serviceInfoRsService = serviceInfoRsService; this.connectionType = connectionType; this.peerConnectionManager = peerConnectionManager; this.databaseSessionManager = databaseSessionManager; this.uiBridgeService = uiBridgeService; this.rsServiceRegistry = rsServiceRegistry; } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { var peerConnection = ctx.channel().attr(PeerAttribute.PEER_CONNECTION).get(); // Drop messages if SSL peer is not validated if (peerConnection == null) { log.warn("Dropping message as SSL not validated"); ((RawItem) msg).dispose(); ReferenceCountUtil.release(msg); return; } log.trace("Got message: {}", msg); var rawItem = (RawItem) msg; Item item = null; var sessionBound = false; peerConnection.incrementReceivedCounter(rawItem.getSize()); try { item = rsServiceRegistry.buildIncomingItem(rawItem); log.debug("<== {}", item.getClass().getSimpleName()); rawItem.deserialize(item); log.debug(" \\- : {}", item); var service = rsServiceRegistry.getServiceFromType(item.getServiceType()); if (service != null) { var handleItemMethod = service.getClass().getDeclaredMethod("handleItem", PeerConnection.class, Item.class); if (handleItemMethod.isAnnotationPresent(Transactional.class)) { sessionBound = databaseSessionManager.bindSession(); } service.handleItem(peerConnection, item); } else { log.warn("Unknown item (service: {}, subtype: {}). Ignoring.", item.getServiceType(), item.getSubType()); } } catch (Exception e) // NOSONAR: We need to catch all exceptions here otherwise it's invisible { log.error("Failed to deserialize item {}", item, e); rawItem.dispose(); item = null; // Don't dispose twice } finally { if (sessionBound) { databaseSessionManager.unbindSession(); } if (item != null) { item.dispose(); // Dispose the item } } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { var peerConnection = ctx.channel().attr(PeerAttribute.PEER_CONNECTION).get(); var remote = peerConnection != null ? peerConnection : ctx.channel().remoteAddress(); if (cause instanceof TooLongFrameException || cause instanceof IOException) { if (log.isDebugEnabled()) { log.debug("Error in channel of {} (closing connection): ", remote, cause); } else { log.error("Error in channel of {} (closing connection): {}", remote, cause.getMessage()); } ctx.close(); } else { log.debug("Error in channel of {}:", remote, cause); } } @Override public void channelActive(ChannelHandlerContext ctx) { log.debug("{} connection with {}", connectionType == TCP_INCOMING ? "Incoming" : "Outgoing", ctx.channel().remoteAddress()); ctx.channel().attr(PeerAttribute.MULTI_PACKET).set(false); } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof SslHandshakeCompletionEvent sslHandshakeCompletionEvent) { if (!sslHandshakeCompletionEvent.isSuccess()) { log.debug("SSL handshake failed"); // There doesn't seem to ever be a useful message in the even so we don't display any ctx.close(); return; } try (var ignored = new DatabaseSession(databaseSessionManager)) { Location location; synchronized (PeerHandler.class) // Make sure we cannot have an outgoing and incoming connection with the same peer at the same time { location = SSL.checkPeerCertificate(profileService, locationService, ctx.pipeline().get(SslHandler.class).engine().getSession().getPeerCertificates()); locationService.setConnected(location, ctx.channel().remoteAddress()); var peerConnection = peerConnectionManager.addPeer(location, ctx); peerConnection.schedule(() -> serviceInfoRsService.init(peerConnection), ThreadLocalRandom.current().nextInt(2, 9), TimeUnit.SECONDS); } var message = "Established " + connectionType.getDescription() + " connection with " + location.getProfile().getName() + " (" + location.getSafeName() + ")"; log.info(message); uiBridgeService.showTrayNotification(CONNECTION, message); sendSliceProbe(ctx); } catch (CertificateException | SSLPeerUnverifiedException e) { log.error("Certificate error: {}", e.getMessage()); ctx.close(); } } } @Override public void channelInactive(ChannelHandlerContext ctx) { var peerConnection = ctx.channel().attr(PeerAttribute.PEER_CONNECTION).get(); var remote = peerConnection != null ? peerConnection : ctx.channel().remoteAddress(); log.debug("Closing connection with {}", remote); if (peerConnection != null) { if (!log.isDebugEnabled()) { log.warn("Closing connection with {}", remote); } peerConnection.cleanup(); try (var ignored = new DatabaseSession(databaseSessionManager)) { locationService.setDisconnected(peerConnection.getLocation()); } peerConnectionManager.removePeer(peerConnection.getLocation()); } } private static void sendSliceProbe(ChannelHandlerContext ctx) { PeerConnectionManager.writeSliceProbe(ctx); // this makes the remote RS send packets in the new format } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/SimplePacketEncoder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.pipeline; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToByteEncoder; import io.xeres.app.net.peer.packet.Packet; @ChannelHandler.Sharable public class SimplePacketEncoder extends MessageToByteEncoder { @Override protected void encode(ChannelHandlerContext ctx, Packet msg, ByteBuf out) { ctx.writeAndFlush(msg.getBuffer()); // nothing to do, just send the buffer } } ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/pipeline/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * Pipeline process. *

It works in the following way. *

For incoming packets *

incoming bytes -> Packet -> Item -> deserialization -> service data
*

For outgoing packets *

service data -> serialization -> Item -> Packet -> outgoing bytes
*

Right now, the packet encoder sends simple packets. It'll be upgraded to send multi packets later. * Both multi packets and simple packets are accepted as input. */ package io.xeres.app.net.peer.pipeline; ================================================ FILE: app/src/main/java/io/xeres/app/net/peer/ssl/SSL.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.ssl; import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.SslProvider; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.crypto.rsid.RSSerialVersion; import io.xeres.app.crypto.x509.X509; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.net.peer.ConnectionType; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.common.id.LocationIdentifier; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.net.ssl.SSLException; import java.io.ByteArrayInputStream; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.SignatureException; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.security.spec.InvalidKeySpecException; import java.util.Locale; import java.util.regex.Pattern; import static io.xeres.app.net.peer.ConnectionType.TCP_INCOMING; public final class SSL { private static final Logger log = LoggerFactory.getLogger(SSL.class); private static final Pattern ISSUER_MATCHER = Pattern.compile("^CN=(\\p{XDigit}{16})$"); private SSL() { throw new UnsupportedOperationException("Utility class"); } /** * Creates an SSL context. * * @param privateKeyData the private key * @param certificate the certificate * @param connectionType the connection type (incoming for a server, outgoing for a client) * @return the ssl context * @throws InvalidKeySpecException if the private key is bad * @throws NoSuchAlgorithmException if the private key has an unsupported key algorithm * @throws SSLException if there's an SSL error */ public static SslContext createSslContext(byte[] privateKeyData, X509Certificate certificate, ConnectionType connectionType) throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException { SslContextBuilder builder; if (connectionType == TCP_INCOMING) { builder = SslContextBuilder.forServer(RSA.getPrivateKey(privateKeyData), certificate); } else { builder = SslContextBuilder.forClient() .endpointIdentificationAlgorithm(null) // No hostname verification. Would be impractical in a P2P setup .keyManager(RSA.getPrivateKey(privateKeyData), certificate); } return builder .sslProvider(SslProvider.JDK) .protocols("TLSv1.3") .clientAuth(ClientAuth.REQUIRE) .trustManager(InsecureTrustManagerFactory.INSTANCE) .build(); } /** * Checks if a certificate is valid. Either it matches a location that we already have or it's the location of a profile that * we have accepted. In the later case, the new location is also created with a null name that will be updated later * using discovery. * * @param profileService the profile service * @param locationService the location service * @param chain the certificate chain * @return the location * @throws CertificateException if the location is not allowed */ public static Location checkPeerCertificate(ProfileService profileService, LocationService locationService, Certificate[] chain) throws CertificateException { var isNewLocation = false; if (chain == null || chain.length == 0) { throw new CertificateException("Empty certificate"); } var x509Certificate = X509.getCertificate(chain[0].getEncoded()); var locationIdentifier = X509.getLocationIdentifier(x509Certificate); log.debug("SSL ID: {}", locationIdentifier); var location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElse(null); if (location == null) { location = createLocationIfAcceptedProfile(locationIdentifier, x509Certificate, profileService); if (location == null) { throw new CertificateException("Unknown location (SSL ID: " + locationIdentifier + ")"); } isNewLocation = true; } log.debug("Found location: {} {}", location.getSafeName(), location.isConnected() ? ", is already connected" : ""); if (location.isConnected()) { throw new CertificateException("Already connected"); } if (location.getProfile().isComplete()) { try { verify(PGP.getPGPPublicKey(location.getProfile().getPgpPublicKeyData()), x509Certificate); if (isNewLocation) { profileService.createOrUpdateProfile(location.getProfile()); } } catch (InvalidKeyException e) { throw new CertificateException(e.getMessage(), e); } } return location; } private static Location createLocationIfAcceptedProfile(LocationIdentifier locationIdentifier, X509Certificate x509Certificate, ProfileService profileService) { var issuer = x509Certificate.getIssuerX500Principal().getName(); var matcher = ISSUER_MATCHER.matcher(issuer); if (matcher.matches()) { var pgpIdentifier = Long.parseUnsignedLong(matcher.group(1).toLowerCase(Locale.ROOT), 16); var profile = profileService.findProfileByPgpIdentifier(pgpIdentifier) .filter(Profile::isComplete) .filter(Profile::isAccepted) .orElse(null); if (profile != null) { return Location.createLocation(null, profile, locationIdentifier); } log.debug("No profile found for location: {}", locationIdentifier); } else { log.debug("Couldn't match PGP key from certificate issuer: {}", issuer); } return null; } private static void verify(PGPPublicKey pgpPublicKey, X509Certificate cert) throws CertificateException { var version = RSSerialVersion.getFromSerialNumber(cert.getSerialNumber()); log.debug("Certificate version: {}", version); try { var in = cert.getTBSCertificate(); if (version.ordinal() < RSSerialVersion.V07_0001.ordinal()) { // If this is a 0.6 certificate, the signature verification is performed // on the hash of the certificate var md = new Sha1MessageDigest(); md.update(in); in = md.getBytes(); } PGP.verify(pgpPublicKey, cert.getSignature(), new ByteArrayInputStream(in)); } catch (CertificateEncodingException | IOException | SignatureException | PGPException e) { throw new CertificateException(e); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/protocol/DomainNameSocketAddress.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.protocol; import java.io.Serial; import java.net.SocketAddress; public final class DomainNameSocketAddress extends SocketAddress { @Serial private static final long serialVersionUID = -551345992744929084L; private final String name; private DomainNameSocketAddress(String name) { if (name.contains(":")) { throw new IllegalArgumentException("DomainNameSocketAddress is only usable for domains alone, not domain/ports"); } else { this.name = name; } } public static DomainNameSocketAddress of(String name) { return new DomainNameSocketAddress(name); } public String getName() { return name; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/protocol/PeerAddress.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.protocol; import io.xeres.common.protocol.HostPort; import io.xeres.common.protocol.i2p.I2pAddress; import io.xeres.common.protocol.ip.IP; import io.xeres.common.protocol.tor.OnionAddress; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Optional; import java.util.regex.Pattern; import static io.xeres.app.net.protocol.PeerAddress.Type.*; import static io.xeres.common.protocol.ip.IP.isInvalidPort; import static java.util.function.Predicate.not; /** * A class that can contain any peer address. *

* Vocabulary: *

    *
  • url: a Retroshare URL (ipv4://192.168.1.1:80, etc...)
  • *
  • address: a string that can be an ipv4 socket or tor address (192.168.1.1:80, foobar.onion, ...)
  • *
  • ipAndPort: 192.168.1.1:80
  • *
  • socket address: an ip socket address, directly usable with java functions
  • *
*

Creating a PeerAddress always succeed. Its validity can be checked with isValid(). */ public final class PeerAddress { public enum Type { INVALID(""), IPV4("ipv4://"), IPV6("ipv6://"), TOR(""), HOSTNAME(""), I2P(""); private final String scheme; Type(String scheme) { this.scheme = scheme; } public String scheme() { return scheme; } } private static final Pattern HOSTNAME_OK_PATTERN = Pattern.compile("^(?:\\p{Alnum}(?>[\\p{Alnum}-]{0,61}\\p{Alnum})?\\.)+(\\p{Alpha}(?>[\\p{Alnum}-]{0,61}\\p{Alnum})?)\\.?$"); private SocketAddress socketAddress; private final Type type; /** * Creates a PeerAddress from a URL (ipv4://, etc...). * * @param url the URL * @return a PeerAddress */ public static PeerAddress fromUrl(String url) { if (url == null) { return fromInvalid(); } if (url.startsWith(IPV4.scheme())) { return fromIpAndPort(url.substring(IPV4.scheme().length())); } return fromInvalid(); } /** * Creates a PeerAddress from an address (eg. juiejkslajfsk.onion, 85.12.33.11:8081, ...). * * @param address the address * @return a PeerAddress */ public static PeerAddress fromAddress(String address) { if (address == null) { return fromInvalid(); } return tryFromHidden(address).orElse(tryFromIpAndPort(address).orElse(fromHostnameAndPort(address))); } /** * Creates a PeerAddress from a hidden address (Tor/I2P) * * @param address the address * @return a PeerAddress */ public static PeerAddress fromHidden(String address) { if (address == null) { return fromInvalid(); } return tryFromHidden(address).orElse(fromInvalid()); } private static Optional tryFromHidden(String address) { var peerAddress = tryFromOnion(address); if (peerAddress.isEmpty()) { peerAddress = tryFromI2p(address); } return peerAddress; } private static Optional tryFromIpAndPort(String address) { var peerAddress = fromIpAndPort(address); if (peerAddress.isValid()) { return Optional.of(peerAddress); } return Optional.empty(); } /** * Creates a PeerAddress from an IP and a port. * * @param ip the IP address * @param port the port * @return a PeerAddress */ public static PeerAddress from(String ip, int port) { if (isInvalidPort(port)) { return fromInvalid(); } if (isInvalidIpAddress(ip)) { return fromInvalid(); } try { return new PeerAddress(new InetSocketAddress(InetAddress.getByName(ip), port), IPV4); } catch (UnknownHostException _) { return fromInvalid(); // Won't happen anyway } } /** * Creates a PeerAddress from an "ip:port" string. * * @param ipAndPort a string in the form "ip:port"; for example, "192.168.1.2:8002" * @return a PeerAddress */ public static PeerAddress fromIpAndPort(String ipAndPort) { try { var hostPort = HostPort.parse(ipAndPort); return from(hostPort); } catch (IllegalArgumentException _) { return fromInvalid(); } } public static PeerAddress from(HostPort hostPort) { return from(hostPort.host(), hostPort.port()); } /** * Creates a PeerAddress from a RsCertificate byte array. * * @param data a byte array which is made of the 4 bytes of the IP and the 2 bytes of the port (big endian). * @return a PeerAddress */ public static PeerAddress fromByteArray(byte[] data) { if (data == null || data.length != 6) { return fromInvalid(); } var ip = String.format("%d.%d.%d.%d", Byte.toUnsignedInt(data[0]), Byte.toUnsignedInt(data[1]), Byte.toUnsignedInt(data[2]), Byte.toUnsignedInt(data[3])); if (isInvalidIpAddress(ip)) { return fromInvalid(); } var port = Byte.toUnsignedInt(data[4]) << 8 | Byte.toUnsignedInt(data[5]); if (isInvalidPort(port)) { return fromInvalid(); } return from(ip, port); } public static PeerAddress fromHostname(String hostname) { if (isInvalidHostname(hostname)) { return fromInvalid(); } return new PeerAddress(DomainNameSocketAddress.of(hostname), HOSTNAME); } public static PeerAddress fromHostname(String hostname, int port) { if (isInvalidHostname(hostname) || isInvalidPort(port)) { return fromInvalid(); } return new PeerAddress(InetSocketAddress.createUnresolved(hostname, port), HOSTNAME); } public static PeerAddress fromHostnameAndPort(String hostnameAndPort) { try { var hostPort = HostPort.parse(hostnameAndPort); if (isInvalidHostname(hostPort.host())) { return fromInvalid(); } return fromHostname(hostPort.host(), hostPort.port()); } catch (IllegalArgumentException _) { return fromInvalid(); } } public static PeerAddress fromSocketAddress(SocketAddress socketAddress) { return new PeerAddress(socketAddress, Type.IPV4); } /** * Creates a PeerAddress from an onion address (i.e. "jskljfksdjk.onion") * * @param onion the onion address * @return a PeerAddress */ public static PeerAddress fromOnion(String onion) { return tryFromOnion(onion).orElse(fromInvalid()); } private static Optional tryFromOnion(String onion) { if (OnionAddress.isValidAddress(onion)) { var hostPort = HostPort.parse(onion); return Optional.of(new PeerAddress(InetSocketAddress.createUnresolved(hostPort.host(), hostPort.port()), TOR)); } return Optional.empty(); } public static PeerAddress fromI2p(String i2p) { return tryFromI2p(i2p).orElse(fromInvalid()); } private static Optional tryFromI2p(String i2p) { if (I2pAddress.isValidAddress(i2p)) { var hostPort = HostPort.parse(i2p); return Optional.of(new PeerAddress(InetSocketAddress.createUnresolved(hostPort.host(), hostPort.port()), I2P)); } return Optional.empty(); } /** * Creates an invalid PeerAddress. * * @return a PeerAddress */ public static PeerAddress fromInvalid() { return new PeerAddress(INVALID); } private PeerAddress(Type type) { this.type = type; } private PeerAddress(SocketAddress socketAddress, Type type) { this.type = type; this.socketAddress = socketAddress; } /** * Gets the SocketAddress of the PeerAddress (if the protocol allows it). * * @return a SocketAddress */ public SocketAddress getSocketAddress() { return socketAddress; } /** * Gets the address and port of the PeerAddress (if the protocol allows it), or any other suitable format. * * @return the IP address and port in the following format: "ip:port" or any other suitable format */ public Optional getAddress() { if (socketAddress instanceof InetSocketAddress inetSocketAddress) { return Optional.of(inetSocketAddress.getHostString() + ":" + inetSocketAddress.getPort()); } else if (socketAddress instanceof DomainNameSocketAddress domainNameSocketAddress) { return Optional.of(domainNameSocketAddress.getName()); } return Optional.empty(); } /** * Gets the IP address and port in an array of bytes. * * @return the IP address in the 4 first bytes and the port in the 2 last ones (big endian). */ public Optional getAddressAsBytes() { if (socketAddress instanceof InetSocketAddress inetSocketAddress) { var port = inetSocketAddress.getPort(); switch (type) { case HOSTNAME -> { var hostname = inetSocketAddress.getHostName().getBytes(StandardCharsets.US_ASCII); var bytes = new byte[hostname.length + 2]; System.arraycopy(hostname, 0, bytes, 0, hostname.length); bytes[bytes.length - 2] = (byte) (port >> 8); bytes[bytes.length - 1] = (byte) (port & 0xff); return Optional.of(bytes); } case IPV4 -> { var bytes = new byte[6]; System.arraycopy(inetSocketAddress.getAddress().getAddress(), 0, bytes, 0, 4); bytes[4] = (byte) (port >> 8); bytes[5] = (byte) (port & 0xff); return Optional.of(bytes); } case null, default -> throw new UnsupportedOperationException("Can't get address for type " + type); } } else if (socketAddress instanceof DomainNameSocketAddress) { throw new IllegalStateException("Can't get the address of a DomainNameSocketAddress as it requires a port"); } return Optional.empty(); } /** * Gets the type of the PeerAddress. * * @return the type of the PeerAddress */ public Type getType() { return type; } public String getUrl() { return type.scheme() + getAddress().orElseThrow(); } /** * Checks if the PeerAddress is invalid. * * @return true if invalid */ public boolean isInvalid() { return type == INVALID; } /** * Checks if the PeerAddress is valid. * * @return true if valid */ public boolean isValid() { return type != INVALID; } /** * Checks if the PeerAddress is a hidden address (Tor/I2P) * * @return true if the address is a hidden address */ public boolean isHidden() { return type == TOR || type == I2P; } /** * Checks if the PeerAddress is an external address (that is, something that can be connected to from outside a LAN). * * @return true if external address */ public boolean isExternal() { return type == TOR || type == I2P || (type == IPV4 && IP.isPublicIp(((InetSocketAddress) socketAddress).getHostString())); } public boolean isLAN() { return type == IPV4 && IP.isLanIp(((InetSocketAddress) socketAddress).getHostString()); } public boolean isHostname() { return type == HOSTNAME; } private static boolean isInvalidIpAddress(String address) { if (address == null) { return true; } var octets = address.split("\\."); if (octets.length != 4) { return true; } try { return Arrays.stream(octets) .filter(not(s -> s.length() > 1 && s.startsWith("0"))) .map(Integer::parseInt) .filter(i -> (i >= 0 && i <= 255)) .count() != 4 || !IP.isRoutableIp(address); } catch (NumberFormatException _) { return true; } } private static boolean isInvalidHostname(String hostname) { return !(hostname != null && hostname.length() <= 253 && HOSTNAME_OK_PATTERN.matcher(hostname).matches()); } @Override public String toString() { return "PeerAddress{" + "socketAddress=" + socketAddress + ", type=" + type + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/ControlPoint.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import io.xeres.app.util.XmlUtils; import io.xeres.common.AppName; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.xml.sax.SAXException; import javax.xml.parsers.ParserConfigurationException; import javax.xml.xpath.XPathException; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; import javax.xml.xpath.XPathNodes; import java.io.ByteArrayInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URI; import java.util.HashMap; import java.util.Locale; import java.util.Map; final class ControlPoint { private static final Logger log = LoggerFactory.getLogger(ControlPoint.class); private ControlPoint() { throw new UnsupportedOperationException("Utility class"); } static boolean updateDevice(DeviceSpecs upnpDevice, URI location) { var controlPointFound = false; try { var document = XmlUtils.getSecureDocumentBuilderFactory().newDocumentBuilder().parse(location.toString()); var xPath = XPathFactory.newInstance().newXPath(); var devices = xPath.evaluateExpression("//device[deviceType[contains(text(), 'InternetGatewayDevice')]]", document, XPathNodes.class); getDeviceInfo(upnpDevice, devices); var services = xPath.evaluateExpression("//service[serviceType[contains(text(), 'WANIPConnection') or contains(text(), 'WANPPPConnection')]]", document, XPathNodes.class); controlPointFound = hasServices(upnpDevice, services); } catch (FileNotFoundException _) { log.error("UPNP router's URL {} is not accessible", location); } catch (ParserConfigurationException e) { log.error("Couldn't create XML parser for UPNP router URL {}: {}", location, e.getMessage()); } catch (SAXException e) { log.error("XML parse error for UPNP router URL {}: {}", location, e.getMessage()); } catch (XPathException e) { throw new IllegalArgumentException("XPath expression error: " + e.getMessage(), e); } catch (IOException e) { log.error("I/O error when parsing UPNP's router URL {}: {}", location, e.getMessage()); } return controlPointFound; } private static void getDeviceInfo(DeviceSpecs upnpDevice, XPathNodes devices) throws XPathException { if (devices.size() != 1) { throw new IllegalStateException("Require 1 root device, found: " + devices.size()); } var childNodes = devices.get(0).getChildNodes(); for (var i = 0; i < childNodes.getLength(); i++) { var item = childNodes.item(i); switch (item.getNodeName().toLowerCase(Locale.ROOT)) { case "modelname" -> upnpDevice.setModelName(item.getTextContent().trim()); case "manufacturer" -> upnpDevice.setManufacturer(item.getTextContent().trim()); case "manufacturerurl" -> upnpDevice.setManufacturerUrl(item.getTextContent().trim()); case "serialnumber" -> upnpDevice.setSerialNumber(item.getTextContent().trim()); case "presentationurl" -> upnpDevice.setPresentationUrl(item.getTextContent().trim()); default -> log.trace("node: {}", item.getNodeName()); } } } private static boolean hasServices(DeviceSpecs upnpDevice, XPathNodes services) throws XPathException { var controlUrlFound = false; if (services.size() != 1) { throw new IllegalStateException("More than one service: " + services.size()); } var childNodes = services.get(0).getChildNodes(); for (var i = 0; i < childNodes.getLength(); i++) { var item = childNodes.item(i); switch (item.getNodeName().toLowerCase(Locale.ROOT)) { case "controlurl" -> { upnpDevice.setControlUrl(item.getTextContent().trim()); controlUrlFound = true; } case "servicetype" -> upnpDevice.setServiceType(item.getTextContent().trim()); default -> log.trace("service: {}", item.getNodeName()); } } return controlUrlFound; } static boolean addPortMapping(URI controlUrl, String serviceType, String internalIp, int internalPort, int externalPort, int duration, Protocol protocol) { Map args = HashMap.newHashMap(8); args.put("NewRemoteHost", ""); args.put("NewExternalPort", String.valueOf(externalPort)); args.put("NewProtocol", protocol.name()); args.put("NewInternalPort", String.valueOf(internalPort)); args.put("NewInternalClient", internalIp); args.put("NewEnabled", "1"); args.put("NewPortMappingDescription", AppName.NAME + " " + protocol.name()); args.put("NewLeaseDuration", String.valueOf(duration)); var response = Soap.sendRequest(controlUrl, serviceType, "AddPortMapping", args); return response.getStatusCode() == HttpStatus.OK; } static boolean removePortMapping(URI controlUrl, String serviceType, int externalPort, Protocol protocol) { Map args = HashMap.newHashMap(3); args.put("NewRemoteHost", ""); args.put("NewExternalPort", String.valueOf(externalPort)); args.put("NewProtocol", protocol.name()); var response = Soap.sendRequest(controlUrl, serviceType, "DeletePortMapping", args); return response.getStatusCode() == HttpStatus.OK; } static String getExternalIpAddress(URI controlUrl, String serviceType) { var response = Soap.sendRequest(controlUrl, serviceType, "GetExternalIPAddress", null); var body = response.getBody(); if (response.getStatusCode() == HttpStatus.OK && body != null) { var reply = getTextNodes(body); return reply.getOrDefault("NewExternalIPAddress", ""); } return ""; } static Map getTextNodes(String xml) { Map result = new HashMap<>(); try { var document = XmlUtils.getSecureDocumentBuilderFactory().newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes())); var xPath = XPathFactory.newInstance().newXPath(); var textNodes = xPath.evaluateExpression("//text()", document, XPathNodes.class); for (var textNode : textNodes) { result.put(textNode.getParentNode().getNodeName(), textNode.getTextContent()); } } catch (SAXException e) { throw new IllegalArgumentException("XML parse error on UPNP router reply: " + e.getMessage(), e); } catch (IOException e) { throw new IllegalArgumentException("I/O error when parsing UPNP router's XML reply: " + e.getMessage(), e); } catch (ParserConfigurationException e) { throw new IllegalArgumentException("Couldn't create XML parser for UPNP router's XML reply: " + e.getMessage(), e); } catch (XPathExpressionException e) { throw new IllegalArgumentException("XPath expression error: " + e.getMessage(), e); } return result; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/Device.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.regex.Pattern; final class Device implements DeviceSpecs { private static final Logger log = LoggerFactory.getLogger(Device.class); private static final Pattern HTTP_OK_PATTERN = Pattern.compile("^HTTP/1\\.. 200 OK"); private static final int MAX_HEADER_VALUE_LENGTH = 128; private static final Set supportedHeaders = EnumSet.allOf(HttpuHeader.class); private InetSocketAddress inetSocketAddress; private Map headers; private String modelName; private String manufacturer; private URI manufacturerUrl; private String serialNumber; private URI presentationUrl; private URI controlUrl; private URI locationUrl; private String serviceType; private boolean hasControlPoint; private final HashSet ports = new HashSet<>(); static Device from(SocketAddress socketAddress, ByteBuffer byteBuffer) { if (!(socketAddress instanceof InetSocketAddress)) { log.warn("Not an Inet device. Ignoring."); return Device.fromInvalid(); } var reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(byteBuffer.array()), StandardCharsets.US_ASCII)); try { Map headers = new EnumMap<>(HttpuHeader.class); var s = reader.readLine(); if (!HTTP_OK_PATTERN.matcher(s).matches()) { log.warn("Not a valid HTTP response: {}. Ignoring.", s); return Device.fromInvalid(); } while ((s = reader.readLine()) != null) { var tokens = s.split(":", 2); if (tokens.length != 2 || tokens[1].length() > MAX_HEADER_VALUE_LENGTH) { continue; } var header = tokens[0].toUpperCase(Locale.ROOT).strip(); if (supportedHeaders.stream().anyMatch(h -> h.name().equals(header))) { headers.put(HttpuHeader.valueOf(header), tokens[1].strip()); } } return new Device(socketAddress, headers); } catch (IOException e) { log.warn("Couldn't read line, shouldn't happen", e); } return Device.fromInvalid(); } private static Device fromInvalid() { return new Device(); } private Device(SocketAddress socketAddress, Map headers) { inetSocketAddress = (InetSocketAddress) socketAddress; this.headers = headers; } private Device() { } public boolean isValid() { return inetSocketAddress != null && hasLocation(); } public boolean isInvalid() { return inetSocketAddress == null; } public InetSocketAddress getInetSocketAddress() { return inetSocketAddress; } public Optional getHeaderValue(HttpuHeader header) { if (isInvalid()) { return Optional.empty(); } return Optional.ofNullable(headers.get(header)); } public boolean hasLocation() { return getLocationUrl() != null; } public URI getLocationUrl() { if (locationUrl != null) { return locationUrl; } locationUrl = getHeaderValue(HttpuHeader.LOCATION).map(s -> { try { return new URI(s); } catch (URISyntaxException e) { log.error("UPNP: unparseable URL {}, {}", s, e.getMessage()); return null; } }).orElse(null); return locationUrl; } public boolean hasServer() { return getHeaderValue(HttpuHeader.SERVER).isPresent(); } public String getServer() { return getHeaderValue(HttpuHeader.SERVER).orElse(null); } public boolean hasUsn() { return getHeaderValue(HttpuHeader.USN).isPresent(); } public String getUsn() { return getHeaderValue(HttpuHeader.USN).orElse(null); } @Override public boolean hasModelName() { return modelName != null; } @Override public String getModelName() { return modelName; } @Override public void setModelName(String modelName) { this.modelName = modelName; } @Override public boolean hasManufacturer() { return manufacturer != null; } @Override public String getManufacturer() { return manufacturer; } @Override public void setManufacturer(String manufacturer) { this.manufacturer = manufacturer; } @Override public URI getManufacturerUrl() { return manufacturerUrl; } @Override public void setManufacturerUrl(String manufacturerUrl) { this.manufacturerUrl = parseUrl(manufacturerUrl); } @Override public boolean hasSerialNumber() { return serialNumber != null; } @Override public String getSerialNumber() { return serialNumber; } @Override public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; } @Override public boolean hasControlUrl() { return controlUrl != null; } @Override public URI getControlUrl() { return controlUrl; } @Override public void setControlUrl(String controlUrl) { this.controlUrl = parseUrl(locationUrl, controlUrl); } @Override public boolean hasPresentationUrl() { return presentationUrl != null; } @Override public URI getPresentationUrl() { return presentationUrl; } @Override public void setPresentationUrl(String presentationUrl) { this.presentationUrl = parseUrl(presentationUrl); } @Override public String getServiceType() { return serviceType; } @Override public void setServiceType(String serviceType) { this.serviceType = serviceType; } public void addControlPoint() { if (isInvalid()) { throw new IllegalStateException("Trying to add a control point to an invalid device"); } hasControlPoint = ControlPoint.updateDevice(this, getLocationUrl()); } public boolean hasControlPoint() { return hasControlPoint; } public boolean addPortMapping(String internalIp, int internalPort, int externalPort, int duration, Protocol protocol) { var added = ControlPoint.addPortMapping(getControlUrl(), getServiceType(), internalIp, internalPort, externalPort, duration, protocol); if (added) { ports.add(new PortMapping(externalPort, protocol)); } return added; } public void deletePortMapping(int externalPort, Protocol protocol) { if (ControlPoint.removePortMapping(getControlUrl(), getServiceType(), externalPort, protocol)) { ports.removeIf(portMapping -> portMapping.port() == externalPort && portMapping.protocol() == protocol); } } public void removeAllPortMapping() { new HashSet<>(ports).forEach(portMapping -> deletePortMapping(portMapping.port(), portMapping.protocol())); } public String getExternalIpAddress() { return ControlPoint.getExternalIpAddress(getControlUrl(), getServiceType()); } private static URI parseUrl(String url) { return parseUrl(null, url); } private static URI parseUrl(URI baseUrl, String url) { try { if (baseUrl != null) { return baseUrl.resolve(url); } return new URI(addProtocolIfMissing(url)); } catch (URISyntaxException e) { log.error("Wrong URL {}, {}", url, e.getMessage()); return null; } } /** * Fixes the URL returned by some routers that miss a protocol, for * example www.Nucom.com * @param url the url * @return a url with the protocol prepended */ private static String addProtocolIfMissing(String url) { if (url != null && url.toLowerCase(Locale.ROOT).startsWith("www.")) { return "https://" + url; } return url; } @Override public String toString() { return "Device{" + "inetSocketAddress=" + inetSocketAddress + ", headers=" + headers + ", modelName='" + modelName + '\'' + ", manufacturer='" + manufacturer + '\'' + ", manufacturerUrl=" + manufacturerUrl + ", serialNumber='" + serialNumber + '\'' + ", presentationUrl=" + presentationUrl + ", controlUrl=" + controlUrl + ", locationUrl=" + locationUrl + ", serviceType='" + serviceType + '\'' + ", hasControlPoint=" + hasControlPoint + ", ports=" + ports + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/DeviceSpecs.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import java.net.URI; public interface DeviceSpecs { boolean hasModelName(); String getModelName(); void setModelName(String modelName); boolean hasManufacturer(); String getManufacturer(); void setManufacturer(String manufacturer); URI getManufacturerUrl(); void setManufacturerUrl(String manufacturerUrl); boolean hasSerialNumber(); String getSerialNumber(); void setSerialNumber(String serialNumber); boolean hasControlUrl(); URI getControlUrl(); void setControlUrl(String controlUrl); boolean hasPresentationUrl(); URI getPresentationUrl(); void setPresentationUrl(String presentationUrl); String getServiceType(); void setServiceType(String serviceType); } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/HttpuHeader.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; enum HttpuHeader { LOCATION, SERVER, ST, USN } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/PortMapping.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; record PortMapping(int port, Protocol protocol) { @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (PortMapping) o; return port == that.port && protocol == that.protocol; } } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/Protocol.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; enum Protocol { TCP, UDP } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/Soap.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientException; import java.net.URI; import java.time.Duration; import java.util.Map; final class Soap { private static final Logger log = LoggerFactory.getLogger(Soap.class); private Soap() { throw new UnsupportedOperationException("Utility class"); } private static String createSoap(String serviceType, String actionName, Map args) { var soap = new StringBuilder(); soap.append("\r\n"); soap.append(""); soap.append(""); soap.append(""); if (args != null) { args.forEach((key, value) -> soap.append("<").append(key).append(">").append(value).append("")); } soap.append(""); soap.append(""); soap.append(""); return soap.toString(); } static ResponseEntity sendRequest(URI controlUrl, String serviceType, String action, Map args) { var webClient = WebClient.builder() .baseUrl(controlUrl.toString()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_XML_VALUE) .build(); try { return webClient.post() .bodyValue(createSoap(serviceType, action, args)) .header("SOAPAction", "\"" + serviceType + "#" + action + "\"") .retrieve() .toEntity(String.class) .block(Duration.ofSeconds(10)); } catch (WebClientException e) { log.error("Bad request: {}", e.getMessage()); return ResponseEntity.badRequest().build(); } catch (RuntimeException e) { log.error("Timeout while sending request: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).build(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/UPNPService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import io.xeres.app.application.events.UpnpEvent; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.external.ExternalIpResolver; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.LocationService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.common.rest.notification.status.NatStatus; import io.xeres.common.util.ThreadUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.io.IOException; import java.net.*; import java.nio.ByteBuffer; import java.nio.channels.ClosedByInterruptException; import java.nio.channels.DatagramChannel; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.time.Duration; @Service public class UPNPService implements Runnable { private static final Logger log = LoggerFactory.getLogger(UPNPService.class); private static final String MULTICAST_IP = "239.255.255.250"; private static final int MULTICAST_PORT = 1900; private static final int MULTICAST_BUFFER_SEND_SIZE_MAX = 512; // this is the maximum size used by MiniUPNPd so better not use more private static final int MULTICAST_BUFFER_RECV_SIZE = 1024; // also used by MiniUPNPd private static final int MULTICAST_MAX_WAIT_TIME = (int) Duration.ofSeconds(3).toMillis(); // time to wait for a router reply private static final int MULTICAST_MAX_WAIT_SNOOZE = (int) Duration.ofMinutes(5).toMillis(); // time to wait if nothing has answered all requests private static final int MULTICAST_DELAY_HINT = (int) Duration.ofSeconds(1).toSeconds(); // how long a router can delay its reply private static final int PORT_DURATION = (int) Duration.ofHours(1).toMillis(); // how long does a port mapping lasts private static final int PORT_DURATION_ANTICIPATION = (int) Duration.ofMinutes(1).toMillis(); // when to kick in the refresh before it expires private static final Duration SERVICE_RETRY_DURATION = Duration.ofMinutes(5); // time to retry the service when getting an error private static final String[] DEVICES = { // IGD 1 "urn:schemas-upnp-org:device:InternetGatewayDevice:1", "urn:schemas-upnp-org:service:WANIPConnection:1", "urn:schemas-upnp-org:device:WANDevice:1", "urn:schemas-upnp-org:device:WANConnectionDevice:1", "urn:schemas-upnp-org:service:WANPPPConnection:1", // IGD 2 "urn:schemas-upnp-org:device:InternetGatewayDevice:2", "urn:schemas-upnp-org:device:WANDevice:2", "urn:schemas-upnp-org:device:WANConnectionDevice:2", "urn:schemas-upnp-org:service:WANIPConnection:2", // Most routers will respond to all entries }; private enum State { SNOOZING, BROADCASTING, WAITING, CONNECTING, CONNECTED, INTERRUPTED } private final LocationService locationService; private final ApplicationEventPublisher publisher; private final StatusNotificationService statusNotificationService; private final DatabaseSessionManager databaseSessionManager; private final ExternalIpResolver externalIpResolver; private int deviceIndex; private String localIpAddress; private int localPort; private int controlPort; private Thread thread; private SocketAddress multicastAddress; private ByteBuffer sendBuffer; private ByteBuffer receiveBuffer; private State state; private Device device; private boolean externalIpAddressFound; public UPNPService(LocationService locationService, ApplicationEventPublisher publisher, StatusNotificationService statusNotificationService, DatabaseSessionManager databaseSessionManager, ExternalIpResolver externalIpResolver) { this.locationService = locationService; this.publisher = publisher; this.statusNotificationService = statusNotificationService; this.databaseSessionManager = databaseSessionManager; this.externalIpResolver = externalIpResolver; } public void start(String localIpAddress, int localPort, int controlPort) { log.info("Starting UPNP service..."); this.localIpAddress = localIpAddress; this.localPort = localPort; this.controlPort = controlPort; statusNotificationService.setNatStatus(NatStatus.UNKNOWN); thread = Thread.ofVirtual() .name("UPNP Service") .start(this); } public void stop() { if (thread != null) { log.info("Stopping UPNP..."); thread.interrupt(); } statusNotificationService.setNatStatus(NatStatus.UNKNOWN); } public boolean isRunning() { return thread.isAlive(); } public void waitForTermination() { ThreadUtils.waitForThread(thread); } private static String getMSearch(String device) { return "M-SEARCH * HTTP/1.1\r\nHost: " + MULTICAST_IP + ":" + MULTICAST_PORT + "\r\nST: " + device + "\r\nMan: \"ssdp:discover\"\r\nMX: " + MULTICAST_DELAY_HINT + "\r\n\r\n"; } private void getUpnpDeviceSearch(SelectionKey selectionKey) { sendBuffer = ByteBuffer.wrap(getMSearch(DEVICES[deviceIndex % DEVICES.length]).getBytes()); if (sendBuffer.limit() > MULTICAST_BUFFER_SEND_SIZE_MAX) { throw new IllegalArgumentException("Send buffer bigger than " + MULTICAST_BUFFER_SEND_SIZE_MAX + " (" + sendBuffer.limit() + ")"); } deviceIndex++; if (deviceIndex > 0 && deviceIndex % DEVICES.length == 0) { setState(State.SNOOZING, selectionKey); } } @Override public void run() { while (true) { try { upnpLoop(); break; } catch (BindException e) { log.warn("Binding failed: {}, trying again in 5 minutes", e.getMessage()); try { Thread.sleep(SERVICE_RETRY_DURATION); } catch (InterruptedException _) { Thread.currentThread().interrupt(); break; } } } } private void upnpLoop() throws BindException { multicastAddress = new InetSocketAddress(MULTICAST_IP, MULTICAST_PORT); receiveBuffer = ByteBuffer.allocate(MULTICAST_BUFFER_RECV_SIZE); try (var selector = Selector.open(); var channel = DatagramChannel.open(StandardProtocolFamily.INET) .bind(new InetSocketAddress(InetAddress.getByName(localIpAddress), 0)) ) { channel.configureBlocking(false); var registerSelectionKeys = channel.register(selector, SelectionKey.OP_WRITE); state = State.BROADCASTING; while (true) { if (state == State.BROADCASTING) { getUpnpDeviceSearch(registerSelectionKeys); } if (state == State.SNOOZING) { attemptFindExternalAddressUsingDnsIfNeeded(); } selector.select(getSelectorTimeout()); if (Thread.interrupted()) { setState(State.INTERRUPTED, registerSelectionKeys); break; } if (state == State.CONNECTED) { var refreshed = refreshPorts(); if (refreshed) { statusNotificationService.setNatStatus(NatStatus.UPNP); } else { log.error("UPNP port refresh failed, starting again..."); statusNotificationService.setNatStatus(NatStatus.FIREWALLED); setState(State.BROADCASTING, registerSelectionKeys); continue; } } handleSelection(selector, registerSelectionKeys); } cleanupDevice(); } catch (ClosedByInterruptException _) { log.debug("Interrupted, bailing out..."); } catch (BindException e) { throw e; } catch (IOException e) { log.error("Error: ", e); } } private void handleSelection(Selector selector, SelectionKey registerSelectionKeys) throws BindException { var selectedKeys = selector.selectedKeys().iterator(); if (!selectedKeys.hasNext() && state != State.CONNECTED) { setState(State.BROADCASTING, registerSelectionKeys); } while (selectedKeys.hasNext()) { try { var key = selectedKeys.next(); selectedKeys.remove(); if (!key.isValid()) { continue; } if (key.isReadable()) { read(key); } else if (key.isWritable()) { write(key); } } catch (BindException e) { throw e; } catch (IOException e) { log.warn("Glitch, continuing...", e); } } } private int getSelectorTimeout() { return switch (state) { case WAITING -> MULTICAST_MAX_WAIT_TIME; case SNOOZING -> MULTICAST_MAX_WAIT_SNOOZE; case CONNECTED -> PORT_DURATION - PORT_DURATION_ANTICIPATION; default -> 0; }; } private void setState(State newState, SelectionKey key) { state = newState; switch (state) { case BROADCASTING -> key.interestOps(SelectionKey.OP_WRITE); case WAITING -> key.interestOps(SelectionKey.OP_READ); case CONNECTING, CONNECTED, SNOOZING -> key.interestOps(0); case INTERRUPTED -> log.debug("Interrupted"); } } private void read(SelectionKey key) throws IOException { assert state == State.WAITING; @SuppressWarnings("resource") var channel = (DatagramChannel) key.channel(); var routerAddress = channel.receive(receiveBuffer); // XXX: handle multiple responses if there's several routers. use 'rootdevice' to test device = Device.from(routerAddress, receiveBuffer); if (device.isValid()) { setState(State.CONNECTING, key); device.addControlPoint(); if (device.hasControlPoint()) { setState(State.CONNECTED, key); var portsAdded = refreshPorts(); var externalAddressFound = findExternalIpAddressUsingUpnp(); if (!externalAddressFound) { externalAddressFound = findExternalIpAddressUsingDns(); } publisher.publishEvent(new UpnpEvent(localPort, portsAdded, externalAddressFound)); statusNotificationService.setNatStatus(portsAdded ? NatStatus.UPNP : NatStatus.FIREWALLED); } else { // Device has no control point, or it's unreachable; keep searching setState(State.WAITING, key); } } else { // Device has no location or address, keep searching setState(State.WAITING, key); } // XXX: a device must be blacklisted for a while if the above 2 steps fail for it, otherwise we'll run into it again if the user has a broken router on the same LAN receiveBuffer.clear(); // ready to read again } private void write(SelectionKey key) throws IOException { assert state == State.BROADCASTING; @SuppressWarnings("resource") var channel = (DatagramChannel) key.channel(); channel.send(sendBuffer, multicastAddress); setState(State.WAITING, key); sendBuffer.clear(); } private boolean refreshPorts() { // XXX: add a mechanism if the localport is already taken on the router? var refreshed = device.addPortMapping(localIpAddress, localPort, localPort, PORT_DURATION / 1000, Protocol.TCP); refreshed &= device.addPortMapping(localIpAddress, localPort, localPort, PORT_DURATION / 1000, Protocol.UDP); if (controlPort != 0) { refreshed &= device.addPortMapping(localIpAddress, controlPort, controlPort, PORT_DURATION / 1000, Protocol.TCP); } if (refreshed) { log.info("UPNP ports added/refreshed successfully."); } else { log.warn("Failed to add/refresh UPNP ports. Incoming connections won't be accepted."); } return refreshed; } private void cleanupDevice() { if (device != null && device.hasControlPoint()) { device.removeAllPortMapping(); } } private void attemptFindExternalAddressUsingDnsIfNeeded() { // If no UPNP seems available after the first try, attempt // to find the external address using OpenDNS then keep trying // with UPNP (even though it's unlikely to work). This allows at least // to have the IP in the ShortInvite, which is important for reachability. if (!externalIpAddressFound && findExternalIpAddressUsingDns()) { externalIpAddressFound = true; publisher.publishEvent(new UpnpEvent(localPort, false, true)); statusNotificationService.setNatStatus(NatStatus.FIREWALLED); } } private boolean findExternalIpAddressUsingUpnp() { return updateExternalIpAddress(device.getExternalIpAddress()); } private boolean findExternalIpAddressUsingDns() { var externalIpAddress = externalIpResolver.find(); if (externalIpAddress == null) { return false; } return updateExternalIpAddress(externalIpAddress); } private boolean updateExternalIpAddress(String externalIpAddress) { try (var ignored = new DatabaseSession(databaseSessionManager)) { var peerAddress = PeerAddress.from(externalIpAddress, localPort); if (peerAddress.isInvalid()) { log.warn("External IP is invalid: {}", externalIpAddress); return false; } if (!peerAddress.isExternal()) { log.warn("External IP is not external: {}", externalIpAddress); return false; } locationService.updateConnection(locationService.findOwnLocation().orElseThrow(), peerAddress); return true; } } } ================================================ FILE: app/src/main/java/io/xeres/app/net/upnp/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * UPNP implementation. *

* This is a limited UPNP implementation that finds an active router on the network and sets the * proper port forwarding. There is no active listening capabilities (for example, detecting if some new device was turned on) * because the use cases for it are limited, and it directly clashes with the OS (for example Windows * is already listening on port 1900). Using the OS' UPNP stack would require the use of JNI on Windows, Linux * has too many possible setups and OSX is unknown. *

* The goal of this implementation is to be fast and useful in 99% of cases. *

* Theory of operation: *

    *
  • UPNPService launches a thread
  • *
  • the thread broadcasts a MSEARCH HTTPu query as multicast on port 1900
  • *
  • a router answers with its location URL
  • *
  • the thread connects to the control point and retrieves the control point URL which is described in an XML file
  • *
  • further commands (add mapping, removing mapping, get external ip address) are sent to that control point URL using SOAP
  • *
*/ package io.xeres.app.net.upnp; ================================================ FILE: app/src/main/java/io/xeres/app/net/util/NetworkMode.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.util; public enum NetworkMode { PUBLIC, // DHT & Discovery PRIVATE, // Discovery only INVERTED, // DHT only DARKNET; // None public static boolean isDiscoverable(NetworkMode networkMode) { return switch (networkMode) { case PUBLIC, PRIVATE -> true; case INVERTED, DARKNET -> false; }; } public static boolean hasDht(NetworkMode networkMode) { return switch (networkMode) { case PUBLIC, INVERTED -> true; case PRIVATE, DARKNET -> false; }; } public static NetworkMode getNetworkMode(int vsDisc, int vsDht) { if (vsDisc == 2 && vsDht == 2) { return PUBLIC; } else if (vsDisc == 2) { return PRIVATE; } else if (vsDht == 2) { return INVERTED; } return DARKNET; } } ================================================ FILE: app/src/main/java/io/xeres/app/package-info.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * Server part. *

Uses Spring Boot and a REST API. */ package io.xeres.app; ================================================ FILE: app/src/main/java/io/xeres/app/properties/DatabaseProperties.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.properties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; @Configuration @ConfigurationProperties(prefix = "xrs.db") public class DatabaseProperties { private Integer cacheSize; private Integer maxCompactTime; public Integer getCacheSize() { return cacheSize; } public void setCacheSize(Integer cacheSize) { this.cacheSize = cacheSize; } public Integer getMaxCompactTime() { return maxCompactTime; } public void setMaxCompactTime(Integer maxCompactTime) { this.maxCompactTime = maxCompactTime; } } ================================================ FILE: app/src/main/java/io/xeres/app/properties/NetworkProperties.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.properties; import jakarta.annotation.PostConstruct; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.jmx.export.annotation.ManagedAttribute; import org.springframework.jmx.export.annotation.ManagedResource; @Configuration @ConfigurationProperties(prefix = "xrs.network") @ManagedResource(objectName = "io.xeres:type=NetworkProperties", description = "Shows the network configuration") public class NetworkProperties { /** * Enables the slicing of packets. This is only available on new Retroshare packets and only if both ends * of the connection agree to use them. Note that Xeres always accepts sliced packets. */ private boolean packetSlicing; /** * Enables the grouping of packets. Only works if packet slicing is enabled. */ private boolean packetGrouping; /** * Sets the encrypted tunnel format. *

    *
  • ChaCha20 with HMAC SHA-256 {@code "chacha20-sha256"}: the default of Retroshare
  • *
  • ChaCha20 with Poly1305 authenticator {@code "chacha20-poly1305"}: should be accepted by Retroshare, but untested
  • *
*/ private String tunnelEncryption = TUNNEL_ENCRYPTION_CHACHA20_SHA256; public static final String TUNNEL_ENCRYPTION_CHACHA20_SHA256 = "chacha20-sha256"; public static final String TUNNEL_ENCRYPTION_CHACHA20_POLY1305 = "chacha20-poly1305"; private String fileTransferStrategy = FILE_TRANSFER_STRATEGY_LINEAR; public static final String FILE_TRANSFER_STRATEGY_LINEAR = "linear"; public static final String FILE_TRANSFER_STRATEGY_RANDOM = "random"; @PostConstruct private void checkConsistency() { if (packetGrouping && !packetSlicing) { throw new IllegalStateException("'network.packet-grouping' property cannot be enabled without 'network.packet-slicing'"); } } public String getFeatures() { return "packet slicing: " + packetSlicing + ", " + "packet grouping: " + packetGrouping; } @ManagedAttribute(description = "If the packet slicing is enabled for transmission") public boolean isPacketSlicing() { return packetSlicing; } public void setPacketSlicing(boolean packetSlicing) { this.packetSlicing = packetSlicing; } @ManagedAttribute(description = "If the packet grouping is enabled for transmission") public boolean isPacketGrouping() { return packetGrouping; } public void setPacketGrouping(boolean packetGrouping) { this.packetGrouping = packetGrouping; } @ManagedAttribute(description = "The encryption used for tunnels") public String getTunnelEncryption() { return tunnelEncryption; } public void setTunnelEncryption(String tunnelEncryption) { this.tunnelEncryption = tunnelEncryption; } @ManagedAttribute(description = "The file transfer strategy") public String getFileTransferStrategy() { return fileTransferStrategy; } public void setFileTransferStrategy(String fileTransferStrategy) { this.fileTransferStrategy = fileTransferStrategy; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/BoardMessageService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.service.board.BoardRsService; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.apache.commons.collections4.SetUtils; import org.springframework.context.annotation.Lazy; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; @Service public class BoardMessageService { private final BoardRsService boardRsService; private final IdentityService identityService; public BoardMessageService(@Lazy BoardRsService boardRsService, IdentityService identityService) { this.boardRsService = boardRsService; this.identityService = identityService; } public Map getAuthorsMapFromMessages(Page boardMessages) { var authors = boardMessages.stream() .map(BoardMessageItem::getAuthorGxsId) .collect(Collectors.toSet()); return identityService.findAll(authors).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity())); } public Map getMessagesMapFromSummaries(long groupId, Page boardMessages) { var msgIds = boardMessages.stream() .map(BoardMessageItem::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = boardMessages.stream() .map(BoardMessageItem::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); return boardRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(BoardMessageItem::getMsgId, Function.identity())); } public Map getMessagesMapFromMessages(List boardMessages) { var msgIds = boardMessages.stream() .map(BoardMessageItem::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = boardMessages.stream() .map(BoardMessageItem::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); return boardRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(BoardMessageItem::getMsgId, Function.identity())); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/CapabilityService.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.application.autostart.AutoStart; import io.xeres.common.rest.config.Capabilities; import org.springframework.stereotype.Service; import java.util.HashSet; import java.util.Set; /** * Service that informs about the capabilities supported by the system. * Usually dependent on the platform or installation. */ @Service public class CapabilityService { private final AutoStart autoStart; public CapabilityService(AutoStart autoStart) { this.autoStart = autoStart; } /** * Informs about the capabilities supported by the system. * * @return a set of the supported capabilities. */ public Set getCapabilities() { Set capabilities = new HashSet<>(); if (autoStart.isSupported()) { capabilities.add(Capabilities.AUTOSTART); } return capabilities; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/ChannelMessageService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.service.channel.ChannelRsService; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.apache.commons.collections4.SetUtils; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; @Service public class ChannelMessageService { private final ChannelRsService channelRsService; private final IdentityService identityService; public ChannelMessageService(ChannelRsService channelRsService, IdentityService identityService) { this.channelRsService = channelRsService; this.identityService = identityService; } public Map getAuthorsMapFromMessages(Page channelMessages) { var authors = channelMessages.stream() .map(ChannelMessageItem::getAuthorGxsId) .collect(Collectors.toSet()); return identityService.findAll(authors).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity())); } public Map getMessagesMapFromSummaries(long groupId, Page channelMessages) { var msgIds = channelMessages.stream() .map(ChannelMessageItem::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = channelMessages.stream() .map(ChannelMessageItem::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); return channelRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(ChannelMessageItem::getMsgId, Function.identity())); } public Map getMessagesMapFromMessages(List channelMessages) { var msgIds = channelMessages.stream() .map(ChannelMessageItem::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = channelMessages.stream() .map(ChannelMessageItem::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); return channelRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(ChannelMessageItem::getMsgId, Function.identity())); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/ContactService.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.location.Availability; import io.xeres.common.rest.contact.Contact; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @Service public class ContactService { private final ProfileService profileService; private final IdentityService identityService; public ContactService(@Lazy ProfileService profileService, IdentityService identityService) { this.profileService = profileService; this.identityService = identityService; } @Transactional(readOnly = true) public List getContacts() { // Send identities and profiles. var profiles = profileService.getAllProfiles().stream() .collect(Collectors.toMap(Profile::getId, profile -> profile)); var identities = identityService.getAll(); List contacts = new ArrayList<>(profiles.size() + identities.size()); profiles.forEach((key, value) -> contacts.add(new Contact(value.getName(), key, 0L, getAvailability(value), value.isAccepted()))); identities.forEach(identity -> contacts.add(new Contact( identity.getName(), identity.getProfile() != null ? identity.getProfile().getId() : 0L, identity.getId(), getAvailability(identity.getProfile()), isAccepted(identity.getProfile())))); return contacts; } public List getContactsForProfileId(long profileId) { var contacts = identityService.findAllByProfileId(profileId); return toContacts(contacts); } public List toContacts(List identities) { List contacts = new ArrayList<>(identities.size()); identities.forEach(identity -> contacts.add(new Contact( identity.getName(), identity.getProfile() != null ? identity.getProfile().getId() : 0L, identity.getId(), getAvailability(identity.getProfile()), isAccepted(identity.getProfile())))); return contacts; } public Contact toContact(Profile profile) { return new Contact(profile.getName(), profile.getId(), 0L, getAvailability(profile), isAccepted(profile)); } private Availability getAvailability(Profile profile) { if (profile != null) { return profile.getBestAvailability(); } return Availability.OFFLINE; } private boolean isAccepted(Profile profile) { return profile != null && profile.isAccepted(); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/ForumMessageService.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.forum.ForumMessageItemSummary; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.service.forum.ForumRsService; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import org.apache.commons.collections4.SetUtils; import org.springframework.context.annotation.Lazy; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; /** * Message helper service because they're hard to retrieve otherwise. */ @Service public class ForumMessageService { private final ForumRsService forumRsService; private final IdentityService identityService; public ForumMessageService(@Lazy ForumRsService forumRsService, IdentityService identityService) { this.forumRsService = forumRsService; this.identityService = identityService; } public Map getAuthorsMapFromSummaries(Page forumMessages) { var authors = forumMessages.stream() .map(ForumMessageItemSummary::getAuthorGxsId) .collect(Collectors.toSet()); return identityService.findAll(authors).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity())); } public Map getAuthorsMapFromMessages(List forumMessages) { var authors = forumMessages.stream() .map(ForumMessageItem::getAuthorGxsId) .collect(Collectors.toSet()); return identityService.findAll(authors).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity())); } public Map getMessagesMapFromSummaries(long groupId, Page forumMessages) { var msgIds = forumMessages.stream() .map(ForumMessageItemSummary::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = forumMessages.stream() .map(ForumMessageItemSummary::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); return forumRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(ForumMessageItem::getMsgId, Function.identity())); } public Map getMessagesMapFromMessages(long groupId, List forumMessages) { var msgIds = forumMessages.stream() .map(ForumMessageItem::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = forumMessages.stream() .map(ForumMessageItem::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); // XXX: update? why isn't this used? return forumRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(ForumMessageItem::getMsgId, Function.identity())); } public Map getMessagesMapFromMessages(List forumMessages) { var msgIds = forumMessages.stream() .map(ForumMessageItem::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = forumMessages.stream() .map(ForumMessageItem::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var originalMsgIds = forumMessages.stream() .map(ForumMessageItem::getOriginalMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var map = forumRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(ForumMessageItem::getMsgId, Function.identity())); if (!originalMsgIds.isEmpty()) { forumRsService.findAllOldMessages(originalMsgIds).forEach(forumMessageItem -> map.put(forumMessageItem.getMsgId(), forumMessageItem)); } return map; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/GeoIpService.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import com.maxmind.geoip2.DatabaseReader; import com.maxmind.geoip2.exception.GeoIp2Exception; import io.xeres.common.geoip.Country; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.IOException; import java.net.InetAddress; @Service public class GeoIpService { private static final Logger log = LoggerFactory.getLogger(GeoIpService.class); private final DatabaseReader databaseReader; public GeoIpService(DatabaseReader databaseReader) { this.databaseReader = databaseReader; } public Country getCountry(String ipAddress) { try { var country = databaseReader.country(InetAddress.getByName(ipAddress)); return Country.valueOf(country.country().isoCode()); } catch (IOException | GeoIp2Exception | IllegalArgumentException e) { log.error("No country found for IP {}: {}", ipAddress, e.getMessage()); return null; } } } ================================================ FILE: app/src/main/java/io/xeres/app/service/IdentityService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.database.repository.GxsIdentityRepository; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.dto.identity.IdentityConstants; import io.xeres.common.id.GxsId; import io.xeres.common.identity.Type; import org.springframework.data.domain.Limit; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; @Service public class IdentityService { private final GxsIdentityRepository gxsIdentityRepository; public IdentityService(GxsIdentityRepository gxsIdentityRepository) { this.gxsIdentityRepository = gxsIdentityRepository; } public Optional findById(long id) { return gxsIdentityRepository.findById(id); } public boolean hasOwnIdentity() { return gxsIdentityRepository.findById(IdentityConstants.OWN_IDENTITY_ID).isPresent(); } public IdentityGroupItem getOwnIdentity() { return gxsIdentityRepository.findById(IdentityConstants.OWN_IDENTITY_ID).orElseThrow(() -> new IllegalStateException("Missing own gxsId")); } public List findAllByName(String name) { return gxsIdentityRepository.findAllByName(name); } public Optional findByGxsId(GxsId gxsId) { return gxsIdentityRepository.findByGxsId(gxsId); } public List findAllByType(Type type) { return gxsIdentityRepository.findAllByType(type); } public List getAll() { return gxsIdentityRepository.findAll(); } public List findAll(Set gxsIds) { return gxsIdentityRepository.findAllByGxsIdIn(gxsIds); } public List findAllSubscribed() { return gxsIdentityRepository.findAllBySubscribedIsTrue(); } public List findAllByProfileId(long id) { return gxsIdentityRepository.findAllByProfileId(id); } @Transactional public IdentityGroupItem save(IdentityGroupItem identityGroupItem) { return gxsIdentityRepository.save(identityGroupItem); } public List findIdentitiesToValidate(int limit) { return gxsIdentityRepository.findAllByNextValidationNotNullAndNextValidationBeforeOrderByNextValidationDesc(Instant.now(), limit <= 0 ? Limit.unlimited() : Limit.of(limit)); } public void delete(IdentityGroupItem identityGroupItem) { gxsIdentityRepository.delete(identityGroupItem); } @Transactional(propagation = Propagation.NEVER) public byte[] signData(IdentityGroupItem identityGroupItem, byte[] data) { return RSA.sign(identityGroupItem.getAdminPrivateKey(), data); } @Transactional public void removeAllLinksToProfile(long profileId) { var allByProfileId = gxsIdentityRepository.findAllByProfileId(profileId); allByProfileId.forEach(identityGroupItem -> identityGroupItem.setProfile(null)); // XXX: we should possibly refresh the list with contactNotificationService... gxsIdentityRepository.saveAll(allByProfileId); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/InfoService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.netty.util.ResourceLeakDetector; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.common.util.ByteUnitUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.info.BuildProperties; import org.springframework.core.env.Environment; import org.springframework.stereotype.Service; import java.lang.management.ManagementFactory; import java.nio.charset.Charset; import java.time.Duration; import java.util.Locale; import java.util.stream.Collectors; @Service public class InfoService { private static final Logger log = LoggerFactory.getLogger(InfoService.class); private final BuildProperties buildProperties; private final Environment environment; private final NetworkProperties networkProperties; private final RsServiceRegistry rsServiceRegistry; public InfoService(BuildProperties buildProperties, Environment environment, NetworkProperties networkProperties, RsServiceRegistry rsServiceRegistry) { this.buildProperties = buildProperties; this.environment = environment; this.networkProperties = networkProperties; this.rsServiceRegistry = rsServiceRegistry; } public void showStartupInfo() { log.info("Startup sequence ({}, {}, {})", buildProperties.getName(), buildProperties.getVersion(), environment.getActiveProfiles().length > 0 ? environment.getActiveProfiles()[0] : "prod"); } public void showCapabilities() { log.info("OS: {} ({})", System.getProperty("os.name"), System.getProperty("os.arch")); log.info("JRE: {} {} ({})", System.getProperty("java.vendor"), System.getProperty("java.version"), System.getProperty("java.home")); log.info("Charset: {}", Charset.defaultCharset()); log.info("Language: {}", Locale.getDefault().getLanguage()); log.info("TCP/IP stack state: {}", StringUtils.defaultString(System.getProperty("java.net.preferIPv4Stack")).equals("true") ? "sane" : "broken"); log.debug("Working directory: {}", log.isDebugEnabled() ? System.getProperty("user.dir") : ""); log.info("Number of processor threads: {}", Runtime.getRuntime().availableProcessors()); log.info("Memory allocated for the JVM: {}", ByteUnitUtils.fromBytes(Runtime.getRuntime().totalMemory())); log.info("Maximum allocatable memory: {}", ByteUnitUtils.fromBytes(Runtime.getRuntime().maxMemory())); } public void showFeatures() { if (log.isDebugEnabled()) { log.debug("Network features: {}", networkProperties.getFeatures()); log.debug("Services: {}", rsServiceRegistry.getServices().stream().map(rsService -> rsService.getServiceType().getName()).collect(Collectors.joining(", "))); } } public void showDebug() { if (!log.isDebugEnabled()) { return; } if (ResourceLeakDetector.isEnabled()) { log.debug("Netty leak detector level: {}", ResourceLeakDetector.getLevel()); } else { log.debug("Netty leak detector disabled"); } } /** * Gets the uptime since the application startup. * * @return the uptime duration */ public Duration getUptime() { var startTime = ManagementFactory.getRuntimeMXBean().getStartTime(); var currentTime = System.currentTimeMillis(); var uptimeMillis = currentTime - startTime; return Duration.ofMillis(uptimeMillis); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/LocationService.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.crypto.rsid.RSSerialVersion; import io.xeres.app.crypto.x509.X509; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.repository.LocationRepository; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.net.util.NetworkMode; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.location.Availability; import io.xeres.common.protocol.NetMode; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.UnknownHostException; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.spec.InvalidKeySpecException; import java.time.Instant; import java.util.*; import static io.xeres.app.net.util.NetworkMode.hasDht; import static io.xeres.app.net.util.NetworkMode.isDiscoverable; import static io.xeres.app.service.ResourceCreationState.*; import static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID; import static java.util.function.Predicate.not; @Service public class LocationService { private static final Logger log = LoggerFactory.getLogger(LocationService.class); private static final int KEY_SIZE = 3072; private final SettingsService settingsService; private final ProfileService profileService; private final PeerConnectionManager peerConnectionManager; private final LocationRepository locationRepository; private Slice locations; private int pageIndex; private int connectionIndex = -1; public LocationService(SettingsService settingsService, ProfileService profileService, PeerConnectionManager peerConnectionManager, LocationRepository locationRepository) { this.settingsService = settingsService; this.profileService = profileService; this.peerConnectionManager = peerConnectionManager; this.locationRepository = locationRepository; } KeyPair generateLocationKeys() { if (settingsService.getLocationPrivateKeyData() != null) { return null; } log.info("Generating keys, algorithm: RSA, bits: {} ...", KEY_SIZE); var keyPair = RSA.generateKeys(KEY_SIZE); log.info("Successfully generated key pair"); return keyPair; } byte[] generateLocationCertificate(byte[] locationPublicKeyData) throws CertificateException, InvalidKeySpecException, NoSuchAlgorithmException, IOException { log.info("Generating certificate..."); var x509Certificate = X509.generateCertificate( PGP.getPGPSecretKey(settingsService.getSecretProfileKey()), RSA.getPublicKey(locationPublicKeyData), "CN=" + Long.toHexString(profileService.getOwnProfile().getPgpIdentifier()).toUpperCase(Locale.ROOT), // older RS use a random string I think, like 12:34:55:44:4e:44:99:23 "CN=-", new Date(0), new Date(0), RSSerialVersion.V07_0001.serialNumber() ); log.info("Successfully generated certificate"); return x509Certificate.getEncoded(); } @Transactional public ResourceCreationState generateOwnLocation(String name) { if (!settingsService.isOwnProfilePresent()) { log.error("Cannot create a location without a profile; Create a profile first"); return FAILED; } var ownProfile = profileService.getOwnProfile(); if (!ownProfile.getLocations().isEmpty()) { return ALREADY_EXISTS; } var keyPair = generateLocationKeys(); byte[] x509Certificate; try { x509Certificate = generateLocationCertificate(keyPair.getPublic().getEncoded()); createOwnLocation(name, keyPair, x509Certificate); } catch (InvalidKeySpecException | NoSuchAlgorithmException | IOException | CertificateException e) { log.error("Failed to generate certificate: {}", e.getMessage()); return FAILED; } return CREATED; } @Transactional public void createOwnLocation(String name, KeyPair keyPair, byte[] x509Certificate) throws CertificateException { settingsService.saveLocationKeys(keyPair); settingsService.saveLocationCertificate(x509Certificate); var location = Location.createLocation(name); location.setLocationIdentifier(X509.getLocationIdentifier(X509.getCertificate(settingsService.getLocationCertificate()))); profileService.getOwnProfile().addLocation(location); locationRepository.save(location); } /** * Find the location. * * @param locationIdentifier the SSL identifier * @return the location */ public Optional findLocationByLocationIdentifier(LocationIdentifier locationIdentifier) { return locationRepository.findByLocationIdentifier(locationIdentifier); } public Optional findOwnLocation() { return locationRepository.findById(OWN_LOCATION_ID); } public Optional findLocationById(long id) { return locationRepository.findById(id); } public boolean isServiceSupported(Location location, int serviceId) { return peerConnectionManager.isServiceSupported(location, serviceId); } public boolean hasOwnLocation() { return findOwnLocation().isPresent(); } public void markAllConnectionsAsDisconnected() { locationRepository.putAllConnectedToFalse(); } public long countLocations() { return locationRepository.count(); } @Transactional public void markAsAvailable() { var ownLocation = findOwnLocation().orElseThrow(); ownLocation.setAvailability(Availability.AVAILABLE); locationRepository.save(ownLocation); } @Transactional public void setConnected(Location location, SocketAddress socketAddress) { updateConnection(location, socketAddress); // XXX: is this the right place? maybe it should be done in discovery service location.setConnected(true); // @Transactional should save it automatically, but I'm not sure when exactly. To detect simultaneous connections, // we need to make sure that this method has an updated location. locationRepository.save(location); } private static void updateConnection(Location location, SocketAddress socketAddress) { var inetSocketAddress = (InetSocketAddress) socketAddress; location.getConnections().stream() .filter(conn -> conn.getAddress().split(":")[0].equals(inetSocketAddress.getHostString())) .findFirst() .ifPresent(connection -> connection.setLastConnected(Instant.now())); } @Transactional public void setDisconnected(Location location) { location.setConnected(false); locationRepository.save(location); // This is needed because PeerHandler calls it from a non managed context } @Transactional public void setAvailability(Location location, Availability availability) { location.setAvailability(availability); locationRepository.save(location); } @Transactional public Location update(Location location, String locationName, NetMode netMode, String version, NetworkMode networkMode, List peerAddresses) { location.setName(locationName); location.setNetMode(netMode); location.setVersion(version); location.setDiscoverable(isDiscoverable(networkMode)); location.setDht(hasDht(networkMode)); peerAddresses.forEach(peerAddress -> updateConnection(location, peerAddress)); return locationRepository.save(location); } @Transactional public List getConnectionsToConnectTo(int simultaneousLocations) { var ownConnection = findOwnLocation().orElseThrow() .getConnections() .stream() .filter(Connection::isExternal) .findFirst().orElse(null); var ownIp = ownConnection != null ? ownConnection.getIp() : null; locations = locationRepository.findAllByConnectedFalse(PageRequest.of(getPageIndex(), simultaneousLocations, Sort.by("lastConnected").descending())); return locations.stream() .filter(not(Location::isOwn)) .flatMap(location -> location.getBestConnection(getConnectionIndex(), ownIp)) .limit(simultaneousLocations) .toList(); } public Slice getUnconnectedLocationsWithDht(Pageable pageable) { return locationRepository.findAllByConnectedFalseAndDhtTrue(pageable); } public List getConnectedLocations() { return locationRepository.findAllByConnectedTrue(); } public List getAllLocations() { return locationRepository.findAll(); } @Transactional public void updateConnection(Location location, PeerAddress peerAddress) { if (peerAddress.isInvalid()) { return; } if (location.isOwn()) { updateOwnConnection(location, peerAddress); } else { updateOtherConnection(location, peerAddress); } } private static void updateOwnConnection(Location location, PeerAddress peerAddress) { var updated = false; for (var connection : location.getConnections()) { updated = updateAddressIfSameType(peerAddress, connection); if (updated) { break; } } if (!updated) { location.addConnection(Connection.from(peerAddress)); } } private static void updateOtherConnection(Location location, PeerAddress peerAddress) { location.addConnection(Connection.from(peerAddress)); } public String getHostname() throws UnknownHostException { return InetAddress.getLocalHost().getHostName(); } public String getUsername() { var username = System.getProperty("user.name"); if (StringUtils.isEmpty(username)) { throw new NoSuchElementException("No logged in username"); } return username; } private static boolean updateAddressIfSameType(PeerAddress from, Connection to) { if ((from.isExternal() && to.isExternal()) || (!from.isExternal() && !to.isExternal())) { to.setAddress(from.getAddress().orElseThrow()); return true; } return false; } private int getPageIndex() { if (locations == null || locations.isLast()) { pageIndex = 0; connectionIndex++; } else { pageIndex++; } return pageIndex; } private int getConnectionIndex() { return connectionIndex; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/MessageService.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.common.id.Identifier; import io.xeres.common.message.MessageType; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; import java.util.Objects; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; import static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE; @Service public class MessageService { private final SimpMessageSendingOperations messagingTemplate; public MessageService(SimpMessageSendingOperations messagingTemplate) { this.messagingTemplate = messagingTemplate; } public void sendToConsumers(String path, MessageType type, Object payload) { var headers = buildMessageHeaders(type); sendToConsumers(path, headers, payload); } public void sendToConsumers(String path, MessageType type, long destination, Object payload) { var headers = buildMessageHeaders(type, String.valueOf(destination)); sendToConsumers(path, headers, payload); } public void sendToConsumers(String path, MessageType type, Identifier destination, Object payload) { var headers = buildMessageHeaders(type, destination.toString()); sendToConsumers(path, headers, payload); } private void sendToConsumers(String path, Map headers, Object payload) { Objects.requireNonNull(payload, "Payload *must* be an object that can be serialized to JSON"); messagingTemplate.convertAndSend(path, payload, headers); } private static Map buildMessageHeaders(MessageType messageType, String id) { Map headers = new HashMap<>(); headers.put(MESSAGE_TYPE, messageType.name()); if (id != null) { headers.put(DESTINATION_ID, id); } return headers; } private static Map buildMessageHeaders(MessageType messageType) { return buildMessageHeaders(messageType, null); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/NetworkService.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.application.events.IpChangedEvent; import io.xeres.app.application.events.LocationReadyEvent; import io.xeres.app.application.events.NetworkReadyEvent; import io.xeres.app.application.events.UpnpEvent; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.settings.Settings; import io.xeres.app.net.bdisc.BroadcastDiscoveryService; import io.xeres.app.net.dht.DhtService; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.net.upnp.UPNPService; import io.xeres.common.events.ConnectWebSocketsEvent; import io.xeres.common.properties.StartupProperties; import io.xeres.common.protocol.ip.IP; import org.apache.commons.lang3.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import static io.xeres.common.properties.StartupProperties.Property.CONTROL_PORT; import static io.xeres.common.properties.StartupProperties.Property.SERVER_PORT; @Service public class NetworkService { private static final Logger log = LoggerFactory.getLogger(NetworkService.class); private String localIpAddress; private final ProfileService profileService; private final LocationService locationService; private final IdentityService identityService; private final PeerService peerService; private final UPNPService upnpService; private final BroadcastDiscoveryService broadcastDiscoveryService; private final DhtService dhtService; private final SettingsService settingsService; private final DatabaseSessionManager databaseSessionManager; private final ApplicationEventPublisher publisher; private final AtomicBoolean running = new AtomicBoolean(); private boolean startWhenPossible; public NetworkService(ProfileService profileService, LocationService locationService, IdentityService identityService, PeerService peerService, UPNPService upnpService, BroadcastDiscoveryService broadcastDiscoveryService, DhtService dhtService, SettingsService settingsService, DatabaseSessionManager databaseSessionManager, ApplicationEventPublisher publisher) { this.profileService = profileService; this.locationService = locationService; this.identityService = identityService; this.peerService = peerService; this.upnpService = upnpService; this.broadcastDiscoveryService = broadcastDiscoveryService; this.dhtService = dhtService; this.settingsService = settingsService; this.databaseSessionManager = databaseSessionManager; this.publisher = publisher; } public boolean checkReadiness() { if (profileService.hasOwnProfile() && locationService.hasOwnLocation() && identityService.hasOwnIdentity()) { configure(); return true; } return false; } private void configure() { configureLocalPort(); publisher.publishEvent(new LocationReadyEvent()); } private int configureLocalPort() { if (settingsService.getLocalPort() == 0) { var localPort = Optional.ofNullable(StartupProperties.getInteger(SERVER_PORT)).orElseGet(IP::getFreeLocalPort); if (localPort != 0) { log.info("Using local port {}", localPort); settingsService.setLocalPort(localPort); } else { log.warn("No network available to configure the local port"); } } return settingsService.getLocalPort(); } public String getLocalIpAddress() { return localIpAddress; } public int getPort() { return settingsService.getLocalPort(); } public void start() { if (running.compareAndSet(false, true)) { localIpAddress = IP.getLocalIpAddress(); var localPort = configureLocalPort(); locationService.markAllConnectionsAsDisconnected(); locationService.markAsAvailable(); log.info("Starting network services..."); var ownAddress = PeerAddress.from(localIpAddress, localPort); if (ownAddress.isValid()) { try (var _ = new DatabaseSession(databaseSessionManager)) { locationService.updateConnection(locationService.findOwnLocation().orElseThrow(), ownAddress); } startHelperServices(ownAddress.isLAN(), false); peerService.start(localPort); startWhenPossible = false; publisher.publishEvent(new NetworkReadyEvent()); publisher.publishEvent(new ConnectWebSocketsEvent()); } else { log.error("Local address is invalid: {}, can't start network services", localIpAddress); running.set(false); startWhenPossible = true; } } } private void startHelperServices(boolean isLan, boolean restart) { if (isLan) { if (settingsService.isUpnpEnabled()) { if (restart) { dhtService.stop(); upnpService.stop(); } upnpService.start(localIpAddress, settingsService.getLocalPort(), settingsService.isUpnpRemoteEnabled() ? Objects.requireNonNull(StartupProperties.getInteger(CONTROL_PORT)) : 0); } else { startDhtIfNeeded(restart); } startBroadcastDiscoveryIfNeeded(restart); } else { upnpService.stop(); broadcastDiscoveryService.stop(); startDhtIfNeeded(restart); } } public void stop() { startWhenPossible = false; if (running.compareAndSet(true, false)) { dhtService.stop(); upnpService.stop(); broadcastDiscoveryService.stop(); peerService.stop(); upnpService.waitForTermination(); } } public void compareSettingsAndApplyActions(Settings oldSettings, Settings newSettings) { applyBroadcastDiscovery(oldSettings, newSettings); applyDht(oldSettings, newSettings); applyUpnp(oldSettings, newSettings); applyTor(oldSettings, newSettings); applyI2p(oldSettings, newSettings); } @Scheduled(initialDelay = 2, fixedDelay = 1, timeUnit = TimeUnit.MINUTES) void checkIp() { if (!locationService.hasOwnLocation()) { return; } var newLocalIpAddress = IP.getLocalIpAddress(); if (newLocalIpAddress == null) { log.error("No TCP/IP stack available..."); return; } if (!newLocalIpAddress.equals(localIpAddress)) { log.warn("Local IP address changed: {} -> {}", localIpAddress, newLocalIpAddress); publisher.publishEvent(new IpChangedEvent(newLocalIpAddress)); } } @EventListener public void onIpChangedEvent(IpChangedEvent event) { log.warn("IP change event received, possibly restarting some services..."); if (!running.get() && startWhenPossible) { start(); return; } if (!IP.isRoutableIp(localIpAddress)) { stop(); startWhenPossible = true; return; } localIpAddress = event.localIpAddress(); startHelperServices(IP.isLanIp(localIpAddress), true); } @EventListener public void onUpnpEvent(UpnpEvent event) { if (event.portsForwarded()) { log.info("Ports forwarded on the router"); } else { log.info("Ports not forwarded on the router"); } if (!event.externalIpFound()) { log.warn("External IP address not found"); } // We start the DHT here because it's better when the incoming port is working first. // But it can still work without it. if (settingsService.isDhtEnabled()) { dhtService.start(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(), event.localPort()); } } private void startDhtIfNeeded(boolean restart) { if (settingsService.isDhtEnabled()) { if (restart) { dhtService.stop(); } dhtService.start(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(), settingsService.getLocalPort()); } } private void startBroadcastDiscoveryIfNeeded(boolean restart) { if (settingsService.isBroadcastDiscoveryEnabled()) { if (restart) { broadcastDiscoveryService.stop(); } broadcastDiscoveryService.start(localIpAddress, settingsService.getLocalPort()); } } private void applyBroadcastDiscovery(Settings oldSettings, Settings newSettings) { if (newSettings.isBroadcastDiscoveryEnabled() != oldSettings.isBroadcastDiscoveryEnabled()) { if (newSettings.isBroadcastDiscoveryEnabled()) { broadcastDiscoveryService.start(localIpAddress, newSettings.getLocalPort()); } else { broadcastDiscoveryService.stop(); } } } private void applyDht(Settings oldSettings, Settings newSettings) { if (newSettings.isDhtEnabled() != oldSettings.isDhtEnabled()) { if (newSettings.isDhtEnabled()) { dhtService.start(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(), newSettings.getLocalPort()); } else { dhtService.stop(); } } } private void applyUpnp(Settings oldSettings, Settings newSettings) { if (newSettings.isUpnpEnabled() != oldSettings.isUpnpEnabled()) { if (newSettings.isUpnpEnabled()) { upnpService.start(localIpAddress, newSettings.getLocalPort(), getRemotePortForUpnp(newSettings)); } else { upnpService.stop(); } } else if (newSettings.isUpnpRemoteEnabled() != oldSettings.isUpnpRemoteEnabled()) { upnpService.stop(); upnpService.start(localIpAddress, newSettings.getLocalPort(), getRemotePortForUpnp(newSettings)); } } private int getRemotePortForUpnp(Settings settings) { return (settings.isRemoteEnabled() && settings.isUpnpRemoteEnabled()) ? Objects.requireNonNull(StartupProperties.getInteger(CONTROL_PORT)) : 0; } private void applyTor(Settings oldSettings, Settings newSettings) { if (!Strings.CS.equals(newSettings.getTorSocksHost(), oldSettings.getTorSocksHost()) || newSettings.getTorSocksPort() != oldSettings.getTorSocksPort()) { peerService.restartTor(); } } private void applyI2p(Settings oldSettings, Settings newSettings) { if (!Strings.CS.equals(newSettings.getI2pSocksHost(), oldSettings.getI2pSocksHost()) || newSettings.getI2pSocksPort() != oldSettings.getI2pSocksPort()) { peerService.restartI2p(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/service/PeerService.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.net.peer.bootstrap.PeerI2pClient; import io.xeres.app.net.peer.bootstrap.PeerTcpClient; import io.xeres.app.net.peer.bootstrap.PeerTcpServer; import io.xeres.app.net.peer.bootstrap.PeerTorClient; import io.xeres.common.properties.StartupProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.concurrent.atomic.AtomicBoolean; import static io.xeres.common.properties.StartupProperties.Property.SERVER_ADDRESS; import static io.xeres.common.properties.StartupProperties.Property.SERVER_ONLY; @Service public class PeerService { private static final Logger log = LoggerFactory.getLogger(PeerService.class); private final PeerTcpClient peerTcpClient; private final PeerTorClient peerTorClient; private final PeerI2pClient peerI2pClient; private final PeerTcpServer peerTcpServer; private final SettingsService settingsService; private final AtomicBoolean running = new AtomicBoolean(); public PeerService(PeerTcpClient peerTcpClient, PeerTorClient peerTorClient, PeerI2pClient peerI2pClient, PeerTcpServer peerTcpServer, SettingsService settingsService) { this.peerTcpClient = peerTcpClient; this.peerTorClient = peerTorClient; this.peerI2pClient = peerI2pClient; this.peerTcpServer = peerTcpServer; this.settingsService = settingsService; } public void start(int localPort) { log.info("Starting peer services on port {}", localPort); running.lazySet(true); peerTcpServer.start(StartupProperties.getString(SERVER_ADDRESS), localPort); if (!StartupProperties.getBoolean(SERVER_ONLY, false)) { peerTcpClient.start(); } startTor(); startI2p(); } public void stop() { running.set(false); peerTcpServer.stop(); peerTcpClient.stop(); peerTorClient.stop(); peerI2pClient.stop(); } public void startTor() { if (settingsService.hasTorSocksConfigured()) { peerTorClient.start(); } } public void stopTor() { peerTorClient.stop(); } public void restartTor() { stopTor(); startTor(); } public void startI2p() { if (settingsService.hasI2pSocksConfigured()) { peerI2pClient.start(); } } public void stopI2p() { peerI2pClient.stop(); } public void restartI2p() { stopI2p(); startI2p(); } public boolean isRunning() { return running.get(); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/ProfileService.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.application.events.PeerDisconnectedEvent; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsid.RSId; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.repository.ProfileRepository; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.common.AppName; import io.xeres.common.dto.profile.ProfileConstants; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import io.xeres.common.rest.profile.ProfileKeyAttributes; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKey; import org.bouncycastle.openpgp.PGPSecretKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.security.InvalidKeyException; import java.security.Security; import java.util.*; import java.util.stream.Collectors; import static io.xeres.app.service.ResourceCreationState.*; import static io.xeres.common.Features.EXPERIMENTAL_EC; @Service public class ProfileService { private static final Logger log = LoggerFactory.getLogger(ProfileService.class); private static final int KEY_SIZE = EXPERIMENTAL_EC ? 255 : 3072; private static final int KEY_ID_LENGTH_MIN = ProfileConstants.NAME_LENGTH_MIN; private static final int KEY_ID_LENGTH_MAX = ProfileConstants.NAME_LENGTH_MAX; private static final String KEY_ID_SUFFIX = "(Generated by " + AppName.NAME + ")"; private final ProfileRepository profileRepository; private final SettingsService settingsService; private final PeerConnectionManager peerConnectionManager; private final Map> profilesToDelete = HashMap.newHashMap(2); private final ContactNotificationService contactNotificationService; public ProfileService(ProfileRepository profileRepository, SettingsService settingsService, PeerConnectionManager peerConnectionManager, ContactNotificationService contactNotificationService) { this.profileRepository = profileRepository; this.settingsService = settingsService; this.peerConnectionManager = peerConnectionManager; Security.addProvider(new BouncyCastleProvider()); this.contactNotificationService = contactNotificationService; } @Transactional public ResourceCreationState generateProfileKeys(String name) { if (hasOwnProfile()) { return ALREADY_EXISTS; } if (name.length() < KEY_ID_LENGTH_MIN) { throw new IllegalArgumentException("Profile name is too short, minimum is " + KEY_ID_LENGTH_MIN); } if (name.length() > KEY_ID_LENGTH_MAX) { throw new IllegalArgumentException("Profile name is too long, maximum is " + KEY_ID_LENGTH_MAX); } log.info("Generating PGP keys, algorithm: {}, bits: {} ...", EXPERIMENTAL_EC ? "EdDSA" : "RSA", KEY_SIZE); try { var pgpSecretKey = PGP.generateSecretKey(name, KEY_ID_SUFFIX, KEY_SIZE); var pgpPublicKey = pgpSecretKey.getPublicKey(); log.info("Successfully generated PGP key pair, id: {}", Id.toString(pgpSecretKey.getKeyID())); createOwnProfile(name, pgpSecretKey, pgpPublicKey); return CREATED; } catch (PGPException | IOException e) { log.error("Failed to generate PGP key pair", e); } return FAILED; } @Transactional public void createOwnProfile(String name, PGPSecretKey pgpSecretKey, PGPPublicKey pgpPublicKey) throws IOException { var ownProfile = Profile.createOwnProfile(name, pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded()); profileRepository.save(ownProfile); settingsService.saveSecretProfileKey(pgpSecretKey.getEncoded()); } public Profile getOwnProfile() { return profileRepository.findById(ProfileConstants.OWN_PROFILE_ID).orElseThrow(() -> new IllegalStateException("Missing own profile")); } public boolean hasOwnProfile() { return profileRepository.findById(ProfileConstants.OWN_PROFILE_ID).isPresent(); } public Optional findProfileById(long id) { return profileRepository.findById(id); } public List findProfilesByName(String name) { return profileRepository.findAllByNameContaining(name); } public Optional findProfileByPgpFingerprint(ProfileFingerprint profileFingerprint) { return profileRepository.findByProfileFingerprint(profileFingerprint); } public Optional findProfileByPgpIdentifier(long pgpIdentifier) { return profileRepository.findByPgpIdentifier(pgpIdentifier); } public List findAllCompleteProfilesByPgpIdentifiers(Set pgpIdentifiers) { return profileRepository.findAllCompleteByPgpIdentifiers(pgpIdentifiers); } public Optional findDiscoverableProfileByPgpIdentifier(long pgpIdentifier) { return profileRepository.findDiscoverableProfileByPgpIdentifier(pgpIdentifier); } public List findAllDiscoverableProfilesByPgpIdentifiers(Set pgpIdentifiers) { return profileRepository.findAllDiscoverableProfilesByPgpIdentifiers(pgpIdentifiers); } public Optional findProfileByLocationIdentifier(LocationIdentifier locationIdentifier) { return profileRepository.findProfileByLocationIdentifier(locationIdentifier); } public ProfileKeyAttributes findProfileKeyAttributes(long id) { var profile = findProfileById(id).orElseThrow(); try { var publicKey = PGP.getPGPPublicKey(profile.getPgpPublicKeyData()); if (publicKey.getSignatures().hasNext()) { var signature = publicKey.getSignatures().next(); return new ProfileKeyAttributes( publicKey.getVersion(), publicKey.getAlgorithm(), publicKey.getBitStrength(), signature.getHashAlgorithm() ); } throw new IllegalArgumentException("No signature present in the key"); } catch (InvalidKeyException _) { throw new IllegalArgumentException("PGP public key for profile is invalid"); } } @Transactional public Profile createOrUpdateProfile(final Profile profile) { Objects.requireNonNull(profile); var savedProfile = profileRepository.save( findProfileByPgpFingerprint(profile.getProfileFingerprint()) .map(foundProfile -> foundProfile.updateWith(profile)) .orElse(profile) ); contactNotificationService.addOrUpdateProfile(savedProfile); return savedProfile; } public Profile getProfileFromRSId(RSId rsId) { var profile = findProfileByPgpFingerprint(rsId.getPgpFingerprint()).orElseGet(() -> createNewProfile(rsId)); profile.setAccepted(true); profile.addLocation(Location.createLocation(rsId)); return profile; } private static Profile createNewProfile(RSId rsId) { if (rsId.getPgpPublicKey().isPresent()) { var pgpPublicKey = rsId.getPgpPublicKey().get(); return Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), rsId.getPgpFingerprint(), pgpPublicKey); } return Profile.createEmptyProfile(rsId.getName(), rsId.getPgpIdentifier(), rsId.getPgpFingerprint()); } @Transactional public void deleteProfile(long id) { // Make sure we don't automatically connect again while // the profile is being deleted var profile = profileRepository.findById(id).orElseThrow(); profile.setAccepted(false); var connectedLocations = profile.getLocations().stream() .filter(Location::isConnected) .toList(); // If there's no connected locations, just delete the profile // and we're done. Otherwise, we need to disconnect the locations // and wait until that's done before deleting the profile. if (connectedLocations.isEmpty()) { profileRepository.delete(profile); contactNotificationService.removeProfile(profile); } else { profilesToDelete.put(profile, connectedLocations.stream().map(Location::getLocationIdentifier).collect(Collectors.toSet())); var ids = connectedLocations.stream().map(Location::getId).toList(); ids.forEach(location -> { var peer = peerConnectionManager.getPeerByLocation(location); if (peer != null) { peer.getCtx().close(); } }); } } @EventListener public void onPeerDisconnectedEvent(PeerDisconnectedEvent event) { if (profilesToDelete.isEmpty()) { return; } profilesToDelete.forEach((_, locationIdentifiers) -> locationIdentifiers.removeIf(locationId -> locationId.equals(event.locationIdentifier()))); var it = profilesToDelete.entrySet().iterator(); while (it.hasNext()) { var profileSetEntry = it.next(); if (profileSetEntry.getValue().isEmpty()) { profileRepository.delete(profileSetEntry.getKey()); contactNotificationService.removeProfile(profileSetEntry.getKey()); it.remove(); } } } public List getAllProfiles() { return profileRepository.findAll(); } public List getAllDiscoverableProfiles() { return profileRepository.getAllDiscoverableProfiles(); } public List getAllProfilesIn(Set profileIds) { return profileRepository.findAllById(profileIds); } @Transactional public void fixAllProfiles() { profileRepository.findAll().forEach(profile -> { var pgpPublicKeyData = profile.getPgpPublicKeyData(); if (pgpPublicKeyData != null) { try { var pgpPublicKey = PGP.getPGPPublicKey(pgpPublicKeyData); profile.setCreated(pgpPublicKey.getCreationTime().toInstant()); } catch (InvalidKeyException _) { // Skip } } }); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/QrCodeService.java ================================================ package io.xeres.app.service; import com.google.zxing.BarcodeFormat; import com.google.zxing.WriterException; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.awt.image.BufferedImage; import static org.apache.commons.lang3.StringUtils.isEmpty; @Service public class QrCodeService { private static final Logger log = LoggerFactory.getLogger(QrCodeService.class); public BufferedImage generateQrCode(String message) { if (isEmpty(message)) { log.warn("No QR code to encode because the input is empty"); return null; } var qrCodeWriter = new QRCodeWriter(); BitMatrix matrix; try { matrix = qrCodeWriter.encode(message, BarcodeFormat.QR_CODE, 256, 256); } catch (WriterException e) { log.error("Couldn't generate QR Code: {}", e.getMessage(), e); return null; } return MatrixToImageWriter.toBufferedImage(matrix); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/ResourceCreationState.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; public enum ResourceCreationState { CREATED, ALREADY_EXISTS, FAILED } ================================================ FILE: app/src/main/java/io/xeres/app/service/SettingsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.micrometer.common.util.StringUtils; import io.xeres.app.application.events.SettingsChangedEvent; import io.xeres.app.database.model.settings.Settings; import io.xeres.app.database.model.settings.SettingsMapper; import io.xeres.app.database.repository.SettingsRepository; import io.xeres.common.dto.settings.SettingsDTO; import io.xeres.common.properties.StartupProperties; import io.xeres.common.protocol.HostPort; import io.xeres.common.util.RemoteUtils; import jakarta.annotation.PostConstruct; import jakarta.json.JsonPatch; import jakarta.json.JsonValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.Objects; import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.isNotBlank; @Service public class SettingsService { private static final Logger log = LoggerFactory.getLogger(SettingsService.class); private static final String BACKUP_FILE_PREFIX = "backup_"; private static final String BACKUP_FILE_EXTENSION = ".zip"; private static final Pattern BACKUP_FILES = Pattern.compile("^backup_\\d{14}.zip$"); private static final int BACKUP_FILES_RETENTION = 3; private static final DateTimeFormatter backupFileFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") .withZone(ZoneId.systemDefault()); private final SettingsRepository settingsRepository; private final ApplicationEventPublisher publisher; private final ObjectMapper objectMapper; private final UiBridgeService uiBridgeService; private Settings settings; public SettingsService(SettingsRepository settingsRepository, ApplicationEventPublisher publisher, ObjectMapper objectMapper, UiBridgeService uiBridgeService) { this.settingsRepository = settingsRepository; this.publisher = publisher; this.objectMapper = objectMapper; this.uiBridgeService = uiBridgeService; } @PostConstruct void init() // Keep as default access for testing { settings = settingsRepository.findById((byte) 1).orElseThrow(() -> new IllegalStateException("No setting configuration")); setPasswordInClients(); } private void setPasswordInClients() { var remotePassword = getPasswordForClients(); if (remotePassword != null) { uiBridgeService.setClientsAuthentication("user", remotePassword); } } private String getPasswordForClients() { if (!StartupProperties.getBoolean(StartupProperties.Property.CONTROL_PASSWORD, true)) { return null; } String remotePassword = null; if (RemoteUtils.isRemoteUiClient()) { remotePassword = StartupProperties.getString(StartupProperties.Property.REMOTE_PASSWORD); } if (remotePassword == null && hasRemotePassword()) { remotePassword = getRemotePassword(); } return remotePassword; } /** * Performs a backup of the database. *

* The last {@code BACKUP_FILE_RETENTION} files are kept. The rest is deleted. A timestamp is placed within the name of each backup file. * * @param directory the directory in where to place the backup. */ public void backup(String directory) { Objects.requireNonNull(directory); var backupFile = Path.of(directory, BACKUP_FILE_PREFIX + backupFileFormatter.format(Instant.now()) + BACKUP_FILE_EXTENSION); log.info("Doing backup of database to {}", backupFile); settingsRepository.backupDatabase(backupFile.toString()); deleteOldestBackupSiblings(backupFile); } private void deleteOldestBackupSiblings(Path file) { try (var pathStream = Files.find(file.getParent(), 1, (path, attributes) -> BACKUP_FILES.matcher(path.getFileName().toString()).matches() && attributes.isRegularFile())) { pathStream.sorted(Comparator.comparing(path -> path.toFile().lastModified())) .sorted(Comparator.reverseOrder()) .skip(BACKUP_FILES_RETENTION) .forEach(this::deleteFile); } catch (IOException e) { throw new RuntimeException(e); } } private void deleteFile(Path path) { try { Files.delete(path); } catch (IOException _) { log.error("Couldn't delete old backup file: {}", path); } } /** * Retrieve the settings. For DTO use only. * * @return the settings as a DTO */ public SettingsDTO getSettings() { return SettingsMapper.toDTO(settings); } @Transactional public Settings applyPatchToSettings(JsonPatch jsonPatch) { var source = objectMapper.convertValue(settings, JsonValue.class); var patched = jsonPatch.apply(source.asJsonObject()); updateSettings(objectMapper.convertValue(patched, Settings.class)); return settings; } @Transactional public Settings applySettings(Settings newSettings) { // Those 5 are not transfered in the UI newSettings.setPgpPrivateKeyData(settings.getPgpPrivateKeyData()); newSettings.setLocationPrivateKeyData(settings.getLocationPrivateKeyData()); newSettings.setLocationPublicKeyData(settings.getLocationPublicKeyData()); newSettings.setLocationCertificate(settings.getLocationCertificate()); newSettings.setLocalPort(settings.getLocalPort()); updateSettings(newSettings); return newSettings; } private void updateSettings(Settings settings) { var oldSettings = this.settings; this.settings = settings; settingsRepository.save(settings); publisher.publishEvent(new SettingsChangedEvent(oldSettings, settings)); } @Transactional public void saveSecretProfileKey(byte[] privateKeyData) { settings.setPgpPrivateKeyData(privateKeyData); settingsRepository.save(settings); } public byte[] getSecretProfileKey() { return settings.getPgpPrivateKeyData(); } @Transactional public void saveLocationKeys(KeyPair keyPair) { settings.setLocationPrivateKeyData(keyPair.getPrivate().getEncoded()); settings.setLocationPublicKeyData(keyPair.getPublic().getEncoded()); settingsRepository.save(settings); } public byte[] getLocationPublicKeyData() { return settings.getLocationPublicKeyData(); } public byte[] getLocationPrivateKeyData() { return settings.getLocationPrivateKeyData(); } @Transactional public void saveLocationCertificate(byte[] data) { settings.setLocationCertificate(data); settingsRepository.save(settings); } public byte[] getLocationCertificate() { return settings.getLocationCertificate(); } public boolean hasOwnLocation() { return settings.hasLocationCertificate(); } public boolean isOwnProfilePresent() { return settings.getPgpPrivateKeyData() != null; } public boolean hasTorSocksConfigured() { return isNotBlank(settings.getTorSocksHost()) && settings.getTorSocksPort() != 0; } public HostPort getTorSocksHostPort() { return new HostPort(settings.getTorSocksHost(), settings.getTorSocksPort()); } public boolean hasI2pSocksConfigured() { return isNotBlank(settings.getI2pSocksHost()) && settings.getI2pSocksPort() != 0; } public HostPort getI2pSocksHostPort() { return new HostPort(settings.getI2pSocksHost(), settings.getI2pSocksPort()); } public boolean isUpnpEnabled() { return settings.isUpnpEnabled(); } public boolean isBroadcastDiscoveryEnabled() { return settings.isBroadcastDiscoveryEnabled(); } public boolean isDhtEnabled() { return settings.isDhtEnabled(); } public int getLocalPort() { return settings.getLocalPort(); } @Transactional public void setLocalPort(int port) { settings.setLocalPort(port); settingsRepository.save(settings); } public boolean isAutoStartEnabled() { return settings.isAutoStartEnabled(); } public boolean hasIncomingDirectory() { return StringUtils.isNotEmpty(settings.getIncomingDirectory()); } public String getIncomingDirectory() { return settings.getIncomingDirectory(); } @Transactional public void setIncomingDirectory(String directory) { settings.setIncomingDirectory(directory); settingsRepository.save(settings); } public boolean hasRemotePassword() { return StringUtils.isNotEmpty(settings.getRemotePassword()); } public String getRemotePassword() { return settings.getRemotePassword(); } @Transactional public void setRemotePassword(String password) { settings.setRemotePassword(password); settingsRepository.save(settings); } public int getVersion() { return settings.getVersion(); } @Transactional public void setVersion(int version) { settings.setVersion(version); settingsRepository.save(settings); } public boolean isUpnpRemoteEnabled() { return settings.isRemoteEnabled() && settings.isUpnpRemoteEnabled(); } public boolean isRemoteEnabled() { return settings.isRemoteEnabled(); } public boolean hasRemotePortConfigured() { return settings.isRemoteEnabled() && settings.getRemotePort() != 0; } public int getRemotePort() { return settings.getRemotePort(); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/UiBridgeService.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.common.tray.TrayNotificationType; import io.xeres.ui.client.message.MessageClient; import io.xeres.ui.support.splash.SplashService; import io.xeres.ui.support.tray.TrayService; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; /** * This class allows to call methods in the UI module. This (and XeresApplication) should be the only classes being able to do that. * This helps separate concerns as long as this class stays as small as possible. * There's an ArchUnit rule that finds violations. */ @Service public class UiBridgeService { public enum SplashStatus { DATABASE, NETWORK } private final SplashService splashService; private final TrayService trayService; private final WebClient.Builder webClientBuilder; private final MessageClient messageClient; public UiBridgeService(SplashService splashService, TrayService trayService, WebClient.Builder webClientBuilder, MessageClient messageClient) { this.splashService = splashService; this.trayService = trayService; this.webClientBuilder = webClientBuilder; this.messageClient = messageClient; } public void setSplashStatus(SplashStatus status) { splashService.status(switch (status) { case DATABASE -> SplashService.Status.DATABASE; case NETWORK -> SplashService.Status.NETWORK; }); } public void closeSplashScreen() { splashService.close(); } public void showTrayNotification(TrayNotificationType type, String message) { trayService.showNotification(type, message); } public void setTrayStatus(String message) { trayService.setTooltip(message); } public void setClientsAuthentication(String username, String password) { webClientBuilder.defaultHeaders(httpHeaders -> httpHeaders.setBasicAuth(username, password)); messageClient.setAuthentication(username, password); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/UnHtmlService.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import org.apache.commons.lang3.Strings; import org.commonmark.ext.gfm.strikethrough.Strikethrough; import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; import org.commonmark.node.*; import org.commonmark.renderer.markdown.MarkdownRenderer; import org.jsoup.Jsoup; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; import org.jsoup.safety.Cleaner; import org.jsoup.safety.Safelist; import org.springframework.stereotype.Service; import java.util.List; import java.util.Locale; import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.isBlank; @Service public class UnHtmlService { private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); private final MarkdownRenderer markdownRenderer; public UnHtmlService() { markdownRenderer = MarkdownRenderer.builder() .extensions(List.of(StrikethroughExtension.create())) .build(); } public String cleanupMessage(String text) { // Only process HTML if (isBlank(text) || (!Strings.CI.startsWith(text, "") && !Strings.CI.startsWith(text, "") && !Strings.CI.startsWith(text, " jsoupNodes, org.commonmark.node.Node commonMarkParent) { for (Node jsoupNode : jsoupNodes) { if (jsoupNode instanceof TextNode textNode) { String text = textNode.text(); if (!text.trim().isEmpty()) { commonMarkParent.appendChild(new Text(text)); } } else if (jsoupNode instanceof Element element) { var commonMarkNode = createCommonMarkNode(element); if (commonMarkNode != null) { commonMarkParent.appendChild(commonMarkNode); // Recursively convert child nodes convertNodes(element.childNodes(), commonMarkNode); } else { // If the element isn't converted, just convert its children convertNodes(element.childNodes(), commonMarkParent); } } } } private static org.commonmark.node.Node createCommonMarkNode(Element element) { String tagName = element.tagName().toLowerCase(); return switch (tagName) { case "h1" -> createHeading(1); case "h2" -> createHeading(2); case "h3" -> createHeading(3); case "h4" -> createHeading(4); case "h5" -> createHeading(5); case "h6" -> createHeading(6); case "p" -> new Paragraph(); case "br" -> new HardLineBreak(); case "hr" -> new ThematicBreak(); case "blockquote" -> new BlockQuote(); case "ul" -> new BulletList(); case "ol" -> new OrderedList(); case "li" -> new ListItem(); case "em", "i" -> new Emphasis(); case "strong", "b" -> new StrongEmphasis(); case "s", "del" -> new Strikethrough("~"); case "code" -> { if (element.parent() != null && "pre".equals(element.parent().tagName().toLowerCase(Locale.ROOT))) { // The code block is handled by the "pre" element yield null; } var code = new Code(); if (element.childNodeSize() == 1) { var node = element.childNode(0); if (node instanceof TextNode textNode) { // Code doesn't handle children code.setLiteral(textNode.text()); } } yield code; } case "pre" -> { var codeBlock = new FencedCodeBlock(); // Try to detect language if (element.childrenSize() == 1 && "code".equals(element.child(0).tagName().toLowerCase(Locale.ROOT))) { String classNames = element.child(0).className(); if (!classNames.isEmpty()) { String language = WHITESPACE_PATTERN.split(classNames)[0]; codeBlock.setInfo(language.replace("language-", "")); } setFencedCodeBlockLiteralIfFound(codeBlock, element.child(0)); } setFencedCodeBlockLiteralIfFound(codeBlock, element); yield codeBlock; } case "a" -> { var link = new Link(); link.setDestination(element.attr("href")); link.setTitle(element.attr("title")); yield link; } case "img" -> { var image = new Image(); image.setDestination(element.attr("src")); image.setTitle(element.attr("title")); var altText = new Text(element.attr("alt")); image.appendChild(altText); yield image; } default -> null; // For unsupported elements, return null to skip but still process children }; } private static void setFencedCodeBlockLiteralIfFound(FencedCodeBlock codeBlock, Element element) { if (element.childNodeSize() == 1) { var node = element.childNode(0); if (node instanceof TextNode textNode) { // FencedCodeBlock doesn't handle children codeBlock.setLiteral(textNode.text()); return; } } if (codeBlock.getLiteral() == null) { codeBlock.setLiteral(""); } } private static Heading createHeading(int level) { var heading = new Heading(); heading.setLevel(level); return heading; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/UpgradeService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.configuration.DataDirConfiguration; import io.xeres.app.database.model.file.File; import io.xeres.app.database.model.share.Share; import io.xeres.app.service.file.FileService; import io.xeres.app.xrs.service.identity.IdentityRsService; import io.xeres.common.pgp.Trust; import io.xeres.common.util.SecureRandomUtils; import org.bouncycastle.openpgp.PGPException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; @Service public class UpgradeService { private static final Logger log = LoggerFactory.getLogger(UpgradeService.class); private static final String INCOMING_DIRECTORY_NAME = "Incoming"; private static final String STICKERS_DIRECTORY_NAME = "Stickers"; private final DataDirConfiguration dataDirConfiguration; private final SettingsService settingsService; private final FileService fileService; private final IdentityRsService identityRsService; private final ProfileService profileService; public UpgradeService(DataDirConfiguration dataDirConfiguration, SettingsService settingsService, FileService fileService, IdentityRsService identityRsService, ProfileService profileService) { this.dataDirConfiguration = dataDirConfiguration; this.settingsService = settingsService; this.fileService = fileService; this.identityRsService = identityRsService; this.profileService = profileService; } /** * Configures defaults and upgrades that cannot be done on the database definition alone because * they depend on some runtime parameters. This is not called in UI client only mode. */ public void upgrade() { var version = 5; // Increment this number when needing to add new defaults // Don't do this stuff when running tests if (dataDirConfiguration.getDataDir() == null) { return; } if (!settingsService.hasIncomingDirectory()) { var incomingDirectory = Path.of(dataDirConfiguration.getDataDir(), INCOMING_DIRECTORY_NAME); if (Files.notExists(incomingDirectory)) { try { Files.createDirectory(incomingDirectory); } catch (IOException e) { throw new IllegalStateException("Couldn't create incoming directory: " + incomingDirectory + ", :" + e.getMessage()); } } settingsService.setIncomingDirectory(incomingDirectory.toString()); fileService.addShare(Share.createShare(INCOMING_DIRECTORY_NAME, File.createFile(incomingDirectory), false, Trust.UNKNOWN)); } if (settingsService.getVersion() < 1) { var password = new char[20]; SecureRandomUtils.nextPassword(password); settingsService.setRemotePassword(String.valueOf(password)); Arrays.fill(password, (char) 0); } if (settingsService.getVersion() < 2) { fileService.encryptAllHashes(); } if (settingsService.getVersion() < 3) { try { identityRsService.fixOwnProfile(); } catch (PGPException | IOException e) { throw new IllegalStateException("Couldn't fix own profile hash + signature: " + e.getMessage()); } profileService.fixAllProfiles(); } if (settingsService.getVersion() < 4) { var stickersDirectory = Path.of(dataDirConfiguration.getDataDir(), STICKERS_DIRECTORY_NAME); if (Files.notExists(stickersDirectory)) { try { Files.createDirectory(stickersDirectory); } catch (IOException e) { // Not very important, we can live without stickers. log.error("Couldn't create stickers directory: {}, {}. Stickers won't be available", stickersDirectory, e.getMessage()); } } } if (settingsService.getVersion() < 5) { // Removing the service string will change the identity's signature, // so we need to recompute it again. identityRsService.fixOwnIdentity(); } // [Add new defaults here] settingsService.setVersion(version); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/audio/AudioService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.audio; import io.xeres.common.util.ThreadUtils; import org.springframework.stereotype.Service; import javax.sound.sampled.*; import java.io.ByteArrayOutputStream; import java.util.function.Consumer; import java.util.function.Supplier; @Service public class AudioService { private static final int AUDIO_SAMPLE_RATE = 16000; // in Hz, wideband private static final int AUDIO_SAMPLE_SIZE = 16; // in bits private static final int AUDIO_SAMPLE_CHANNELS = 1; // mono private TargetDataLine inputLine; private SourceDataLine outputLine; private volatile boolean isRecording; private volatile boolean isPlaying; private Thread recordThread; private Thread playThread; private ByteArrayOutputStream audioBuffer; private Consumer audioConsumer; private Supplier audioSupplier; private AudioFormat audioFormat; private int frameSize; public int getAudioSampleRate() { return AUDIO_SAMPLE_RATE; } public int getAudioSampleSize() { return AUDIO_SAMPLE_SIZE; } public int getAudioSampleChannels() { return AUDIO_SAMPLE_CHANNELS; } @SuppressWarnings("DataFlowIssue") public int getSpeexEncoderMode() { return switch (AUDIO_SAMPLE_RATE) { case 8000 -> 0; case 16000 -> 1; case 32000 -> 2; default -> throw new IllegalStateException("Wrong sample rate " + AUDIO_SAMPLE_RATE + ", must be 8000, 16000 or 32000"); }; } public void startPlayingAndRecording(int frameSize, Consumer audioConsumer, Supplier audioSupplier) { startPlaying(audioSupplier); startRecording(frameSize, audioConsumer); } public void stopRecordingAndPlaying() { stopRecording(); stopPlaying(); } private void startRecording(int frameSize, Consumer audioConsumer) { createAudioFormatIfNeeded(); try { inputLine = AudioSystem.getTargetDataLine(audioFormat); inputLine.open(); audioBuffer = new ByteArrayOutputStream(); this.frameSize = frameSize; this.audioConsumer = audioConsumer; isRecording = true; inputLine.start(); recordThread = Thread.ofVirtual() .name("Audio Capture Service") .start(this::captureAudio); } catch (LineUnavailableException | IllegalArgumentException e) { throw new IllegalStateException("Audio capture device not available: " + e.getMessage()); } } private void stopRecording() { isRecording = false; ThreadUtils.waitForThread(recordThread); if (inputLine != null) { inputLine.stop(); inputLine.close(); } } private void startPlaying(Supplier audioSupplier) { createAudioFormatIfNeeded(); try { outputLine = AudioSystem.getSourceDataLine(audioFormat); outputLine.open(); this.audioSupplier = audioSupplier; isPlaying = true; outputLine.start(); playThread = Thread.ofVirtual() .name("Audio Playing Service") .start(this::playAudio); } catch (LineUnavailableException | IllegalArgumentException e) { throw new IllegalStateException("Audio playing device not available: " + e.getMessage()); } } private void stopPlaying() { isPlaying = false; ThreadUtils.waitForThread(playThread); if (outputLine != null) { outputLine.stop(); outputLine.close(); } } private void createAudioFormatIfNeeded() { if (audioFormat == null) { audioFormat = new AudioFormat(AUDIO_SAMPLE_RATE, AUDIO_SAMPLE_SIZE, AUDIO_SAMPLE_CHANNELS, true, false); } } private void captureAudio() { var buffer = new byte[frameSize * AUDIO_SAMPLE_CHANNELS * (AUDIO_SAMPLE_SIZE / 8)]; int bytesRead; while (isRecording) { bytesRead = inputLine.read(buffer, 0, buffer.length); if (bytesRead == buffer.length) // Only use full buffers, otherwise that's not enough to process a frame and the encoder will complain { audioBuffer.reset(); audioBuffer.write(buffer, 0, bytesRead); audioConsumer.accept(audioBuffer.toByteArray()); } } } private void playAudio() { while (isPlaying) { var buffer = audioSupplier.get(); outputLine.write(buffer, 0, buffer.length); } } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/BackupService.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.crypto.rsid.RSId; import io.xeres.app.service.IdentityService; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.util.XmlUtils; import io.xeres.app.xrs.service.identity.IdentityRsService; import io.xeres.common.id.ProfileFingerprint; import io.xeres.common.pgp.Trust; import io.xeres.common.rest.config.ImportRsFriendsResponse; import io.xeres.common.rsid.Type; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; import jakarta.xml.bind.helpers.DefaultValidationEventHandler; import org.apache.commons.lang3.StringUtils; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPKeyPair; import org.bouncycastle.openpgp.PGPUtil; import org.bouncycastle.openpgp.jcajce.JcaPGPSecretKeyRingCollection; import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import javax.xml.stream.XMLStreamException; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.security.InvalidKeyException; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.SignatureException; import java.security.cert.CertificateException; import java.security.spec.InvalidKeySpecException; import java.util.Collection; import java.util.List; import java.util.Objects; /** * Handles exporting and importing of profiles and friends, including importing from Retroshare. */ @Service public class BackupService { private static final Logger log = LoggerFactory.getLogger(BackupService.class); private static final long BACKUP_MAX_SIZE = 1024 * 1024 * 100L; // 100 MB private static final long RS_PROFILE_MAX_SIZE = (long) 1024 * 1024; // 1 MB private static final long RS_FRIENDS_MAX_SIZE = 1024 * 1024 * 10L; // 10 MB private final ProfileService profileService; private final LocationService locationService; private final IdentityService identityService; private final IdentityRsService identityRsService; private final SettingsService settingsService; public BackupService(ProfileService profileService, LocationService locationService, IdentityService identityService, IdentityRsService identityRsService, SettingsService settingsService) { this.profileService = profileService; this.locationService = locationService; this.identityService = identityService; this.identityRsService = identityRsService; this.settingsService = settingsService; } public byte[] backup() throws JAXBException { var out = new ByteArrayOutputStream(); var export = new Export(); var local = new Local(); local.setProfile(new Profile(settingsService.getSecretProfileKey())); local.setLocation(new Location(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(), settingsService.getLocationPrivateKeyData(), settingsService.getLocationPublicKeyData(), settingsService.getLocationCertificate(), settingsService.getLocalPort())); var identityGroupItem = identityService.getOwnIdentity(); local.setIdentity(new Identity(identityGroupItem.getName(), identityGroupItem.getAdminPrivateKey().getEncoded(), identityGroupItem.getAdminPublicKey().getEncoded())); export.setProfiles(profileService.getAllDiscoverableProfiles()); export.setLocal(local); JAXBContext context; context = JAXBContext.newInstance(Export.class); var marshaller = context.createMarshaller(); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); marshaller.marshal(export, out); return out.toByteArray(); } @Transactional public void restore(MultipartFile file) throws JAXBException, IOException, InvalidKeyException, NoSuchAlgorithmException, InvalidKeySpecException, CertificateException, PGPException, XMLStreamException { if (file == null) { throw new IllegalArgumentException("XML backup file is empty"); } if (file.getSize() >= BACKUP_MAX_SIZE) { throw new IllegalArgumentException("XML backup size is bigger than " + BACKUP_MAX_SIZE + " bytes"); } JAXBContext context; context = JAXBContext.newInstance(Export.class); var xmlInputFactory = XmlUtils.getSecureXMLInputFactory(); var unmarshaller = context.createUnmarshaller(); var input = xmlInputFactory.createXMLStreamReader(file.getInputStream()); var export = (Export) unmarshaller.unmarshal(input); var localProfile = export.getProfiles().stream() .filter(profile -> profile.getTrust() == Trust.ULTIMATE) .findFirst().orElseThrow(() -> new IllegalArgumentException("No local profile in the profile list")); var localLocationIdentifier = export.getLocal().getLocation().getLocationIdentifier(); var localLocation = localProfile.getLocations().stream() .filter(location -> location.getLocationIdentifier().equals(localLocationIdentifier)) .findFirst().orElseThrow(); // XXX: if not found, create new location? should be allowed createOwnProfile(localProfile.getName(), export.getLocal().getProfile().getPgpPrivateKey(), localProfile.getPgpPublicKeyData()); createOwnLocation(localLocation.getName(), export.getLocal().getLocation().getPrivateKey(), export.getLocal().getLocation().getPublicKey(), export.getLocal().getLocation().getX509Certificate()); createOwnIdentity(export.getLocal().getIdentity().getName(), export.getLocal().getIdentity().getPrivateKey(), export.getLocal().getIdentity().getPublicKey()); createProfiles(export.getProfiles()); } @Transactional public void importProfileFromRs(MultipartFile file, String locationName, String password) { if (file == null) { throw new IllegalArgumentException("RS keyring is empty"); } if (file.getSize() >= RS_PROFILE_MAX_SIZE) { throw new IllegalArgumentException("RS keyring is too big"); } if (StringUtils.isEmpty(locationName)) { throw new IllegalArgumentException("Location name is empty"); } if (StringUtils.isEmpty(password)) { password = ""; } String profileName; try (var inputStream = getInputStream(file)) { var secretRingCollection = new JcaPGPSecretKeyRingCollection(inputStream); var secretRing = secretRingCollection.getKeyRings().next(); var secretKey = secretRing.getSecretKey(); var digestCalculator = new JcaPGPDigestCalculatorProviderBuilder().build(); var keyDecryptor = new JcePBESecretKeyDecryptorBuilder(digestCalculator); var id = secretKey.getPublicKey().getUserIDs().next(); profileName = cleanupProfileName(id); PGPKeyPair keyPair; // Decrypt try { keyPair = secretKey.extractKeyPair(keyDecryptor.build(password.toCharArray())); } catch (PGPException e) { throw new IllegalArgumentException("Wrong password", e); } // End encrypt again with an empty password because we use a different security model var newSecretKey = PGP.encryptKeyPair(keyPair, id); createOwnProfile(profileName, newSecretKey.getEncoded(), newSecretKey.getPublicKey().getEncoded()); } catch (PGPException | InvalidKeyException | IOException e) { log.error("Error while parsing PGP data", e); throw new IllegalArgumentException(e); } locationService.generateOwnLocation(locationName); identityRsService.generateOwnIdentity(profileName, true); } @Transactional public ImportRsFriendsResponse importFriendsFromRs(MultipartFile file) throws JAXBException, IOException, XMLStreamException { if (file == null) { throw new IllegalArgumentException("Friends file is empty"); } if (file.getSize() >= RS_FRIENDS_MAX_SIZE) { throw new IllegalArgumentException("Friends file is too large"); } JAXBContext context; context = JAXBContext.newInstance(Root.class); var unmarshaller = context.createUnmarshaller(); unmarshaller.setEventHandler(new DefaultValidationEventHandler()); // Display better error messages var xmlInputFactory = XmlUtils.getSecureXMLInputFactory(); var input = xmlInputFactory.createXMLStreamReader(file.getInputStream()); var root = (Root) unmarshaller.unmarshal(input); var certificates = root.getPgpIDs().stream() .map(PgpId::getSslIDs) .flatMap(Collection::stream) .map(SslId::getCertificate) .filter(Objects::nonNull) .toList(); var success = 0; var errors = 0; for (var certificate : certificates) { try { RSId.parse(certificate, Type.CERTIFICATE).ifPresent(rsId -> profileService.createOrUpdateProfile(profileService.getProfileFromRSId(rsId))); success++; } catch (Exception e) { log.error("Error while adding friend {}", certificate, e); errors++; } } return new ImportRsFriendsResponse(success, errors); } public boolean verifyUpdate(Path updateFile, byte[] signature) { try { PGP.verify(PGP.getUpdateSigningKey(), signature, Files.newInputStream(updateFile)); return true; } catch (PGPException | IOException | SignatureException e) { log.error("Error while verifying update {}", e.getMessage()); return false; } } private static InputStream getInputStream(MultipartFile file) throws IOException { if (Objects.requireNonNull(file.getOriginalFilename()).endsWith(".asc")) { // Skip the PGP public key block because we don't need it, and // it gives problems for Bouncy Castle which can't read it for some reason try (var in = new BufferedReader(new InputStreamReader(file.getInputStream()))) { String line; while ((line = readRsLine(in)) != null) { if (line.equals("-----END PGP PUBLIC KEY BLOCK-----")) { readRsLine(in); // Skip the empty line before the next private key block var out = new ByteArrayOutputStream(); var writer = new OutputStreamWriter(out); while ((line = readRsLine(in)) != null) { writer.write(line + "\r\n"); } writer.close(); return PGPUtil.getDecoderStream(new ByteArrayInputStream(out.toByteArray())); } } } } else { return PGPUtil.getDecoderStream(file.getInputStream()); } return null; } /** * Retroshare uses \r\r\n (mostly) instead of \r\n for line endings. This makes readLine() read * an extra line. This method fixes it by returning only one line ending. * * @param reader the BufferedReader * @return one line * @throws IOException when there's an I/O error */ private static String readRsLine(BufferedReader reader) throws IOException { var line = reader.readLine(); reader.mark(512); if (line == null) { return null; } var lineToSkip = reader.readLine(); if (lineToSkip == null || !lineToSkip.isEmpty()) { reader.reset(); } return line; } private String cleanupProfileName(String profileName) { return profileName.replace(" (Generated by RetroShare) <>", ""); } private void createOwnProfile(String name, byte[] privateKey, byte[] publicKey) throws InvalidKeyException, IOException { var pgpSecretKey = PGP.getPGPSecretKey(privateKey); var pgpPublicKey = PGP.getPGPPublicKey(publicKey); profileService.createOwnProfile(name, pgpSecretKey, pgpPublicKey); } private void createOwnLocation(String name, byte[] privateKey, byte[] publicKey, byte[] x509Certificate) throws NoSuchAlgorithmException, InvalidKeySpecException, CertificateException { var keyPair = new KeyPair(RSA.getPublicKey(publicKey), RSA.getPrivateKey(privateKey)); locationService.createOwnLocation(name, keyPair, x509Certificate); } private void createOwnIdentity(String name, byte[] privateKey, byte[] publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException, PGPException, IOException { var keyPair = new KeyPair(RSA.getPublicKey(publicKey), RSA.getPrivateKey(privateKey)); identityRsService.createOwnIdentity(name, keyPair); } private void createProfiles(List profiles) throws InvalidKeyException { for (io.xeres.app.database.model.profile.Profile profile : profiles) { if (profile.getTrust() != Trust.ULTIMATE) { var pgpPublicKey = PGP.getPGPPublicKey(profile.getPgpPublicKeyData()); var createdProfile = io.xeres.app.database.model.profile.Profile.createProfile( profile.getName(), profile.getPgpIdentifier(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey); profile.getLocations().forEach(createdProfile::addLocation); createdProfile.setAccepted(true); profileService.createOrUpdateProfile(createdProfile); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/Export.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import io.xeres.app.database.model.profile.Profile; import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlElementWrapper; import jakarta.xml.bind.annotation.XmlRootElement; import java.util.List; @XmlRootElement class Export { private List profiles; private Local local; public Export() { // Default constructor } @XmlElementWrapper @XmlElement(name = "profile") public List getProfiles() { return profiles; } public void setProfiles(List profiles) { this.profiles = profiles; } public Local getLocal() { return local; } public void setLocal(Local local) { this.local = local; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/Group.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import jakarta.xml.bind.annotation.XmlElement; import java.util.List; class Group { private List pgpIDs; public Group() { // Default constructor } @XmlElement(name = "pgpID") public List getPgpIDs() { return pgpIDs; } public void setPgpIDs(List pgpIDs) { this.pgpIDs = pgpIDs; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/Identity.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import jakarta.xml.bind.annotation.XmlAttribute; class Identity { private String name; private byte[] privateKey; private byte[] publicKey; @SuppressWarnings("unused") public Identity() { // Default constructor } public Identity(String name, byte[] privateKey, byte[] publicKey) { this.name = name; this.privateKey = privateKey; this.publicKey = publicKey; } @XmlAttribute public String getName() { return name; } public void setName(String name) { this.name = name; } @XmlAttribute public byte[] getPrivateKey() { return privateKey; } public void setPrivateKey(byte[] privateKey) { this.privateKey = privateKey; } @XmlAttribute public byte[] getPublicKey() { return publicKey; } public void setPublicKey(byte[] publicKey) { this.publicKey = publicKey; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/Local.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; class Local { private Profile profile; private Location location; private Identity identity; public Local() { // Default constructor } public Profile getProfile() { return profile; } public void setProfile(Profile profile) { this.profile = profile; } public Location getLocation() { return location; } public void setLocation(Location location) { this.location = location; } public Identity getIdentity() { return identity; } public void setIdentity(Identity identity) { this.identity = identity; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/Location.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import io.xeres.common.id.LocationIdentifier; import jakarta.xml.bind.annotation.XmlAttribute; import jakarta.xml.bind.annotation.XmlType; import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter; @XmlType(name = "localLocation") class Location { private LocationIdentifier locationIdentifier; private byte[] privateKey; private byte[] publicKey; private byte[] x509Certificate; private int localPort; @SuppressWarnings("unused") public Location() { // Default constructor } public Location(LocationIdentifier locationIdentifier, byte[] privateKey, byte[] publicKey, byte[] x509Certificate, int localPort) { this.locationIdentifier = locationIdentifier; this.privateKey = privateKey; this.publicKey = publicKey; this.x509Certificate = x509Certificate; this.localPort = localPort; } @XmlAttribute(name = "locationId") @XmlJavaTypeAdapter(LocationIdentifierXmlAdapter.class) public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } public void setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; } @XmlAttribute public byte[] getPrivateKey() { return privateKey; } public void setPrivateKey(byte[] privateKey) { this.privateKey = privateKey; } @XmlAttribute public byte[] getPublicKey() { return publicKey; } public void setPublicKey(byte[] publicKey) { this.publicKey = publicKey; } @XmlAttribute public byte[] getX509Certificate() { return x509Certificate; } public void setX509Certificate(byte[] x509Certificate) { this.x509Certificate = x509Certificate; } @XmlAttribute public int getLocalPort() { return localPort; } public void setLocalPort(int localPort) { this.localPort = localPort; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/LocationIdentifierXmlAdapter.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import io.xeres.common.id.LocationIdentifier; import jakarta.xml.bind.annotation.adapters.XmlAdapter; public class LocationIdentifierXmlAdapter extends XmlAdapter { @Override public LocationIdentifier unmarshal(String v) { return LocationIdentifier.fromString(v); } @Override public String marshal(LocationIdentifier v) { return v.toString(); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/PgpId.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import jakarta.xml.bind.annotation.XmlElement; import java.util.List; class PgpId { private List sslIDs; public PgpId() { // Default constructor } @XmlElement(name = "sslID") public List getSslIDs() { return sslIDs; } public void setSslIDs(List sslIDs) { this.sslIDs = sslIDs; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/Profile.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import jakarta.xml.bind.annotation.XmlAttribute; import jakarta.xml.bind.annotation.XmlType; @XmlType(name = "localProfile") class Profile { private byte[] pgpPrivateKey; @SuppressWarnings("unused") public Profile() { // Default constructor } public Profile(byte[] pgpPrivateKey) { this.pgpPrivateKey = pgpPrivateKey; } @XmlAttribute public byte[] getPgpPrivateKey() { return pgpPrivateKey; } public void setPgpPrivateKey(byte[] pgpPrivateKey) { this.pgpPrivateKey = pgpPrivateKey; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/RSIdXmlAdapter.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import io.xeres.app.crypto.rsid.RSId; import io.xeres.common.rsid.Type; import jakarta.xml.bind.annotation.adapters.XmlAdapter; public class RSIdXmlAdapter extends XmlAdapter { @Override public RSId unmarshal(String v) { return RSId.parse(v, Type.CERTIFICATE).orElseThrow(() -> new IllegalArgumentException("Couldn't parse certificate")); } @Override public String marshal(RSId v) { return v.getArmored(); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/Root.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlElementWrapper; import jakarta.xml.bind.annotation.XmlRootElement; import java.util.List; @XmlRootElement class Root { private List pgpIDs; private List groups; public Root() { // Default constructor } @XmlElementWrapper @XmlElement(name = "pgpID") public List getPgpIDs() { return pgpIDs; } public void setPgpIDs(List pgpIDs) { this.pgpIDs = pgpIDs; } @XmlElementWrapper @XmlElement(name = "group") public List getGroups() { return groups; } public void setGroups(List groups) { this.groups = groups; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/backup/SslId.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.backup; import jakarta.xml.bind.annotation.XmlAttribute; class SslId { private String certificate; public SslId() { // Default constructor } @XmlAttribute(name = "certificate") public String getCertificate() { return certificate; } public void setCertificate(String certificate) { this.certificate = certificate; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/file/FileService.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.file; import io.xeres.app.configuration.DataDirConfiguration; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.database.model.file.File; import io.xeres.app.database.model.file.FileDownload; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.model.share.Share; import io.xeres.app.database.repository.FileDownloadRepository; import io.xeres.app.database.repository.FileRepository; import io.xeres.app.database.repository.ShareRepository; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.app.util.expression.Expression; import io.xeres.common.id.Sha1Sum; import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.FileInputStream; import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; import java.time.Instant; import java.time.temporal.TemporalAmount; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import static org.apache.commons.collections4.ListUtils.emptyIfNull; @Service public class FileService { private static final Logger log = LoggerFactory.getLogger(FileService.class); public static final String DOWNLOAD_PREFIX = "."; public static final String DOWNLOAD_EXTENSION = ".xrsdownload"; private static final TemporalAmount SCAN_DELAY = Duration.ofMinutes(10); // Delay between shares scan private static final Map temporaryHashes = new ConcurrentHashMap<>(); static final int SMALL_FILE_SIZE = 1024 * 16; // 16 KB private final FileNotificationService fileNotificationService; private final ShareRepository shareRepository; private final FileRepository fileRepository; private final FileDownloadRepository fileDownloadRepository; private final HashBloomFilter bloomFilter; private final EntityManager entityManager; private static final String[] ignoredSuffixes = { ".bak", ".sys", ".com", ".class", ".obj", ".o", ".tmp", ".temp", ".cache", DOWNLOAD_EXTENSION, "~" }; private static final String[] ignoredPrefixes = { "thumbs", "temp." }; public FileService(FileNotificationService fileNotificationService, ShareRepository shareRepository, FileRepository fileRepository, FileDownloadRepository fileDownloadRepository, DataDirConfiguration dataDirConfiguration, EntityManager entityManager) { this.fileNotificationService = fileNotificationService; this.shareRepository = shareRepository; this.fileRepository = fileRepository; this.fileDownloadRepository = fileDownloadRepository; bloomFilter = new HashBloomFilter(dataDirConfiguration.getDataDir(), 10_000, 0.01d); // XXX: parameters will need experimenting, especially the max files (yes it can be extended, but not reduced) this.entityManager = entityManager; updateBloomFilter(); } /** * Adds a share. * * @param share the share, the name must be unique otherwise nothing is added */ @Transactional public void addShare(Share share) { if (shareRepository.findByName(share.getName()).isPresent()) { return; } saveFullPath(share.getFile()); shareRepository.save(share); } /** * This is used for migration only. */ @Transactional public void encryptAllHashes() { fileRepository.findAll().forEach(file -> { if (file.getHash() != null) { file.setEncryptedHash(encryptHash(file.getHash())); } }); } /** * Checks shares and scans the oldest one. *

* Note that the user might expect at most each {@link #SCAN_DELAY} for a new file to be picked up, that's why * the time spent while scanning is included. */ @Transactional public void checkForSharesToScan() { var sharesToScan = shareRepository.findAll(Sort.by(Sort.Order.by("lastScanned")).ascending()); log.debug("Shares to scan: {}", sharesToScan); var now = Instant.now(); sharesToScan.stream() .filter(share -> share.getLastScanned() == null || share.getLastScanned().isBefore(now.minus(SCAN_DELAY))) .findFirst().ifPresent(share -> { log.debug("Scanning: {}", share); share.setLastScanned(now); shareRepository.save(share); scanShare(share); }); } /** * Synchronizes the list of shares. * * @param shares the list of shares to synchronize the database to. */ @Transactional public void synchronize(List shares) { emptyIfNull(shares).forEach(share -> { saveFullPath(share.getFile()); setLastUpdated(share); shareRepository.save(share); }); var ids = shares.stream() .map(Share::getId) .filter(id -> id != 0) .collect(Collectors.toSet()); emptyIfNull(getShares()).forEach(share -> { if (!ids.contains(share.getId())) { // XXX: make sure no indexing process is handling this, it will have to be aborted first then. we need to store it in a list var sharedDirectory = share.getFile(); shareRepository.delete(share); fileRepository.delete(sharedDirectory); } }); } /** * Sets the last updated field properly. Try to keep the old one if possible and it definitely * must not be null. * * @param share the share to set the last updated field */ private void setLastUpdated(Share share) { if (share.getId() != 0L) { var oldShare = shareRepository.findById(share.getId()).orElseThrow(() -> new IllegalStateException("Share ID not found. Concurrent modification?")); share.setLastScanned(oldShare.getLastScanned()); } else { share.setLastScanned(Instant.EPOCH); } } /** * Gets the shares. * * @return the list of shares */ public List getShares() { return shareRepository.findAll(); } /** * Gets a map that allows to find the path of a share. * * @param shares the list of shares * @return a map that can be used to find the path of the list of shares */ public Map getFilesMapFromShares(List shares) { return shares.stream() .collect(Collectors.toMap(Share::getId, share -> toPath(getFullPath(share.getFile())))); } private static String toPath(List files) { return files.stream() .map(file -> file.getName().endsWith(":\\") ? file.getName().substring(0, file.getName().length() - 1) : file.getName()) // On Windows, C:\ -> C: to avoid double file separators .collect(Collectors.joining(java.io.File.separator)); } public Optional findFileByHash(Sha1Sum hash) { var files = fileRepository.findByHash(hash); if (files.isEmpty()) { return Optional.empty(); } return Optional.of(files.getFirst()); } public Optional findFileByEncryptedHash(Sha1Sum encryptedHash) { if (bloomFilter.mightContain(encryptedHash)) { var files = fileRepository.findByEncryptedHash(encryptedHash); if (!files.isEmpty()) { return Optional.of(files.getFirst()); } } return Optional.empty(); } public Optional findFilePathByHash(Sha1Sum hash) { Objects.requireNonNull(hash); var tempPath = temporaryHashes.get(hash); if (tempPath != null) { return Optional.of(tempPath); } return findFileByHash(hash).map(this::getFilePath); } /** * Deletes a file and its parents (if they're not the parent of other files, and they're not a share). * * @param file the file to delete */ public void deleteFile(File file) { var parents = getFullPath(file); for (int i = parents.size() - 2; i >= 0; i--) // File is included in the path so -2 and we go up { var parent = parents.get(i); if (fileRepository.countByParent(parent) != 1) { break; } if (shareRepository.findShareByFile(parent).isPresent()) { break; } file = parent; } fileRepository.delete(file); } public List searchFiles(String name) { return fileRepository.findAllByNameContainingIgnoreCase(name); } public List searchFiles(List expressions) { var cb = entityManager.getCriteriaBuilder(); var query = cb.createQuery(File.class); var file = query.from(File.class); List predicates = new ArrayList<>(); for (Expression expression : expressions) { predicates.add(expression.toPredicate(cb, file)); } query.select(file).where(cb.and(predicates.toArray(new Predicate[0]))); return entityManager.createQuery(query).getResultList(); } public Optional findShareForFile(File file) { Set fileIds = new HashSet<>(); while (file.hasParent()) { fileIds.add(file.getId()); file = file.getParent(); } return shareRepository.findShareByFileIdIn(fileIds); } public long addDownload(String name, Sha1Sum hash, long size, Location location) { var download = fileDownloadRepository.findByHash(hash); if (download.isPresent()) { return download.get().getId(); } var fileDownload = new FileDownload(); fileDownload.setName(name); fileDownload.setHash(hash); fileDownload.setSize(size); fileDownload.setLocation(location); var saved = fileDownloadRepository.save(fileDownload); return saved.getId(); } @Transactional public void suspendDownload(Sha1Sum hash, BitSet chunkMap) { fileDownloadRepository.findByHash(hash).ifPresent(fileDownload -> fileDownload.setChunkMap(chunkMap)); } @Transactional public void markDownloadAsCompleted(Sha1Sum hash) { fileDownloadRepository.findByHash(hash).ifPresent(fileDownload -> fileDownload.setCompleted(true)); } public Optional findById(long id) { return fileDownloadRepository.findById(id); } @Transactional public void removeDownload(long id) { fileDownloadRepository.deleteById(id); } public Optional findByPath(Path path) { var candidates = fileRepository.findAllByName(path.getFileName().toString()); for (File candidate : candidates) { if (getFullPathAsString(candidate).equals(path.toString())) { return Optional.of(candidate.getHash()); } } return Optional.empty(); } public static Sha1Sum encryptHash(Sha1Sum hash) { var digest = new Sha1MessageDigest(); digest.update(hash.getBytes()); return digest.getSum(); } private void saveFullPath(File file) { var tree = getFullPath(file); // Only save the new file paths tree.forEach(f -> { if (f.getId() == 0L) { var saved = fileRepository.save(f); f.setId(saved.getId()); } }); } private List getFullPath(File file) { List tree = new ArrayList<>(); tree.add(file); while (file.getParent() != null) { var parent = file.getParent(); tree.add(parent); file = parent; } Collections.reverse(tree); // We need to use findByNameAndParent*Name*() here because the parents are built on the fly and not taken from the database. // Otherwise, hibernate would complain about unsaved transient references. tree.forEach(fileToUpdate -> fileRepository.findByNameAndParentName(fileToUpdate.getName(), fileToUpdate.getParent() != null ? fileToUpdate.getParent().getName() : null).ifPresent(fileFound -> fileToUpdate.setId(fileFound.getId()))); return tree; } private String getFullPathAsString(File file) { return toPath(getFullPath(file)); } void scanShare(Share share) { try { var ioBuffer = new byte[SMALL_FILE_SIZE]; fileNotificationService.startScanning(share); var directory = share.getFile(); var directoryPath = getFilePath(directory); var visitor = new TrackingFileVisitor(fileRepository, directory) { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { Objects.requireNonNull(file); Objects.requireNonNull(attrs); if (isIndexableFile(file, attrs)) { indexFile(file, attrs); } return FileVisitResult.CONTINUE; } @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { Objects.requireNonNull(dir); Objects.requireNonNull(attrs); if (isIndexableDirectory(dir, attrs)) { indexDirectory(dir, attrs); return FileVisitResult.CONTINUE; } else { return FileVisitResult.SKIP_SUBTREE; } } @Override public FileVisitResult postVisitDirectory(Path dir, IOException exc) { Objects.requireNonNull(dir); super.postVisitDirectory(dir, exc); if (exc != null) { log.debug("Failed to fully scan directory {}: {}", dir, exc.getMessage()); } return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFileFailed(Path file, IOException exc) { Objects.requireNonNull(file); log.debug("Visiting file {} failed: {}", file, exc.getMessage()); return FileVisitResult.CONTINUE; } private void indexFile(Path file, BasicFileAttributes attrs) { var currentFile = fileRepository.findByNameAndParent(file.getFileName().toString(), getCurrentDirectory()).orElseGet(() -> File.createFile(getCurrentDirectory(), file.getFileName().toString(), attrs.size(), null)); var lastModified = attrs.lastModifiedTime().toInstant(); log.debug("Checking file {}, modification time: {}", file, lastModified); if (currentFile.getModified() == null || lastModified.isAfter(currentFile.getModified())) { log.debug("Current file in database, modified: {}", currentFile.getModified()); var hash = calculateFileHash(file, ioBuffer); currentFile.setHash(hash); currentFile.setEncryptedHash(encryptHash(hash)); currentFile.setModified(lastModified); fileRepository.save(currentFile); setChanged(); } } private void indexDirectory(Path dir, BasicFileAttributes attrs) { super.preVisitDirectory(dir, attrs); log.debug("Entering directory {}", dir); var directory = getCurrentDirectory(); if (fileRepository.findByNameAndParent(directory.getName(), directory.getParent()).isEmpty()) { fileRepository.save(directory); } } }; Files.walkFileTree(directoryPath, visitor); directory.setModified(Files.getLastModifiedTime(directoryPath).toInstant()); fileRepository.save(directory); if (visitor.foundChanges()) { updateBloomFilter(); } } catch (IOException e) { throw new RuntimeException(e); } finally { fileNotificationService.stopScanning(); } } public Path getFilePath(File file) { if (file.hasParent()) { return getFilePath(file.getParent()).resolve(file.getName()); } return Path.of(file.getName()); } private boolean isIndexableFile(Path file, BasicFileAttributes attrs) { if (attrs.isRegularFile() && attrs.size() > 0) { var fileName = file.getFileName().toString(); return !isIgnoredFile(fileName); } return false; } private boolean isIndexableDirectory(Path directory, BasicFileAttributes attrs) { if (attrs.isDirectory()) { var directoryName = directory.getFileName().toString(); return !isIgnoredDirectory(directoryName); } return false; } private static boolean isIgnoredFile(String fileName) { fileName = fileName.toLowerCase(Locale.ROOT); for (var ignoredSuffix : ignoredSuffixes) { if (fileName.endsWith(ignoredSuffix)) { return true; } } for (var ignoredPrefix : ignoredPrefixes) { if (fileName.startsWith(ignoredPrefix)) { return true; } } return false; } private static boolean isIgnoredDirectory(String dirName) { return dirName.startsWith("."); } public Sha1Sum calculateTemporaryFileHash(Path path) { var byPath = findByPath(path); if (byPath.isPresent()) { return byPath.get(); } var ioBuffer = new byte[SMALL_FILE_SIZE]; var hash = calculateFileHash(path, ioBuffer); if (hash != null) { temporaryHashes.put(hash, path); } return hash; } Sha1Sum calculateFileHash(Path path, byte[] ioBuffer) { log.debug("Calculating file hash of file {}", path); try { var size = Files.size(path); if (size == 0) { log.debug("File is empty, ignoring"); return null; // We ignore empty files } else if (size > SMALL_FILE_SIZE) { return calculateLargeFileHash(path); } else { return calculateSmallFileHash(path, ioBuffer); } } catch (IOException e) { log.warn("Error while trying to compute hash of file {}", path, e); return null; } } private Sha1Sum calculateLargeFileHash(Path path) throws IOException { try (var fc = FileChannel.open(path, StandardOpenOption.READ)) // ExtendedOpenOption.DIRECT is useless for memory mapped files { fileNotificationService.startScanningFile(path); var md = new Sha1MessageDigest(); var size = fc.size(); var offset = 0L; while (size > 0) { var bufferSize = Math.min(size, Integer.MAX_VALUE); var buffer = fc.map(FileChannel.MapMode.READ_ONLY, offset, bufferSize); md.update(buffer); offset += bufferSize; size -= bufferSize; } return md.getSum(); } finally { fileNotificationService.stopScanningFile(); } } private Sha1Sum calculateSmallFileHash(Path path, byte[] ioBuffer) throws IOException { try (var ios = new FileInputStream(path.toFile())) { fileNotificationService.startScanningFile(path); var md = new Sha1MessageDigest(); int read; while ((read = ios.read(ioBuffer)) > 0) { md.update(ioBuffer, 0, read); } return md.getSum(); } finally { fileNotificationService.stopScanningFile(); } } private void updateBloomFilter() { // XXX: extend the bloom filter if needed bloomFilter.clear(); fileRepository.findAll().forEach(file -> bloomFilter.add(file.getEncryptedHash())); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/file/HashBloomFilter.java ================================================ /* * Copyright (c) 2023-2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.file; import com.sangupta.bloomfilter.AbstractBloomFilter; import com.sangupta.bloomfilter.core.BitArray; import com.sangupta.bloomfilter.core.JavaBitSetArray; import com.sangupta.bloomfilter.core.MMapFileBackedBitArray; import io.xeres.common.id.Sha1Sum; import java.io.IOException; import java.nio.file.Path; import java.util.Collection; /** * A Bloom filter implementation specifically designed for storing Turtle file hashes. *

* Use add() to insert entries and mightContain() to check if an entry might be in it. False positives * are possible and one just has to make sure that the probability is low enough so that accesses to the * database are kept at a minimum when not needed. In any case a match needs a database access for confirmation. *

* Removing an entry is not possible. One has to clear and re-add all entries. *

* The entries are persisted to disk. */ public class HashBloomFilter { private static final String PERSISTENT_FILE = "turtle_bf"; private final AbstractBloomFilter bFilter; private BitArray bArray; public HashBloomFilter(String baseDir, int expectedInsertions, double falsePositiveProbability) { bFilter = new AbstractBloomFilter<>(expectedInsertions, falsePositiveProbability, (sha1Sum, byteSink) -> byteSink.putBytes(sha1Sum.getBytes())) { @Override protected BitArray createBitArray(int numBits) { if (baseDir == null) { bArray = new JavaBitSetArray(numBits); return bArray; } else { try { bArray = new MMapFileBackedBitArray(Path.of(baseDir, PERSISTENT_FILE).toFile(), numBits); return bArray; } catch (IOException e) { throw new RuntimeException(e); } } } @Override public boolean contains(Sha1Sum value) { // The following workaround (the getBytes() call) is needed unless // https://github.com/sangupta/bloomfilter/pull/5 is merged and a new upstream release is done. // We also need to clone it otherwise the array gets modified. return super.contains(value.clone().getBytes()); } }; } /** * Adds a value. * * @param value the value to be added */ public void add(Sha1Sum value) { bFilter.add(value); } /** * Adds all the values from the given collection. * * @param values the collection of values to be added */ public void addAll(Collection values) { bFilter.addAll(values); } /** * Determines if the given value might be in the bloom filter. * * @param value the value to check * @return true if the value is possibly in it, false if it's definitely not */ public boolean mightContain(Sha1Sum value) { return bFilter.contains(value); } /** * Determines if all the given values might be contained in the bloom filter. * * @param values the collection of values to check * @return true if all the values are possibly in it, false if at least one is definitely not */ public boolean mightContainAll(Collection values) { return bFilter.containsAll(values); } /** * Clears the Bloom filter back to an empty state. */ public void clear() { bArray.clear(); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/file/TrackingFileVisitor.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.file; import io.xeres.app.database.model.file.File; import io.xeres.app.database.repository.FileRepository; import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.FileVisitor; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; public class TrackingFileVisitor implements FileVisitor { private final FileRepository fileRepository; private boolean skipRoot; // The first entered directory is already the root directory private final List directories = new ArrayList<>(); private boolean foundChanges; public TrackingFileVisitor(FileRepository fileRepository, File rootDirectory) { this.fileRepository = fileRepository; directories.addLast(rootDirectory); } @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { if (!skipRoot) { skipRoot = true; } else { var directory = fileRepository.findByNameAndParent(dir.getFileName().toString(), directories.getLast()).orElseGet(() -> File.createDirectory(directories.getLast(), dir.getFileName().toString(), attrs.lastModifiedTime().toInstant())); directories.addLast(directory); } return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFileFailed(Path file, IOException exc) { return FileVisitResult.CONTINUE; } @Override public FileVisitResult postVisitDirectory(Path dir, IOException exc) { directories.removeLast(); return FileVisitResult.CONTINUE; } public File getCurrentDirectory() { return directories.getLast(); } public boolean foundChanges() { return foundChanges; } void setChanged() { foundChanges = true; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/identicon/IdenticonService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.identicon; import io.xeres.app.configuration.CacheDirConfiguration; import io.xeres.common.gxs.GxsGroupConstants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import java.awt.geom.AffineTransform; import java.awt.image.AffineTransformOp; import java.awt.image.BufferedImage; import java.awt.image.WritableRaster; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @Service public class IdenticonService { private static final Logger log = LoggerFactory.getLogger(IdenticonService.class); private final CacheDirConfiguration cacheDirConfiguration; public IdenticonService(CacheDirConfiguration cacheDirConfiguration) { this.cacheDirConfiguration = cacheDirConfiguration; } public byte[] getIdenticon(byte[] hash) { var data = getIdenticonFromCache(hash); if (data != null) { return data; } var image = generateIdenticon(hash, GxsGroupConstants.IMAGE_SIDE_SIZE, GxsGroupConstants.IMAGE_SIDE_SIZE); var output = new ByteArrayOutputStream(); try { ImageIO.write(image, "png", output); } catch (IOException e) { throw new RuntimeException("Could not generate identicon", e); } var outputData = output.toByteArray(); putIdenticonToCache(hash, outputData); return outputData; } private byte[] getIdenticonFromCache(byte[] hash) { var path = getFilePath(hash); if (path == null) { return null; } if (path.toFile().canRead()) { try { return Files.readAllBytes(path); } catch (IOException e) { log.warn("Couldn't read cached file {}: {}", path, e.getMessage()); } } return null; } private void putIdenticonToCache(byte[] hash, byte[] data) { var path = getFilePath(hash); if (path == null) { return; } try { Files.write(path, data); } catch (IOException e) { log.warn("Couldn't write cached file {}: {}", path, e.getMessage()); } } private Path getFilePath(byte[] hash) { var cacheDir = cacheDirConfiguration.getCacheDir(); if (cacheDir == null) { return null; } return Path.of(cacheDir, String.format("identicon_%02x%02x%02x", Byte.toUnsignedInt(hash[0]), Byte.toUnsignedInt(hash[1]), Byte.toUnsignedInt(hash[2]))); } /** * Generates an identicon like the ones from GitHub. * Android version by David Hamp-Gonsalves. * Java version by Kevin Grandjean. * * @param hash the hash, at least 3 bytes are needed * @param imageWidth the width of the images * @param imageHeight the height of the image * @return a buffered image */ private BufferedImage generateIdenticon(byte[] hash, int imageWidth, int imageHeight) { assert hash != null && hash.length >= 3; var width = 5; var height = 5; var identicon = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); WritableRaster raster = identicon.getRaster(); var background = new int[]{240, 240, 240, 255}; var foreground = new int[]{hash[0] & 255, hash[1] & 255, hash[2] & 255, 255}; for (var x = 0; x < width; x++) { //Enforce horizontal symmetry int i = x < 3 ? x : 4 - x; for (var y = 0; y < height; y++) { int[] pixelColor; //toggle pixels based on bit being on/off if ((hash[i] >> y & 1) == 1) { pixelColor = foreground; } else { pixelColor = background; } raster.setPixel(x, y, pixelColor); } } var finalImage = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB); //Scale image to the size you want var at = new AffineTransform(); at.scale((double) imageWidth / width, (double) imageHeight / height); var op = new AffineTransformOp(at, AffineTransformOp.TYPE_NEAREST_NEIGHBOR); finalImage = op.filter(identicon, finalImage); return finalImage; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/NotificationService.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification; import io.xeres.common.rest.notification.Notification; import org.springframework.http.MediaType; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public abstract class NotificationService { final List emitters = new CopyOnWriteArrayList<>(); private Notification previousNotification; private final AtomicBoolean running = new AtomicBoolean(); protected NotificationService() { running.lazySet(true); } /** * Sends that notification to all connecting clients. It's a kind of "sync" notification so that we * get immediate data available. Use it for notifications that report a "state". * * @return the initial notification to send */ protected Notification initialNotification() { return null; } public SseEmitter addClient() { if (!running.get()) { return null; } var emitter = new SseEmitter(-1L); // no timeout addEmitter(emitter); emitter.onCompletion(() -> removeEmitter(emitter)); emitter.onTimeout(() -> removeEmitter(emitter)); CompletableFuture.delayedExecutor(1, TimeUnit.MILLISECONDS).execute(() -> sendInitialNotificationIfNeeded(emitter)); // send a notification to the client that just connected to "sync" it (XXX: remove? what happens if the event is sent immediately? test it... I don't like that delay stuff...) return emitter; } public void sendNotification(Notification notification) { sendNotification(notification, null); } /** * Closes all the emitters. If not called, tomcat will complain about non-closed connections * on shutdown. */ public void shutdown() { running.set(false); emitters.forEach(ResponseBodyEmitter::complete); } private void sendInitialNotificationIfNeeded(SseEmitter emitter) { var notification = initialNotification(); if (notification != null) { sendNotification(notification, emitter); } } private void sendNotification(Notification notification, SseEmitter specificEmitter) { Objects.requireNonNull(notification); if (!running.get()) { return; } if (specificEmitter != null) { sendSseNotification(specificEmitter, notification); } else { if (notification.ignoreDuplicates() && notification.equals(previousNotification)) { return; } previousNotification = notification; sendSseNotification(notification); } } private void addEmitter(SseEmitter emitter) { emitters.add(emitter); } private void removeEmitter(SseEmitter emitter) { emitters.remove(emitter); } private void sendSseNotification(Notification notification) { List deadEmitters = new ArrayList<>(); emitters.forEach(emitter -> { try { emitter.send(createEventBuilder(notification)); } catch (IOException _) { deadEmitters.add(emitter); } }); emitters.removeAll(deadEmitters); } private void sendSseNotification(SseEmitter emitter, Notification notification) { try { emitter.send(createEventBuilder(notification)); } catch (IOException _) { emitters.remove(emitter); } } private static SseEmitter.SseEventBuilder createEventBuilder(Notification notification) { var event = SseEmitter.event(); event.data(notification, MediaType.APPLICATION_JSON); event.name(notification.getType()); return event; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/availability/AvailabilityNotificationService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.availability; import io.xeres.app.database.model.location.Location; import io.xeres.app.service.notification.NotificationService; import io.xeres.common.location.Availability; import io.xeres.common.rest.notification.availability.AvailabilityChange; import org.springframework.stereotype.Service; @Service public class AvailabilityNotificationService extends NotificationService { public void changeAvailability(Location location, Availability availability) { sendNotification(new AvailabilityChange(availability, location.getProfile().getId(), location.getProfile().getName(), location.getId(), location.getSafeName())); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/board/BoardNotificationService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.board; import io.xeres.app.service.BoardMessageService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.service.notification.NotificationService; import io.xeres.app.xrs.service.board.item.BoardGroupItem; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.common.rest.notification.board.AddOrUpdateBoardGroups; import io.xeres.common.rest.notification.board.AddOrUpdateBoardMessages; import io.xeres.common.rest.notification.board.SetBoardGroupMessagesReadState; import io.xeres.common.rest.notification.board.SetBoardMessageReadState; import org.springframework.data.domain.PageImpl; import org.springframework.stereotype.Service; import java.util.List; import static io.xeres.app.database.model.board.BoardMapper.toBoardMessageDTOs; import static io.xeres.app.database.model.board.BoardMapper.toDTOs; @Service public class BoardNotificationService extends NotificationService { private final UnHtmlService unHtmlService; private final BoardMessageService boardMessageService; public BoardNotificationService(UnHtmlService unHtmlService, BoardMessageService boardMessageService) { this.unHtmlService = unHtmlService; this.boardMessageService = boardMessageService; } public void addOrUpdateGroups(List groups) { sendNotification(new AddOrUpdateBoardGroups(toDTOs(groups))); } public void addOrUpdateMessages(List messages) { var page = new PageImpl<>(messages); sendNotification(new AddOrUpdateBoardMessages(toBoardMessageDTOs(unHtmlService, page, boardMessageService.getAuthorsMapFromMessages(page), boardMessageService.getMessagesMapFromMessages(messages)))); } public void setMessageReadState(long groupId, long messageId, boolean read) { sendNotification(new SetBoardMessageReadState(groupId, messageId, read)); } public void setGroupMessagesReadState(long groupId, boolean read) { sendNotification(new SetBoardGroupMessagesReadState(groupId, read)); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/channel/ChannelNotificationService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.channel; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.service.IdentityService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.service.notification.NotificationService; import io.xeres.app.xrs.service.channel.ChannelRsService; import io.xeres.app.xrs.service.channel.item.ChannelGroupItem; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.rest.notification.channel.AddOrUpdateChannelGroups; import io.xeres.common.rest.notification.channel.AddOrUpdateChannelMessages; import io.xeres.common.rest.notification.channel.SetChannelGroupMessagesReadState; import io.xeres.common.rest.notification.channel.SetChannelMessageReadState; import org.apache.commons.collections4.SetUtils; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import static io.xeres.app.database.model.channel.ChannelMapper.toChannelMessageDTOs; import static io.xeres.app.database.model.channel.ChannelMapper.toDTOs; @Service public class ChannelNotificationService extends NotificationService { private final ChannelRsService channelRsService; private final IdentityService identityService; private final UnHtmlService unHtmlService; public ChannelNotificationService(@Lazy ChannelRsService channelRsService, IdentityService identityService, UnHtmlService unHtmlService) { this.channelRsService = channelRsService; this.identityService = identityService; this.unHtmlService = unHtmlService; } public void addOrUpdateGroups(List groups) { sendNotification(new AddOrUpdateChannelGroups(toDTOs(groups))); } public void addOrUpdateMessages(List messages) { sendNotification(new AddOrUpdateChannelMessages(toChannelMessageDTOs(unHtmlService, messages, getAuthorsMapFromMessages(messages), getMessagesMapFromMessages(messages), false))); } public void setMessageReadState(long groupId, long messageId, boolean read) { sendNotification(new SetChannelMessageReadState(groupId, messageId, read)); } public void setGroupMessagesReadState(long groupId, boolean read) { sendNotification(new SetChannelGroupMessagesReadState(groupId, read)); } private Map getAuthorsMapFromMessages(List channelMessages) { var authors = channelMessages.stream() .map(ChannelMessageItem::getAuthorGxsId) .collect(Collectors.toSet()); return identityService.findAll(authors).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity())); } private Map getMessagesMapFromMessages(List channelMessages) { var msgIds = channelMessages.stream() .map(ChannelMessageItem::getMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); var parentMsgIds = channelMessages.stream() .map(ChannelMessageItem::getParentMsgId) .filter(Objects::nonNull) .collect(Collectors.toSet()); return channelRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream() .collect(Collectors.toMap(ChannelMessageItem::getMsgId, Function.identity())); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/contact/ContactNotificationService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.contact; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.service.ContactService; import io.xeres.app.service.notification.NotificationService; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.rest.contact.Contact; import io.xeres.common.rest.notification.contact.AddOrUpdateContacts; import io.xeres.common.rest.notification.contact.RemoveContacts; import org.springframework.stereotype.Service; import java.util.List; @Service public class ContactNotificationService extends NotificationService { private final ContactService contactService; public ContactNotificationService(ContactService contactService) { this.contactService = contactService; } public void addOrUpdateIdentities(List identities) { addOrUpdateContacts(contactService.toContacts(identities)); } public void removeIdentities(List identities) { removeContacts(contactService.toContacts(identities)); } public void addOrUpdateProfile(Profile profile) { addOrUpdateContacts(List.of(contactService.toContact(profile))); } public void removeProfile(Profile profile) { removeContacts(List.of(contactService.toContact(profile))); } private void addOrUpdateContacts(List contacts) { sendNotification(new AddOrUpdateContacts(contacts)); } private void removeContacts(List contacts) { sendNotification(new RemoveContacts(contacts)); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/file/FileNotificationService.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.file; import io.xeres.app.database.model.share.Share; import io.xeres.app.service.notification.NotificationService; import io.xeres.common.rest.notification.Notification; import io.xeres.common.rest.notification.file.FileNotification; import io.xeres.common.rest.notification.file.FileNotificationAction; import org.springframework.stereotype.Service; import java.nio.file.Path; import static io.xeres.common.rest.notification.file.FileNotificationAction.*; @Service public class FileNotificationService extends NotificationService { private FileNotificationAction action = NONE; private String shareName; private String scannedFile; @Override protected Notification initialNotification() { return createNotification(); } private Notification createNotification() { return new FileNotification(action, shareName, scannedFile); } public void startScanning(Share share) { action = START_SCANNING; shareName = share.getName(); sendNotification(createNotification()); } public void startScanningFile(Path scannedFile) { action = START_HASHING; this.scannedFile = scannedFile.toString(); sendNotification(createNotification()); } public void stopScanningFile() { action = STOP_HASHING; scannedFile = null; sendNotification(createNotification()); } public void stopScanning() { action = STOP_SCANNING; shareName = null; scannedFile = null; sendNotification(createNotification()); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/file/FileSearchNotificationService.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.file; import io.xeres.app.service.notification.NotificationService; import io.xeres.common.id.Id; import io.xeres.common.id.Sha1Sum; import io.xeres.common.rest.notification.file.FileSearchNotification; import org.springframework.stereotype.Service; @Service public class FileSearchNotificationService extends NotificationService { public void foundFile(int requestId, String name, long size, Sha1Sum hash) { sendNotification(new FileSearchNotification(requestId, name, size, Id.toString(hash))); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/file/FileTrendNotificationService.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.file; import io.xeres.app.service.notification.NotificationService; import io.xeres.common.rest.notification.file.FileTrendNotification; import org.springframework.stereotype.Service; @Service public class FileTrendNotificationService extends NotificationService { public void receivedSearch(String senderName, String keywords) { sendNotification(new FileTrendNotification(senderName, keywords)); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/forum/ForumNotificationService.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.forum; import io.xeres.app.service.ForumMessageService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.service.notification.NotificationService; import io.xeres.app.xrs.service.forum.item.ForumGroupItem; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.common.rest.notification.forum.AddOrUpdateForumGroups; import io.xeres.common.rest.notification.forum.AddOrUpdateForumMessages; import io.xeres.common.rest.notification.forum.SetForumGroupMessagesReadState; import io.xeres.common.rest.notification.forum.SetForumMessageReadState; import org.springframework.stereotype.Service; import java.util.List; import static io.xeres.app.database.model.forum.ForumMapper.toDTOs; import static io.xeres.app.database.model.forum.ForumMapper.toForumMessageDTOs; @Service public class ForumNotificationService extends NotificationService { private final ForumMessageService forumMessageService; private final UnHtmlService unHtmlService; public ForumNotificationService(ForumMessageService forumMessageService, UnHtmlService unHtmlService) { super(); this.forumMessageService = forumMessageService; this.unHtmlService = unHtmlService; } public void addOrUpdateGroups(List groups) { sendNotification(new AddOrUpdateForumGroups(toDTOs(groups))); } public void addOrUpdateMessages(List messages) { sendNotification(new AddOrUpdateForumMessages(toForumMessageDTOs(unHtmlService, messages, forumMessageService.getAuthorsMapFromMessages(messages), forumMessageService.getMessagesMapFromMessages(messages), false))); } public void setMessageReadState(long groupId, long messageId, boolean read) { sendNotification(new SetForumMessageReadState(groupId, messageId, read)); } public void setGroupMessagesReadState(long groupId, boolean read) { sendNotification(new SetForumGroupMessagesReadState(groupId, read)); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/notification/status/StatusNotificationService.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.notification.status; import io.xeres.app.service.UiBridgeService; import io.xeres.app.service.notification.NotificationService; import io.xeres.common.rest.notification.Notification; import io.xeres.common.rest.notification.status.DhtInfo; import io.xeres.common.rest.notification.status.DhtStatus; import io.xeres.common.rest.notification.status.NatStatus; import io.xeres.common.rest.notification.status.StatusNotification; import org.springframework.stereotype.Service; import java.text.MessageFormat; import java.util.ResourceBundle; @Service public class StatusNotificationService extends NotificationService { private int currentUserCount; private int totalUsers; private NatStatus natStatus = NatStatus.UNKNOWN; private DhtInfo dhtInfo = DhtInfo.fromStatus(DhtStatus.OFF); private final UiBridgeService uiBridgeService; private final ResourceBundle bundle; public StatusNotificationService(UiBridgeService uiBridgeService, ResourceBundle bundle) { super(); this.uiBridgeService = uiBridgeService; this.bundle = bundle; } public void setCurrentUsersCount(int value) { currentUserCount = value; sendNotification(createNotification()); uiBridgeService.setTrayStatus(MessageFormat.format(bundle.getString("main.systray.peers"), value)); } public void setTotalUsers(int value) { totalUsers = value - 1; // We remove our own location sendNotification(createNotification()); } public void setNatStatus(NatStatus value) { natStatus = value; sendNotification(createNotification()); } public void setDhtInfo(DhtInfo value) { dhtInfo = value; sendNotification(createNotification()); } @Override protected Notification initialNotification() { return createNotification(); } private Notification createNotification() { return new StatusNotification( currentUserCount, totalUsers, natStatus, dhtInfo ); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/script/Console.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.script; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @SuppressWarnings("unused") // All methods here can be used by JS public class Console { private static final Logger log = LoggerFactory.getLogger(Console.class); private static final String JS_PREFIX = "[JS]"; public void log(String message) { info(message); } public void info(String message) { log.info(JS_PREFIX + " {}", message); } public void debug(String message) { log.debug(JS_PREFIX + " {}", message); } public void error(String message) { log.error(JS_PREFIX + " {}", message); } public void warn(String message) { log.warn(JS_PREFIX + " {}", message); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/script/ScriptEvent.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.script; record ScriptEvent(String type, Object data) { @Override public String toString() { return "ScriptEvent{" + "type='" + type + '\'' + ", data=" + data + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/service/script/ScriptService.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.script; import io.xeres.app.configuration.DataDirConfiguration; import io.xeres.app.service.IdentityService; import io.xeres.app.service.LocationService; import io.xeres.app.service.MessageService; import io.xeres.app.xrs.service.chat.ChatRsService; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.ChatMessage; import io.xeres.common.message.chat.ChatRoomMessage; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.PolyglotException; import org.graalvm.polyglot.Value; import org.graalvm.polyglot.proxy.ProxyArray; import org.graalvm.polyglot.proxy.ProxyObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicBoolean; import static io.xeres.common.message.MessagePath.*; /// A service to run JS scripts. @Service public class ScriptService { private static final Logger log = LoggerFactory.getLogger(ScriptService.class); private Context context; private final Map eventHandlers = new ConcurrentHashMap<>(); private final AtomicBoolean initialized = new AtomicBoolean(false); private final BlockingQueue eventQueue = new LinkedBlockingQueue<>(); private Thread eventProcessorThread; private final Environment environment; private final DataDirConfiguration dataDirConfiguration; private final ChatRsService chatRsService; private final MessageService messageService; private final IdentityService identityService; private final LocationService locationService; public ScriptService(Environment environment, DataDirConfiguration dataDirConfiguration, @Lazy ChatRsService chatRsService, MessageService messageService, IdentityService identityService, LocationService locationService) { this.environment = environment; this.dataDirConfiguration = dataDirConfiguration; this.chatRsService = chatRsService; this.messageService = messageService; this.identityService = identityService; this.locationService = locationService; } @PostConstruct private void init() { startContext(false); } /// Reloads all scripts. public void reload() { closeContext(); startContext(true); } private void startContext(boolean throwIfErrors) { if (initialized.get()) { return; } Path scriptPath; if (environment.acceptsProfiles(Profiles.of("dev"))) { scriptPath = Path.of("./scripts/api/user.js"); } else { if (dataDirConfiguration.getDataDir() == null) // Don't run for tests { return; } scriptPath = Path.of(dataDirConfiguration.getDataDir(), "Scripts/user.js"); } if (!scriptPath.toFile().isFile()) { log.info("Script file not found: {}", scriptPath); return; } context = Context.newBuilder("js") .option("js.strict", "true") .option("js.console", "false") .allowAllAccess(true) .build(); String scriptContent; try { scriptContent = new String(Files.readAllBytes(scriptPath)); } catch (IOException e) { log.error("Error reading script file: {}", scriptPath, e); return; } // Expose some APIs to the JavaScript script context.getBindings("js").putMember("xeresAPI", new XeresAPI()); context.getBindings("js").putMember("console", new Console()); // Execute the script try { context.eval("js", scriptContent); } catch (PolyglotException e) { if (throwIfErrors) { throw e; } else { log.error("Error in script {}", scriptPath, e); } } initialized.set(true); startEventProcessor(); } private void startEventProcessor() { // We use platform threads, because using polyglot contexts on Java virtual threads on HotSpot is experimental in this release, // because access to caller frames in write or materialize mode is not yet supported on virtual threads (some tools and languages depend on that). eventProcessorThread = Thread.ofPlatform() .name("JavaScript Runner") .start(() -> { while (initialized.get() && !Thread.currentThread().isInterrupted()) { try { ScriptEvent event = eventQueue.take(); processEvent(event); } catch (InterruptedException _) { Thread.currentThread().interrupt(); break; } } }); } public void sendEvent(String type, Object data) { if (!initialized.get()) { return; } eventQueue.add(new ScriptEvent(type, data)); } private void processEvent(ScriptEvent event) { try { // Check if the script has a handler for this event type Value handler = eventHandlers.get(event.type()); if (handler != null && handler.canExecute()) { // Convert Java data to JavaScript value var jsData = convertToJsValue(event.data()); handler.execute(jsData); } } catch (PolyglotException e) { log.error("Error processing event {}", event, e); } } private Value convertToJsValue(Object data) { if (data instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) data; var proxyMap = ProxyObject.fromMap(map); return context.asValue(proxyMap); } if (data instanceof List) { @SuppressWarnings("unchecked") List list = (List) data; var proxyArray = ProxyArray.fromList(list); return context.asValue(proxyArray); } return context.asValue(data); } @PreDestroy private void shutdown() { closeContext(); } private void closeContext() { initialized.set(false); if (eventProcessorThread != null) { eventProcessorThread.interrupt(); } if (context != null) { context.close(); } } /// The Xeres API callable by JS scripts. @SuppressWarnings("unused") // All methods here can be used by JS public class XeresAPI { /// Registers an event handler. Those are called by Xeres. /// /// @param eventType the event type /// @param handler the handler public void registerEventHandler(String eventType, Value handler) { eventHandlers.put(eventType, handler); } /// Sends a message to a chat room. /// /// @param roomId the room id /// @param message the message public void sendChatRoomMessage(long roomId, String message) { chatRsService.sendChatRoomMessage(roomId, message); messageService.sendToConsumers(chatRoomDestination(), MessageType.CHAT_ROOM_MESSAGE, roomId, new ChatRoomMessage(identityService.getOwnIdentity().getName(), identityService.getOwnIdentity().getGxsId(), message)); } /// Sends a private chat message. /// /// @param destination the destination (location) /// @param message the message public void sendPrivateMessage(String destination, String message) { var location = LocationIdentifier.fromString(destination); chatRsService.sendPrivateMessage(location, message); var chatMessage = new ChatMessage(message); chatMessage.setOwn(true); messageService.sendToConsumers(chatPrivateDestination(), MessageType.CHAT_PRIVATE_MESSAGE, location, chatMessage); } /// Sends a distant chat message. /// /// @param destination the destination (gxsId) /// @param message the message public void sendDistantMessage(String destination, String message) { var gxsId = GxsId.fromString(destination); chatRsService.sendPrivateMessage(gxsId, message); var chatMessage = new ChatMessage(message); chatMessage.setOwn(true); messageService.sendToConsumers(chatDistantDestination(), MessageType.CHAT_PRIVATE_MESSAGE, gxsId, chatMessage); } public String getAvailability() { return locationService.findOwnLocation().orElseThrow().getAvailability().name(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/service/shell/History.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.shell; import java.util.LinkedList; class History { private final LinkedList historyList = new LinkedList<>(); private final int maxSize; private int currentIndex; public History(int maxSize) { this.maxSize = maxSize; currentIndex = -1; } public void addCommand(String command) { if (command == null || command.trim().isEmpty()) { return; } // Remove duplicates historyList.remove(command); // Add to front historyList.addFirst(command); // Maintain size if (historyList.size() > maxSize) { historyList.removeLast(); } currentIndex = -1; } public String getPrevious() { if (historyList.isEmpty()) { return null; } if (currentIndex < historyList.size() - 1) { currentIndex++; return historyList.get(currentIndex); } return historyList.getLast(); } public String getNext() { if (historyList.isEmpty() || currentIndex <= 0) { currentIndex = -1; return null; } currentIndex--; return historyList.get(currentIndex); } } ================================================ FILE: app/src/main/java/io/xeres/app/service/shell/ShellService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.shell; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.LoggerContext; import io.xeres.app.service.InfoService; import io.xeres.app.service.LocationService; import io.xeres.app.service.script.ScriptService; import io.xeres.app.xrs.service.forum.ForumRsService; import io.xeres.app.xrs.service.gxs.GxsHelperService; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.mui.MUI; import io.xeres.common.mui.Shell; import io.xeres.common.mui.ShellResult; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.ByteUnitUtils; import io.xeres.common.util.OsUtils; import jakarta.annotation.PreDestroy; import org.apache.commons.lang3.StringUtils; import org.graalvm.polyglot.PolyglotException; import org.slf4j.LoggerFactory; import org.springframework.boot.DefaultApplicationArguments; import org.springframework.stereotype.Service; import java.io.File; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.file.Path; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; import static io.xeres.common.mui.ShellAction.*; @Service public class ShellService implements Shell { private final History history = new History(20); private final ScriptService scriptService; private final ForumRsService forumRsService; private final GxsHelperService gxsHelperService; private final LocationService locationService; private final InfoService infoService; public ShellService(ScriptService scriptService, ForumRsService forumRsService, GxsHelperService gxsHelperService, LocationService locationService, InfoService infoService) { this.scriptService = scriptService; this.forumRsService = forumRsService; this.gxsHelperService = gxsHelperService; this.locationService = locationService; this.infoService = infoService; } @Override public ShellResult sendCommand(String input) { var args = new DefaultApplicationArguments(translateCommandline(input)); var arg = args.getNonOptionArgs().isEmpty() ? null : args.getNonOptionArgs().getFirst(); if (StringUtils.isAsciiPrintable(arg)) { history.addCommand(input); try { return switch (arg.toLowerCase(Locale.ROOT)) { case "help", "?" -> new ShellResult(SUCCESS, """ Available commands: - help: displays this help - avail: shows the available memory - clear: clears the screen - cpu: shows the CPU count - exit: closes the shell - fix_forum_duplicates: fix forum duplicates - gc: runs the garbage collector - loglevel [package] [level]: sets the log level of a package - logs: shows the logs - open: opens a directory (app, cache, data or download) - properties: shows the properties - pwd: shows the current directory - reset_last_peer_message_update [location identifier] [group gxs identifier] [service id]: resets the last peer message update - reload: reloads user scripts - uname: shows the operating system - uptime: shows the app uptime"""); case "exit", "endshell", "endcli" -> new ShellResult(EXIT); case "clear", "cls" -> new ShellResult(CLS); case "avail", "free" -> getMemorySpecs(); case "cpu" -> getCpuCount(); case "pwd", "cd" -> getWorkingDirectory(); case "properties", "props" -> getProperties(); case "uname" -> getOperatingSystem(); case "uptime" -> getUptime(); case "gc" -> runGc(); case "loglevel" -> setLogLevel(getArgument(args, 1), getArgument(args, 2)); case "logs" -> showLogs(); case "open" -> openDirectory(getArgument(args, 1)); case "loadwb" -> new ShellResult(SUCCESS, "Not again!"); case "reload" -> reload(); case "fix_forum_duplicates" -> fixForumDuplicates(); case "reset_last_peer_message_update" -> resetLastPeerMessageUpdate(getArgument(args, 1), getArgument(args, 2), getArgument(args, 3)); default -> new ShellResult(UNKNOWN_COMMAND, arg); }; } catch (Exception e) { return new ShellResult(ERROR, e.getMessage()); } } return new ShellResult(NO_OP); } /** * Gets the argument. * * @param args the arguments * @param index the index of the argument, 0 for the command name, 1 for the first argument, etc... * @return the argument or null if it wasn't supplied */ private String getArgument(DefaultApplicationArguments args, int index) { return args.getNonOptionArgs().size() > index ? args.getNonOptionArgs().get(index) : null; } @Override public String getPreviousCommand() { return history.getPrevious(); } @Override public String getNextCommand() { return history.getNext(); } private ShellResult getProperties() { var properties = System.getProperties(); var map = properties.entrySet().stream() .collect(Collectors.toMap(k -> (String) k.getKey(), e -> (String) e.getValue())) .entrySet().stream() .sorted(Map.Entry.comparingByKey()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (oldValue, _) -> oldValue, LinkedHashMap::new)); var sb = new StringBuilder(); map.forEach((key, value) -> processProperty(sb, key, value)); return new ShellResult(SUCCESS, sb.toString()); } private static void processProperty(StringBuilder sb, String key, String value) { if (key.endsWith(".path")) { value = String.join("\n", value.split(File.pathSeparator)); } else { value = showLineSeparator(value); } sb.append(key).append(" = ").append(value).append("\n"); } private static String showLineSeparator(String in) { in = in.replace("\n", "\\n"); in = in.replace("\r", "\\r"); return in; } private static ShellResult getMemorySpecs() { var totalMemory = Runtime.getRuntime().totalMemory(); return new ShellResult(SUCCESS, "Memory allocated for the JVM: " + ByteUnitUtils.fromBytes(totalMemory) + "\n" + "Used memory: " + ByteUnitUtils.fromBytes(totalMemory - Runtime.getRuntime().freeMemory()) + "\n" + "Maximum allocatable memory: " + ByteUnitUtils.fromBytes(Runtime.getRuntime().maxMemory())); } private ShellResult getUptime() { var duration = infoService.getUptime(); var days = duration.toDays(); var hours = duration.toHours() % 24; var minutes = duration.toMinutes() % 60; var seconds = duration.getSeconds() % 60; return new ShellResult(SUCCESS, String.format("%d days, %d hours, %d minutes, %d seconds", days, hours, minutes, seconds)); } private static ShellResult getCpuCount() { return new ShellResult(SUCCESS, "CPU count: " + Runtime.getRuntime().availableProcessors()); } private static ShellResult getWorkingDirectory() { return new ShellResult(SUCCESS, System.getProperty("user.dir")); } private static ShellResult getOperatingSystem() { return new ShellResult(SUCCESS, System.getProperty("os.name") + " (" + System.getProperty("os.arch") + ")"); } private static ShellResult runGc() { System.gc(); return new ShellResult(SUCCESS, "Done"); } private static ShellResult setLogLevel(String packageName, String level) { if (StringUtils.isBlank(packageName)) { throw new IllegalArgumentException("package name must be provided (eg. io.xeres.app.application.Startup)"); } if (StringUtils.isBlank(level)) { throw new IllegalArgumentException("log level must be provided (trace, debug, info, warn, error)"); } var loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); var logger = loggerContext.exists(packageName); Objects.requireNonNull(logger, "no such logger"); logger.setLevel(Level.valueOf(level)); return new ShellResult(SUCCESS, "Level of " + logger.getName() + " changed to " + logger.getLevel()); } private static ShellResult showLogs() { OsUtils.shellOpen(OsUtils.getLogFile().toFile()); return new ShellResult(SUCCESS, "Showing logs in external viewer"); } private static ShellResult openDirectory(String name) { Path directory; directory = switch (name) { case "app" -> OsUtils.getApplicationHome(); case "cache" -> OsUtils.getCacheDir(); case "data" -> OsUtils.getDataDir(); case "download" -> OsUtils.getDownloadDir(); case null, default -> null; }; Objects.requireNonNull(directory, "Invalid directory name. Must be either 'app', 'cache, 'data' or 'download'"); OsUtils.showFolder(directory.toFile()); return new ShellResult(SUCCESS, "Opening " + name + " directory at " + directory + " ..."); } private ShellResult reload() { try { scriptService.reload(); } catch (PolyglotException e) { var sw = new StringWriter(); var pw = new PrintWriter(sw); e.printStackTrace(pw); return new ShellResult(ERROR, "Reload failed: " + sw); } return new ShellResult(SUCCESS, "Reloaded"); } private ShellResult fixForumDuplicates() { forumRsService.fixDuplicates(); return new ShellResult(SUCCESS, "Fixed forum duplicates"); } private ShellResult resetLastPeerMessageUpdate(String locationString, String gxsIdString, String serviceTypeString) { var location = Objects.requireNonNull(locationService.findLocationByLocationIdentifier(LocationIdentifier.fromString(locationString)).orElse(null), "Invalid location identifier"); var gxsId = GxsId.fromString(gxsIdString); if (gxsId.isNullIdentifier()) { throw new IllegalArgumentException("Invalid group identifier"); } var rsServiceType = RsServiceType.fromName(serviceTypeString); if (rsServiceType == RsServiceType.NONE) { throw new IllegalArgumentException("Invalid service type, must be one of " + Arrays.stream(RsServiceType.values()) .sorted() .map(Enum::name) .filter(s -> s.startsWith("GXS_")) .collect(Collectors.joining(", "))); } gxsHelperService.setLastPeerMessageUpdate(location, gxsId, Instant.EPOCH, rsServiceType); return new ShellResult(SUCCESS, "Successfully reset peer update time"); } /** * [code borrowed from ant.jar] * Crack a command line. * * @param toProcess the command line to process. * @return the command line broken into strings. * An empty or null toProcess parameter results in a zero sized array. */ static String[] translateCommandline(String toProcess) { enum State { NORMAL, IN_QUOTE, IN_DOUBLE_QUOTE } if (StringUtils.isEmpty(toProcess)) { //no command? no string return new String[0]; } // parse with a simple finite state machine var state = State.NORMAL; final var tok = new StringTokenizer(toProcess, "\"' ", true); final var current = new StringBuilder(); final ArrayList result = new ArrayList<>(); var lastTokenHasBeenQuoted = false; while (tok.hasMoreTokens()) { String nextTok = tok.nextToken(); switch (state) { case State.IN_QUOTE -> { if ("'".equals(nextTok)) { lastTokenHasBeenQuoted = true; state = State.NORMAL; } else { current.append(nextTok); } } case State.IN_DOUBLE_QUOTE -> { if ("\"".equals(nextTok)) { lastTokenHasBeenQuoted = true; state = State.NORMAL; } else { current.append(nextTok); } } default -> { switch (nextTok) { case "'" -> state = State.IN_QUOTE; case "\"" -> state = State.IN_DOUBLE_QUOTE; case " " -> { if (lastTokenHasBeenQuoted || !current.isEmpty()) { result.add(current.toString()); current.setLength(0); } } case null, default -> current.append(nextTok); } lastTokenHasBeenQuoted = false; } } } if (lastTokenHasBeenQuoted || !current.isEmpty()) { result.add(current.toString()); } if (state == State.IN_QUOTE || state == State.IN_DOUBLE_QUOTE) { throw new RuntimeException("unbalanced quotes in " + toProcess); } return result.toArray(new String[0]); } @PreDestroy private void cleanup() { MUI.closeShell(); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/DevUtils.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; public final class DevUtils { private DevUtils() { throw new UnsupportedOperationException("Utility class"); } public static String getDirFromDevelopmentSetup(String directory) { // Find out if we're running from rootProject, which means // we have an 'app' folder in there. // We use a relative directory because currentDir is not supposed // to change, and it looks clearer. var appDir = Path.of("app"); if (Files.exists(appDir)) { return Path.of(".", directory).toString(); } appDir = Path.of("..", "app"); if (Files.exists(appDir)) { return Path.of("..", directory).toString(); } throw new IllegalStateException("Unable to find/create directory. Current directory must be the project's root directory or 'app'. It is " + Paths.get("").toAbsolutePath()); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/GxsUtils.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util; import io.xeres.common.util.image.ImageUtils; import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; import java.io.ByteArrayOutputStream; import java.io.IOException; public final class GxsUtils { public static final long IMAGE_MAX_INPUT_SIZE = 1024 * 1024 * 10L; // 10 MB; public static final int MAXIMUM_GXS_MESSAGE_SIZE = 199_000; private GxsUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Gets a scaled image for GxS groups. * * @param imageFile the image file * @param sideSize the side size, usually 64 pixels * @return a scaled image array * @throws IOException if there's an I/O error */ public static byte[] getScaledGroupImage(MultipartFile imageFile, int sideSize) throws IOException { if (imageFile == null || imageFile.isEmpty()) { throw new IllegalArgumentException("Image is empty"); } if (imageFile.getSize() >= IMAGE_MAX_INPUT_SIZE) { throw new IllegalArgumentException("Image file size is bigger than " + IMAGE_MAX_INPUT_SIZE + " bytes"); } var image = ImageUtils.setImageSquareAndCrop(ImageIO.read(imageFile.getInputStream()), sideSize); var imageOut = new ByteArrayOutputStream(); if (ImageUtils.isPossiblyTransparent(imageFile.getContentType())) { if (!ImageUtils.writeImageAsPng(image, MAXIMUM_GXS_MESSAGE_SIZE - 2000, imageOut)) { throw new IllegalArgumentException("Couldn't write the image. Unsupported format (transparent)?"); } } else { if (!ImageUtils.writeImageAsJpeg(image, MAXIMUM_GXS_MESSAGE_SIZE - 2000, imageOut)) { throw new IllegalArgumentException("Couldn't write the image. Unsupported format (non-transparent)?"); } } return imageOut.toByteArray(); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/XmlUtils.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.stream.XMLInputFactory; public final class XmlUtils { private XmlUtils() { throw new UnsupportedOperationException("Utility class"); } public static DocumentBuilderFactory getSecureDocumentBuilderFactory() { var df = DocumentBuilderFactory.newInstance(); df.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); df.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); return df; } public static XMLInputFactory getSecureXMLInputFactory() { var xmlInputFactory = XMLInputFactory.newFactory(); xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); return xmlInputFactory; } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/CompoundExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import java.util.List; /** * Matches 2 expressions, ANDed, ORed or XORed together. */ public class CompoundExpression implements Expression { public enum Operator { AND, OR, XOR } private final Operator operator; private final Expression left; private final Expression right; public CompoundExpression(Operator operator, Expression left, Expression right) { this.operator = operator; this.left = left; this.right = right; } @Override public boolean evaluate(File file) { if (left == null || right == null) { return false; } return switch (operator) { case AND -> left.evaluate(file) && right.evaluate(file); case OR -> left.evaluate(file) || right.evaluate(file); case XOR -> left.evaluate(file) ^ right.evaluate(file); }; } @Override public Predicate toPredicate(CriteriaBuilder cb, Root root) { return switch (operator) { case AND -> cb.and(left.toPredicate(cb, root), right.toPredicate(cb, root)); case OR -> cb.or(left.toPredicate(cb, root), right.toPredicate(cb, root)); case XOR -> { var l = left.toPredicate(cb, root); var r = right.toPredicate(cb, root); yield cb.or(cb.and(l, cb.not(r)), cb.and(cb.not(l), r)); } }; } @Override public void linearize(List tokens, List ints, List strings) { tokens.add(ExpressionType.getTokenValueByClass(getClass())); ints.add(operator.ordinal()); left.linearize(tokens, ints, strings); right.linearize(tokens, ints, strings); } @Override public String toString() { return switch (operator) { case AND -> "(" + left + ") AND (" + right + ")"; case OR -> "(" + left + ") OR (" + right + ")"; case XOR -> "(" + left + ") XOR (" + right + ")"; }; } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/DateExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import java.time.Instant; import java.time.temporal.ChronoUnit; /** * Matches the last modified field of the file. This is what is reported by the filesystem * and not the metadata of the file, and is hence not very reliable. */ public class DateExpression extends RelationalExpression { public DateExpression(Operator operator, int lowerValue, int higherValue) { super(operator, lowerValue, higherValue); } @Override String getType() { return "DATE"; } @Override String getDatabaseColumnName() { return "modified"; } @Override public Predicate toPredicate(CriteriaBuilder cb, Root root) { // Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file return switch (operator) { case EQUALS -> cb.equal(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue)); case GREATER_THAN_OR_EQUALS -> cb.lessThanOrEqualTo(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue)); case GREATER_THAN -> cb.lessThan(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue)); case LESSER_THAN_OR_EQUALS -> cb.greaterThanOrEqualTo(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue)); case LESSER_THAN -> cb.greaterThan(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue)); case IN_RANGE -> cb.between(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue), Instant.ofEpochSecond(higherValue)); }; } @Override int getValue(File file) { return (int) file.getModified().truncatedTo(ChronoUnit.SECONDS).getEpochSecond(); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/Expression.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import java.util.List; public interface Expression { boolean evaluate(File file); void linearize(List tokens, List ints, List strings); Predicate toPredicate(CriteriaBuilder cb, Root root); } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/ExpressionMapper.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.xrs.service.turtle.item.TurtleRegExpSearchRequestItem; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.StringJoiner; public final class ExpressionMapper { private static final Logger log = LoggerFactory.getLogger(ExpressionMapper.class); private ExpressionMapper() { throw new UnsupportedOperationException("Utility class"); } private static class Context { private final List tokens; private final List ints; private final List strings; private int tokenIndex; private int integerIndex; private int stringIndex; public Context(List tokens, List ints, List strings) { this.tokens = tokens; this.ints = ints; this.strings = strings; } public boolean hasNextToken() { return tokenIndex < tokens.size(); } public ExpressionType nextToken() { return ExpressionType.values()[tokens.get(tokenIndex++)]; } public int nextIntegerValue() { return ints.get(integerIndex++); } public void skipIntegerValue() { integerIndex++; } public String nextStringValue() { return strings.get(stringIndex++); } } public static List toExpressions(TurtleRegExpSearchRequestItem item) { var context = new Context(item.getTokens(), item.getInts(), item.getStrings()); List expressions = new ArrayList<>(); try { while (context.hasNextToken()) { expressions.add(toExpression(context)); } } catch (IndexOutOfBoundsException | IllegalStateException e) { log.error("Expression error: {} for the following token input: tokens {}, ints {}, strings {}", e.getMessage(), Arrays.toString(item.getTokens().toArray()), Arrays.toString(item.getInts().toArray()), Arrays.toString(item.getStrings().toArray())); return List.of(); } return expressions; } public static TurtleRegExpSearchRequestItem toItem(List expressions) { List tokens = new ArrayList<>(); List ints = new ArrayList<>(); List strings = new ArrayList<>(); for (var expression : expressions) { expression.linearize(tokens, ints, strings); } return new TurtleRegExpSearchRequestItem(tokens, ints, strings); } private static Expression toExpression(Context context) { var token = context.nextToken(); return switch (token) { case DATE -> toDateExpression(context); case POPULARITY -> toPopularityExpression(context); case SIZE -> toSizeExpression(context); case SIZE_MB -> toSizeMbExpression(context); case NAME -> toNameExpression(context); case PATH -> toPathExpression(context); case EXTENSION -> toExtensionExpression(context); case HASH -> toHashExpression(context); case COMPOUND -> toCompoundExpression(context); }; } private static DateExpression toDateExpression(Context context) { var operator = RelationalExpression.Operator.values()[context.nextIntegerValue()]; var lowerValue = context.nextIntegerValue(); var higherValue = context.nextIntegerValue(); return new DateExpression(operator, lowerValue, higherValue); } private static PopularityExpression toPopularityExpression(Context context) { var operator = RelationalExpression.Operator.values()[context.nextIntegerValue()]; var lowerValue = context.nextIntegerValue(); var higherValue = context.nextIntegerValue(); return new PopularityExpression(operator, lowerValue, higherValue); } private static SizeExpression toSizeExpression(Context context) { var operator = RelationalExpression.Operator.values()[context.nextIntegerValue()]; var lowerValue = context.nextIntegerValue(); var higherValue = context.nextIntegerValue(); return new SizeExpression(operator, lowerValue, higherValue); } private static SizeMbExpression toSizeMbExpression(Context context) { var operator = RelationalExpression.Operator.values()[context.nextIntegerValue()]; var lowerValue = context.nextIntegerValue(); var higherValue = context.nextIntegerValue(); return new SizeMbExpression(operator, lowerValue, higherValue); } private static NameExpression toNameExpression(Context context) { var operator = StringExpression.Operator.values()[context.nextIntegerValue()]; var caseSensitive = context.nextIntegerValue() == 0; var stringsSize = context.nextIntegerValue(); var sb = new StringJoiner(" "); while (stringsSize-- > 0) { sb.add(context.nextStringValue()); } return new NameExpression(operator, sb.toString(), caseSensitive); } private static PathExpression toPathExpression(Context context) { var operator = StringExpression.Operator.values()[context.nextIntegerValue()]; var caseSensitive = context.nextIntegerValue() == 0; var stringsSize = context.nextIntegerValue(); var sb = new StringJoiner(" "); while (stringsSize-- > 0) { sb.add(context.nextStringValue()); } return new PathExpression(operator, sb.toString(), caseSensitive); } private static ExtensionExpression toExtensionExpression(Context context) { var operator = StringExpression.Operator.values()[context.nextIntegerValue()]; var caseSensitive = context.nextIntegerValue() == 0; var stringsSize = context.nextIntegerValue(); var sb = new StringJoiner(" "); while (stringsSize-- > 0) { sb.add(context.nextStringValue()); } return new ExtensionExpression(operator, sb.toString(), caseSensitive); } private static HashExpression toHashExpression(Context context) { var operator = StringExpression.Operator.values()[context.nextIntegerValue()]; context.skipIntegerValue(); // No case sensitivity needed var stringsSize = context.nextIntegerValue(); var sb = new StringJoiner(" "); while (stringsSize-- > 0) { sb.add(context.nextStringValue()); } return new HashExpression(operator, sb.toString()); } private static CompoundExpression toCompoundExpression(Context context) { var operator = CompoundExpression.Operator.values()[context.nextIntegerValue()]; var leftCompound = toExpression(context); var rightCompound = toExpression(context); return new CompoundExpression(operator, leftCompound, rightCompound); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/ExpressionType.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import java.util.Arrays; enum ExpressionType { // The order and value matters DATE(DateExpression.class), POPULARITY(PopularityExpression.class), SIZE(SizeExpression.class), HASH(HashExpression.class), NAME(NameExpression.class), PATH(PathExpression.class), EXTENSION(ExtensionExpression.class), COMPOUND(CompoundExpression.class), SIZE_MB(SizeMbExpression.class); private final Class javaClass; ExpressionType(Class javaClass) { this.javaClass = javaClass; } static byte getTokenValueByClass(Class javaClass) { return (byte) Arrays.stream(values()) .filter(expressionType -> expressionType.javaClass.equals(javaClass)) .findFirst().orElseThrow() .ordinal(); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/ExtensionExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; import io.xeres.common.util.FileNameUtils; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import java.util.ArrayList; import java.util.List; /** * Matches the extension of a file. This implementation deviates a little by matching over * the whole name but uses some tricks to make it acceptable in most common cases. */ public class ExtensionExpression extends StringExpression { public ExtensionExpression(@SuppressWarnings("unused") Operator operator, String template, boolean caseSensitive) { super(Operator.CONTAINS_ANY, template, caseSensitive); } @Override String getType() { return "EXTENSION"; } @Override String getDatabaseColumnName() { return "name"; } @Override public Predicate toPredicate(CriteriaBuilder cb, Root root) { return contains(cb, root); } private Predicate contains(CriteriaBuilder cb, Root root) { List predicates = new ArrayList<>(); words.forEach(s -> predicates.add(like(cb, root.get(getDatabaseColumnName()), "." + s))); var array = predicates.toArray(new Predicate[0]); return cb.or(array); } @Override String getValue(File file) { return FileNameUtils.getExtension(file.getName()).orElse(""); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/HashExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; /** * Matches the hash of the file but doesn't work yet. */ public class HashExpression extends StringExpression { public HashExpression(Operator operator, String template) { super(operator, template, true); } @Override boolean isEnabled() { return false; // Criteria API doesn't seem to support byte arrays so we just fail for now } @Override String getType() { return "HASH"; } @Override String getDatabaseColumnName() { return "hash"; } @Override String getValue(File file) { return file.getHash().toString(); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/NameExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; /** * Matches the name of the file. */ public class NameExpression extends StringExpression { public NameExpression(Operator operator, String template, boolean caseSensitive) { super(operator, template, caseSensitive); } @Override String getType() { return "NAME"; } @Override String getDatabaseColumnName() { return "name"; } @Override String getValue(File file) { return file.getName(); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/PathExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; /** * Matches the path component of a file. Always returns no match because it's difficult to * implement, and it's clumsy anyway (it depends on where the "root" of the share is). */ public class PathExpression extends StringExpression { public PathExpression(Operator operator, String template, boolean caseSensitive) { super(operator, template, caseSensitive); } @Override boolean isEnabled() { return false; } @Override String getType() { return "PATH"; } @Override String getDatabaseColumnName() { return ""; } @Override String getValue(File file) { return ""; } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/PopularityExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; /** * Matches the popularity of a file. Always returns no match because local files * don't have any metadata indicating the popularity. *

* RS does the same. */ public class PopularityExpression extends RelationalExpression { public PopularityExpression(Operator operator, Integer lowerValue, Integer higherValue) { super(operator, lowerValue, higherValue); } @Override boolean isEnabled() { return false; } @Override String getType() { return "POPULARITY"; } @Override String getDatabaseColumnName() { return ""; } @Override int getValue(File file) { return 0; // Popularity is not used } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/RelationalExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import java.util.List; abstract class RelationalExpression implements Expression { public enum Operator { EQUALS, // == GREATER_THAN_OR_EQUALS, // >= GREATER_THAN, // > LESSER_THAN_OR_EQUALS, // <= LESSER_THAN, // < IN_RANGE } boolean isEnabled() { return true; } abstract int getValue(File file); abstract String getType(); /** * Gets the column name from the 'FILE' table in the database. * * @return the column name in lowercase. Null if the relation must be ignored. */ abstract String getDatabaseColumnName(); protected final Operator operator; protected final int lowerValue; protected final int higherValue; protected RelationalExpression(Operator operator, int lowerValue, int higherValue) { this.operator = operator; this.lowerValue = lowerValue; this.higherValue = higherValue; } @Override public boolean evaluate(File file) { var value = getValue(file); // Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file return switch (operator) { case EQUALS -> lowerValue == value; case GREATER_THAN_OR_EQUALS -> lowerValue >= value; case GREATER_THAN -> lowerValue > value; case LESSER_THAN_OR_EQUALS -> lowerValue <= value; case LESSER_THAN -> lowerValue < value; case IN_RANGE -> (lowerValue <= value) && (value <= higherValue); }; } @Override public Predicate toPredicate(CriteriaBuilder cb, Root root) { if (!isEnabled()) { return cb.isFalse(cb.literal(true)); } // Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file return switch (operator) { case EQUALS -> cb.equal(root.get(getDatabaseColumnName()), lowerValue); case GREATER_THAN_OR_EQUALS -> cb.lessThanOrEqualTo(root.get(getDatabaseColumnName()), lowerValue); case GREATER_THAN -> cb.lessThan(root.get(getDatabaseColumnName()), lowerValue); case LESSER_THAN_OR_EQUALS -> cb.greaterThanOrEqualTo(root.get(getDatabaseColumnName()), lowerValue); case LESSER_THAN -> cb.greaterThan(root.get(getDatabaseColumnName()), lowerValue); case IN_RANGE -> cb.between(root.get(getDatabaseColumnName()), lowerValue, higherValue); }; } @Override public void linearize(List tokens, List ints, List strings) { tokens.add(ExpressionType.getTokenValueByClass(getClass())); ints.add(operator.ordinal()); ints.add(lowerValue); ints.add(higherValue); } @Override public String toString() { return switch (operator) { case EQUALS -> getType() + " = " + lowerValue; case GREATER_THAN_OR_EQUALS -> getType() + " <= " + lowerValue; case GREATER_THAN -> getType() + " < " + lowerValue; case LESSER_THAN_OR_EQUALS -> getType() + " >= " + lowerValue; case LESSER_THAN -> getType() + " > " + lowerValue; case IN_RANGE -> lowerValue + " <= " + getType() + " <= " + higherValue; }; } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/SizeExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; /** * Matches the size of the file. Is limited to a maximum file size of a signed 32-bit integer, which is * around 2 GB. Use {@link SizeMbExpression} for bigger files. */ public class SizeExpression extends RelationalExpression { public SizeExpression(Operator operator, int lowerValue, int higherValue) { super(operator, lowerValue, higherValue); } @Override String getType() { return "SIZE"; } @Override String getDatabaseColumnName() { return "size"; } @Override int getValue(File file) { return Math.clamp(file.getSize(), 0, Integer.MAX_VALUE); } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/SizeMbExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; /** * Matches the size of the file. Only works for files bigger than 2 GB. Since it also uses a 32-bit integer, the precision * is limited and there's some trickery to make it work but do not expect it to be very precise. *

* The maximum file size is 2.147 TB. */ public class SizeMbExpression extends RelationalExpression { public SizeMbExpression(Operator operator, int lowerValue, int higherValue) { super(operator, lowerValue, higherValue); } @Override String getType() { return "SIZE"; } @Override String getDatabaseColumnName() { return "size"; } @Override public Predicate toPredicate(CriteriaBuilder cb, Root root) { long lower; long higher; // We need to restore the value with the most pessimistic loss so that the comparison makes sense switch (operator) { case EQUALS -> { lower = getPessimisticValue(lowerValue); higher = getOptimisticValue(lowerValue); } case GREATER_THAN_OR_EQUALS, GREATER_THAN -> { lower = getOptimisticValue(lowerValue); higher = getOptimisticValue(lowerValue); } case LESSER_THAN_OR_EQUALS, LESSER_THAN -> { lower = getPessimisticValue(lowerValue); higher = getPessimisticValue(lowerValue); } case IN_RANGE -> { lower = getPessimisticValue(lowerValue); higher = getOptimisticValue(higherValue); } default -> throw new IllegalStateException("Unexpected operator: " + operator); } // Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file return switch (operator) { case EQUALS, IN_RANGE -> cb.between(root.get(getDatabaseColumnName()), lower, higher); case GREATER_THAN_OR_EQUALS -> cb.lessThanOrEqualTo(root.get(getDatabaseColumnName()), lower); case GREATER_THAN -> cb.lessThan(root.get(getDatabaseColumnName()), lower); case LESSER_THAN_OR_EQUALS -> cb.greaterThanOrEqualTo(root.get(getDatabaseColumnName()), lower); case LESSER_THAN -> cb.greaterThan(root.get(getDatabaseColumnName()), lower); }; } private static long getPessimisticValue(int value) { return (long) value << 20; } private static long getOptimisticValue(int value) { return (long) value << 20 | 0xfffff; } @Override int getValue(File file) { return (int) (file.getSize() >> 20); // the max value that this check can handle is (2 ^ 31 - 1) * 2 ^ 20, which is 2.147 TB } } ================================================ FILE: app/src/main/java/io/xeres/app/util/expression/StringExpression.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.File; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; public abstract class StringExpression implements Expression { public enum Operator { CONTAINS_ANY, CONTAINS_ALL, EQUALS } boolean isEnabled() { return true; } abstract String getValue(File file); abstract String getType(); /** * Gets the column name from the 'FILE' table in the database. * * @return the column name in lowercase. Null if the relation must be ignored. */ abstract String getDatabaseColumnName(); private final Operator operator; protected final List words; private final boolean caseSensitive; protected StringExpression(Operator operator, String template, boolean caseSensitive) { this.operator = operator; this.caseSensitive = caseSensitive; template = caseSensitive ? template : template.toLowerCase(Locale.ENGLISH); words = Arrays.stream(template.split(" ")).toList(); } @Override public boolean evaluate(File file) { var value = getValue(file); if (!caseSensitive) { value = value.toLowerCase(Locale.ENGLISH); } return switch (operator) { case EQUALS -> String.join(" ", words).equals(value); case CONTAINS_ALL -> words.stream().allMatch(value::contains); case CONTAINS_ANY -> words.stream().anyMatch(value::contains); }; } @Override public Predicate toPredicate(CriteriaBuilder cb, Root root) { if (!isEnabled()) { return cb.isFalse(cb.literal(true)); } return switch (operator) { case EQUALS -> equals(cb, root); case CONTAINS_ALL -> contains(cb, root, true); case CONTAINS_ANY -> contains(cb, root, false); }; } private Predicate equals(CriteriaBuilder cb, Root root) { if (caseSensitive) { return cb.equal(root.get(getDatabaseColumnName()), String.join(" ", words)); } else { return cb.equal(cb.lower(root.get(getDatabaseColumnName())), String.join(" ", words).toLowerCase(Locale.ROOT)); } } private Predicate contains(CriteriaBuilder cb, Root root, boolean all) { List predicates = new ArrayList<>(); words.forEach(s -> predicates.add(like(cb, root.get(getDatabaseColumnName()), s))); var array = predicates.toArray(new Predicate[0]); return all ? cb.and(array) : cb.or(array); } protected Predicate like(CriteriaBuilder cb, jakarta.persistence.criteria.Expression x, String pattern) { if (caseSensitive) { return cb.like(x, "%" + pattern + "%"); } else { return cb.like(cb.lower(x), "%" + pattern.toLowerCase(Locale.ROOT) + "%"); } } @Override public void linearize(List tokens, List ints, List strings) { tokens.add(ExpressionType.getTokenValueByClass(getClass())); ints.add(operator.ordinal()); ints.add(caseSensitive ? 0 : 1); ints.add(words.size()); strings.addAll(words); } @Override public String toString() { return switch (operator) { case CONTAINS_ALL -> getType() + " CONTAINS ALL " + String.join(" ", words); case CONTAINS_ANY -> { if (words.size() == 1) { yield getType() + " CONTAINS " + words.getFirst(); } else { yield getType() + " CONTAINS ONE OF " + String.join(" ", words); } } case EQUALS -> { if (words.size() == 1) { yield getType() + " IS " + words.getFirst(); } else { yield getType() + " IS ONE OF " + String.join(" ", words); } } }; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/common/CommentMessageItem.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.id.GxsId; import jakarta.persistence.Entity; import java.util.Set; @Entity(name = "comment_message") public class CommentMessageItem extends GxsMessageItem { public static final int SUBTYPE = 0xf1; private String comment; @Override public int getSubType() { return SUBTYPE; } public CommentMessageItem() { // Needed by JPA } public CommentMessageItem(GxsId gxsId, String name) { setGxsId(gxsId); setName(name); updatePublished(); } public String getComment() { return comment; } public void setComment(String comment) { this.comment = comment; } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { return Serializer.serialize(buf, TlvType.STR_GXS_MESSAGE_COMMENT, comment); } @Override public void readDataObject(ByteBuf buf) { comment = (String) Serializer.deserialize(buf, TlvType.STR_GXS_MESSAGE_COMMENT); } @Override public CommentMessageItem clone() { return (CommentMessageItem) super.clone(); } @Override public String toString() { return "CommentMessageItem{" + "comment='" + comment + '\'' + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/common/FileData.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; public record FileData( FileItem fileItem, long offset, byte[] data ) { @Override public String toString() { return "FileData{" + "fileItem=" + fileItem + ", offset=" + offset + ", data.length=" + data.length + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/common/FileItem.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; import io.xeres.common.id.Sha1Sum; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.Embedded; import jakarta.validation.constraints.NotNull; @Embeddable public record FileItem( long size, @Embedded @NotNull @AttributeOverride(name = "identifier", column = @Column(name = "hash")) Sha1Sum hash, String name, String path, int age) { @Override public String toString() { return "FileItem{" + "size=" + size + ", hash=" + hash + ", name='" + name + '\'' + ", path='" + path + '\'' + ", age=" + age + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/common/FileSet.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; import java.util.List; public record FileSet( List fileItems, String title, String comment ) { @Override public String toString() { return "FileSet{" + "fileItems=" + fileItems + ", title='" + title + '\'' + ", comment='" + comment + '\'' + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/common/SecurityKey.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; import io.xeres.common.id.GxsId; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.Embedded; import jakarta.validation.constraints.NotNull; import java.math.BigInteger; import java.time.Instant; import java.util.Arrays; import java.util.EnumSet; import java.util.Objects; import java.util.Set; @Embeddable public final class SecurityKey implements Comparable { @Embedded @NotNull @AttributeOverride(name = "identifier", column = @Column(name = "key_id")) private GxsId keyGxsId; private Set flags = EnumSet.noneOf(SecurityKey.Flags.class); @NotNull private Instant validFrom; private Instant validTo; // if null, there's no expiration private byte[] data; public SecurityKey() { } public SecurityKey(@NotNull GxsId keyGxsId, Set flags, @NotNull Instant validFrom, Instant validTo, byte[] data) { this.keyGxsId = keyGxsId; this.flags = flags; this.validFrom = validFrom; this.validTo = validTo; this.data = data; } public SecurityKey(@NotNull GxsId keyGxsId, Set flags, int validFrom, int validTo, byte[] data) { this.keyGxsId = keyGxsId; this.flags = flags; this.validFrom = Instant.ofEpochSecond(validFrom); this.validTo = validTo == 0 ? null : Instant.ofEpochSecond(validTo); this.data = data; } public @NotNull GxsId getKeyGxsId() { return keyGxsId; } public void setKeyGxsId(@NotNull GxsId keyId) { this.keyGxsId = keyId; } public Set getFlags() { return flags; } public void setFlags(Set flags) { this.flags = flags; } public @NotNull Instant getValidFrom() { return validFrom; } public void setValidFrom(@NotNull Instant validFrom) { this.validFrom = validFrom; } public int getValidFromInTs() { return (int) validFrom.getEpochSecond(); } public void setValidFrom(int validFrom) { this.validFrom = Instant.ofEpochSecond(validFrom); } public Instant getValidTo() { return validTo; } public void setValidTo(Instant validTo) { this.validTo = validTo; } public int getValidToInTs() { if (validTo == null) { return 0; // no expiration } return (int) validTo.getEpochSecond(); } public void setValidTo(int validTo) { if (validTo == 0) { this.validTo = null; } else { this.validTo = Instant.ofEpochSecond(validTo); } } public byte[] getData() { return data; } public void setData(byte[] data) { this.data = data; } @Override public boolean equals(Object obj) { if (obj == this) { return true; } if (obj == null || obj.getClass() != getClass()) { return false; } var that = (SecurityKey) obj; return Objects.equals(keyGxsId, that.keyGxsId) && Objects.deepEquals(data, that.data); } @Override public int hashCode() { return Objects.hash(keyGxsId, Arrays.hashCode(data)); } @Override public String toString() { return "SecurityKey[" + "gxsId=" + keyGxsId + ", " + "flags=" + flags + ", " + "validFrom=" + validFrom + ", " + "validTo=" + validTo; } @Override public int compareTo(SecurityKey other) { // This really is the sorting order for Retroshare... return new BigInteger(1, keyGxsId.getBytes()).compareTo(new BigInteger(1, other.getKeyGxsId().getBytes())); } public enum Flags { TYPE_PUBLIC_ONLY, // 0x1 TYPE_FULL, // 0x2 UNUSED_3, // 0x4 UNUSED_4, // 0x8 UNUSED_5, // 0x10 DISTRIBUTION_PUBLISHING, // 0x20 DISTRIBUTION_ADMIN, // 0x40 UNUSED_8; // 0x80 public static Set ofTypes() { return EnumSet.of(TYPE_PUBLIC_ONLY, TYPE_FULL); } public static Set ofDistributions() { return EnumSet.of(DISTRIBUTION_PUBLISHING, DISTRIBUTION_ADMIN); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/common/Signature.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; import io.xeres.common.id.GxsId; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.Embedded; import jakarta.validation.constraints.NotNull; import java.util.Arrays; import java.util.Objects; @Embeddable public final class Signature implements Comparable { private Type type; @Embedded @NotNull @AttributeOverride(name = "identifier", column = @Column(name = "gxs_id")) private GxsId gxsId; private byte[] data; public Signature() { } public Signature(Type type, @NotNull GxsId gxsId, byte[] data) { this.type = type; this.gxsId = gxsId; this.data = data; } public Signature(@NotNull GxsId gxsId, byte[] data) { this.gxsId = gxsId; this.data = data; } public Type getType() { return type; } public void setType(Type type) { this.type = type; } public @NotNull GxsId getGxsId() { return gxsId; } public void setGxsId(@NotNull GxsId gxsId) { this.gxsId = gxsId; } public byte[] getData() { return data; } public void setData(byte[] data) { this.data = data; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var signature = (Signature) o; return type == signature.type && Objects.equals(gxsId, signature.gxsId) && Objects.deepEquals(data, signature.data); } @Override public int hashCode() { return Objects.hash(type, gxsId, Arrays.hashCode(data)); } @Override public int compareTo(Signature o) { return type.getValue() - o.type.getValue(); } @Override public String toString() { return "Signature{" + "gxsId=" + gxsId + '}'; } public enum Type { AUTHOR(0x10), // RS calls it IDENTITY PUBLISH(0x20), ADMIN(0x40); Type(int value) { this.value = value; } private final int value; public int getValue() { return value; } public static Signature.Type findByValue(int value) { return Arrays.stream(values()).filter(type -> type.getValue() == value).findFirst().orElseThrow(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/common/VoteMessageItem.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.common.id.GxsId; import jakarta.persistence.Entity; import java.util.Set; @Entity(name = "vote_message") public class VoteMessageItem extends GxsMessageItem { public enum Type { /** * Unset vote? */ NONE, /** * Negative vote. */ DOWN, /** * Positive vote. */ UP } public static final int SUBTYPE = 0xf2; private Type type; public VoteMessageItem() { // Needed by JPA } public VoteMessageItem(GxsId gxsId, String name) { setGxsId(gxsId); setName(name); updatePublished(); } @Override public int getSubType() { return SUBTYPE; } public Type getType() { return type; } public void setType(Type type) { this.type = type; } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { return Serializer.serialize(buf, type); } @Override public void readDataObject(ByteBuf buf) { type = Serializer.deserializeEnum(buf, Type.class); } @Override public VoteMessageItem clone() { return (VoteMessageItem) super.clone(); } @Override public String toString() { return "VoteMessageItem{" + "type=" + type + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/item/Item.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.item; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; import io.netty.util.ReferenceCountUtil; import io.xeres.app.database.model.gxs.GxsMetaAndData; import io.xeres.app.xrs.serialization.GxsMetaAndDataResult; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.gxs.item.DynamicServiceType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Objects; import java.util.Set; import static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE; /** * An item is the base class for the transmission of data within the RS protocol. * They have a service type and a subtype within that service. */ public abstract class Item implements Cloneable { private static final Logger log = LoggerFactory.getLogger(Item.class); private static final int VERSION = 2; protected ByteBuf buf; private ByteBuf backupBuf; public abstract int getServiceType(); // returns an int so that it's easier to externalize plugins later on public abstract int getSubType(); protected Item() { // Needed for instantiation } public void setIncoming(ByteBuf buf) { this.buf = buf; } public void setOutgoing(ByteBufAllocator allocator, RsService service) { buf = allocator.buffer(); buf.writeByte(VERSION); // Handle items that are shared between service and hence have no intrinsic service type if (DynamicServiceType.class.isAssignableFrom(getClass())) { ((DynamicServiceType) this).setServiceType(Objects.requireNonNull(service, "Service cannot be null for a DynamicServiceType").getServiceType().getType()); } buf.writeShort(getServiceType()); buf.writeByte(getSubType()); buf.writeInt(HEADER_SIZE); } public void setSerialization(ByteBufAllocator allocator, RsService service) { backupBuf = buf; setOutgoing(allocator, service); } public RawItem serializeItem(Set flags) { var size = 0; if (GxsMetaAndData.class.isAssignableFrom(getClass())) { log.trace("Serializing class {} using GxsGroupItem system, flags: {}", getClass().getSimpleName(), flags); var result = new GxsMetaAndDataResult(); size += Serializer.serializeGxsMetaAndDataItem(buf, (GxsMetaAndData) this, flags, result); // RS sets this as the size for GxsMetaAndData setItemSize(result.getDataSize() + HEADER_SIZE); } else if (RsSerializable.class.isAssignableFrom(getClass())) { log.trace("Serializing class {} using writeObject(), flags: {}", getClass().getSimpleName(), flags); size += Serializer.serializeRsSerializable(buf, (RsSerializable) this, flags); setItemSize(size + HEADER_SIZE); } else { log.trace("Serializing class {} using annotations", getClass().getSimpleName()); size += Serializer.serializeAnnotatedFields(buf, this); setItemSize(size + HEADER_SIZE); } log.debug("==> {} ({})", getClass().getSimpleName(), size + HEADER_SIZE); var rawItem = new RawItem(buf, getPriority()); log.trace("Serialized buffer ==> {}", rawItem); if (flags.contains(SerializationFlags.SIGNATURE) || flags.contains(SerializationFlags.SIZE)) { buf = backupBuf; backupBuf = null; } return rawItem; } public int getPriority() // returns an int so that it's easier to externalize plugins later on { return ItemPriority.DEFAULT.getPriority(); } public void dispose() { if (buf != null) { assert buf.refCnt() == 1 : "buffer refCount is " + buf.refCnt(); ReferenceCountUtil.release(buf); buf = null; } } /** * Get the item's serialized size. This is always set for incoming items from their deserialization until they're disposed. For outgoing items, the value is undefined until the * item has been serialized. * * @return the size of the item in its serialized form */ public int getItemSize() { return buf.getInt(4); } protected void setItemSize(int size) { buf.setInt(4, size); } /** * To clone an item's subclass. Override the clone() method so that it returns the right type (so that calling clone() * on the subclass, will not return the superclass' type, which is {@link #Item}). There's no need to implement the {@link Cloneable} method in the subclass and * there's no need to deep copy any field either as the only use of clone() in an item's subclass is for sending it to multiple recipient * and nothing will modify any data (mutable or not) data. * * @return an Item's clone */ @Override public Item clone() { try { var clone = (Item) super.clone(); clone.buf = null; return clone; } catch (CloneNotSupportedException _) { throw new AssertionError(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/item/ItemHeader.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.serialization.Serializer; public class ItemHeader { private final ByteBuf buf; private final int serviceType; private final int subType; private int size; private int sizeOffset; public ItemHeader(ByteBuf buf, int serviceType, int subType) { this.buf = buf; this.serviceType = serviceType; this.subType = subType; } public int writeHeader() { size = Serializer.serialize(buf, (byte) 2); size += Serializer.serialize(buf, (short) serviceType); size += Serializer.serialize(buf, (byte) subType); sizeOffset = buf.writerIndex(); size += Serializer.serialize(buf, 0); // the size is written at the end when calling writeSize() return size; } public int writeSize(int dataSize) { size += dataSize; buf.setInt(sizeOffset, size); return size; } public static void readHeader(ByteBuf buf, int serviceType, int subType) { if (buf.readByte() != 2) { throw new IllegalArgumentException("Packet version is not 0x2"); } if (buf.readUnsignedShort() != serviceType) { throw new IllegalArgumentException("Packet type is not " + serviceType); } if (buf.readUnsignedByte() != subType) { throw new IllegalArgumentException("Packet subtype is not " + subType); } buf.readInt(); // size } public static int getSubType(ByteBuf buf) { return buf.getUnsignedByte(3); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/item/ItemPriority.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.item; public enum ItemPriority { /** * Anything that happens in the background and is not really urgent (for example discovery exchanges, file transfers, ...) */ BACKGROUND(2), /** * The default priority. */ DEFAULT(3), NORMAL(5), /** * High priority. Has consequences for other services and should be serviced quickly (for example GxS exchanges). */ HIGH(6), /** * Generated by a user and requires immediate feedback (for example chat, typing feedback, ...) */ INTERACTIVE(7), /** * Must be acknowledged by the other peer quickly, or it will have disruptive effects (for example, heartbeats). */ IMPORTANT(8), /** * Must be carried away immediately, or it won't be usable (for example RTT measurements). */ REALTIME(9); private final int priority; ItemPriority(int priority) { this.priority = priority; } public int getPriority() { return priority; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/item/ItemUtils.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.item; import io.netty.buffer.Unpooled; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceRegistry; import java.util.EnumSet; public final class ItemUtils { private ItemUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Serializes an item to get its serialized size. *

* Note: prefer {@link Item#getItemSize()} which is set for incoming items. For outgoing items, if you don't * need the size before writing the item, prefer {@link PeerConnectionManager#writeItem(PeerConnection, Item, RsService)}. * * @param item the item * @param service the service * @return the total serialized size in bytes */ public static int getItemSerializedSize(Item item, RsService service) { item.setSerialization(Unpooled.buffer().alloc(), service); var rawItem = item.serializeItem(EnumSet.of(SerializationFlags.SIZE)); var size = rawItem.getSize(); rawItem.getBuffer().release(); return size; } /** * Serializes an item to make a signature out of it. * * @param item the item * @param service the service * @return a byte array */ public static byte[] serializeItemForSignature(Item item, RsService service) { item.setSerialization(Unpooled.buffer().alloc(), service); var buf = item.serializeItem(EnumSet.of(SerializationFlags.SIGNATURE)).getBuffer(); var data = new byte[buf.writerIndex()]; buf.getBytes(0, data); buf.release(); return data; } /** * Serializes an item. Do not use this within a netty pipeline. * * @param item the item * @param service the service * @return a byte array */ public static byte[] serializeItem(Item item, RsService service) { item.setSerialization(Unpooled.buffer().alloc(), service); var buf = item.serializeItem(EnumSet.noneOf(SerializationFlags.class)).getBuffer(); var data = new byte[buf.writerIndex()]; buf.getBytes(0, data); buf.release(); return data; } /** * Deserializes an item. Do not use this within a netty pipeline. * * @param data the byte array of the item * @param registry the registry to build the item * @return the item, not null */ public static Item deserializeItem(byte[] data, RsServiceRegistry registry) { var rawItem = new RawItem(Unpooled.wrappedBuffer(data), ItemPriority.DEFAULT.getPriority()); var item = registry.buildIncomingItem(rawItem); rawItem.deserialize(item); rawItem.dispose(); return item; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/item/RawItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.item; import io.netty.buffer.ByteBuf; import io.netty.util.ReferenceCountUtil; import io.xeres.app.database.model.gxs.GxsMetaAndData; import io.xeres.app.net.peer.packet.Packet; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.app.xrs.service.DefaultItem; import org.bouncycastle.util.encoders.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE; public class RawItem { private static final Logger log = LoggerFactory.getLogger(RawItem.class); private int priority = ItemPriority.DEFAULT.getPriority(); protected ByteBuf buf; public RawItem() { } public RawItem(Packet packet) { priority = packet.getPriority(); buf = packet.getItemBuffer(); } public RawItem(ByteBuf buf, int priority) { this.buf = buf; this.priority = priority; } public void deserialize(Item item) { item.setIncoming(buf); buf.skipBytes(HEADER_SIZE); if (item instanceof DefaultItem) { buf.skipBytes(getItemSize()); } else if (GxsMetaAndData.class.isAssignableFrom(item.getClass())) { // This cannot be deserialized because the data is before the metadata, and the data can vary in length (optional fields at the end). It would only be possible if the data was last. throw new IllegalArgumentException("Cannot deserialize a GxsMetaAndData item"); } else if (RsSerializable.class.isAssignableFrom(item.getClass())) { // If the object implements RsSerializable, which is more flexible, use it log.trace("Deserializing class {} using readObject()", item.getClass().getSimpleName()); Serializer.deserializeRsSerializable(buf, (RsSerializable) item); } else { // Otherwise, use the more convenient @RsSerialized notations (recommended) log.trace("Deserializing class {} using annotations", item.getClass().getSimpleName()); Serializer.deserializeAnnotatedFields(buf, item); } // Check if the size matches if (buf.readerIndex() != getItemSize()) { throw new IllegalArgumentException("Size mismatch, size in header: " + getItemSize() + ", actual read size: " + buf.readerIndex() + ", (Version: " + getPacketVersion() + ", Service: " + getPacketService() + ", SubType: " + getPacketSubType() + ")"); } } public int getPacketVersion() { return buf.getUnsignedByte(0); } public int getPacketService() { return buf.getUnsignedShort(1); } public int getPacketSubType() { return buf.getUnsignedByte(3); } private int getItemSize() { return buf.getInt(4); } public int getSize() { return getItemSize() + HEADER_SIZE; } public ByteBuf getBuffer() { return buf; } public int getPriority() { return priority; } public void dispose() { ReferenceCountUtil.release(buf); } @Override public String toString() { String bufOut = null; var size = 0; if (buf != null) { buf.markReaderIndex(); buf.readerIndex(0); var out = new byte[buf.writerIndex()]; size = buf.writerIndex(); buf.readBytes(out); buf.resetReaderIndex(); bufOut = new String(Hex.encode(out)); } return "RawItem{" + "priority=" + priority + ", buf=" + bufOut + ", size=" + size + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/AnnotationSerializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.item.Item; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; final class AnnotationSerializer { private static final Logger log = LoggerFactory.getLogger(AnnotationSerializer.class); private AnnotationSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Object object) { var size = 0; for (var field : getAllFields(object.getClass(), isClassOrderReversed(object))) { log.trace("Serializing field {}, of type {}", field.getName(), field.getType().getSimpleName()); size += Serializer.serialize(buf, field, object); } return size; } static Object deserializeForClass(ByteBuf buf, Class javaClass) { Object instanceObject; try { instanceObject = javaClass.getDeclaredConstructor().newInstance(); } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException _) { throw new IllegalArgumentException("Cannot instantiate object of class " + javaClass.getSimpleName()); } if (!deserialize(buf, instanceObject)) { throw new IllegalArgumentException("Cannot deserialize object of class " + javaClass.getSimpleName()); } return instanceObject; } static boolean deserialize(ByteBuf buf, Object object) { var allFields = getAllFields(object.getClass(), isClassOrderReversed(object)); for (var field : allFields) { log.trace("Deserializing field {}, of type {}", field.getName(), field.getType().getSimpleName()); Serializer.deserialize(buf, field, object, field.getAnnotation(RsSerialized.class)); } return !allFields.isEmpty(); } /** * Search all fields annotated with @RsSerialized, starting with the * first subclass of Item down to the last subclass.
* * @param javaClass the class * @return all fields ordered from superclass to subclass */ private static List getAllFields(Class javaClass, boolean reversed) { if (javaClass == null || javaClass == Item.class) { return Collections.emptyList(); } List superFields = new ArrayList<>(getAllFields(javaClass.getSuperclass(), reversed)); var classFields = Arrays.stream(javaClass.getDeclaredFields()) .filter(field -> { field.setAccessible(true); // NOSONAR return field.isAnnotationPresent(RsSerialized.class); }) .collect(Collectors.toCollection(ArrayList::new)); if (reversed) { classFields.addAll(superFields); return classFields; } superFields.addAll(classFields); return superFields; } private static boolean isClassOrderReversed(Object object) { return object.getClass().getDeclaredAnnotation(RsClassSerializedReversed.class) != null; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/ArraySerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; final class ArraySerializer { private ArraySerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Class javaClass, Object object) { if (javaClass.equals(byte[].class)) { return ByteArraySerializer.serialize(buf, (byte[]) object); } else { throw new IllegalArgumentException("Unhandled array type " + javaClass.getSimpleName()); // XXX: handle other types (see what RS uses...) } } static Object deserialize(ByteBuf buf, Class javaClass) { if (javaClass.equals(byte[].class)) { return ByteArraySerializer.deserialize(buf); } else { throw new IllegalArgumentException("Unhandled array type " + javaClass.getSimpleName()); // XXX } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/BigIntegerSerializer.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.bouncycastle.util.BigIntegers; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigInteger; final class BigIntegerSerializer { private static final Logger log = LoggerFactory.getLogger(BigIntegerSerializer.class); private BigIntegerSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, BigInteger value) { log.trace("Writing big integer: {}", value); var data = BigIntegers.asUnsignedByteArray(value); buf.ensureWritable(Integer.BYTES + data.length); buf.writeInt(data.length); buf.writeBytes(data); return Integer.BYTES + data.length; } static BigInteger deserialize(ByteBuf buf) { var len = buf.readInt(); log.trace("Reading big integer of size: {}", len); var out = new byte[len]; buf.readBytes(out); return BigIntegers.fromUnsignedByteArray(out); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/BooleanSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class BooleanSerializer { private static final Logger log = LoggerFactory.getLogger(BooleanSerializer.class); private BooleanSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, boolean value) { log.trace("Writing boolean: {}", value); buf.ensureWritable(1); buf.writeBoolean(value); return 1; } static boolean deserialize(ByteBuf buf) { var val = buf.readBoolean(); log.trace("Reading boolean: {}", val); return val; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/ByteArraySerializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class ByteArraySerializer { private static final Logger log = LoggerFactory.getLogger(ByteArraySerializer.class); private ByteArraySerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, byte[] a) { if (a == null) { buf.ensureWritable(Integer.BYTES); buf.writeInt(0); return Integer.BYTES; } log.trace("Writing byte array of size {}", a.length); buf.ensureWritable(Integer.BYTES + a.length); buf.writeInt(a.length); buf.writeBytes(a); return Integer.BYTES + a.length; } static byte[] deserialize(ByteBuf buf) { var len = buf.readInt(); log.trace("Reading byte array of size {}", len); var out = new byte[len]; buf.readBytes(out); return out; } static int serialize(ByteBuf buf, byte[] array, int size) { log.trace("Writing byte array of specific size {}", size); buf.ensureWritable(size); buf.writeBytes(array, 0, size); return size; } static byte[] deserialize(ByteBuf buf, int size) { log.trace("Reading byte array of specific size {}", size); var out = new byte[size]; buf.readBytes(out); return out; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/ByteSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class ByteSerializer { private static final Logger log = LoggerFactory.getLogger(ByteSerializer.class); private ByteSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, byte value) { log.trace("Writing byte: {}", value); buf.ensureWritable(Byte.BYTES); buf.writeByte(value); return Byte.BYTES; } static byte deserialize(ByteBuf buf) { var val = buf.readByte(); log.trace("Reading byte: {}", val); return val; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/DoubleSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class DoubleSerializer { private static final Logger log = LoggerFactory.getLogger(DoubleSerializer.class); private DoubleSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, double value) { log.trace("Writing double: {}", value); buf.ensureWritable(Double.BYTES); buf.writeDouble(value); return Double.BYTES; } static double deserialize(ByteBuf buf) { var val = buf.readDouble(); log.debug("Reading double: {}", val); return val; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/EnumSerializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Objects; final class EnumSerializer { private static final Logger log = LoggerFactory.getLogger(EnumSerializer.class); private EnumSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, Enum> e) { Objects.requireNonNull(e, "Null enum not supported"); log.trace("Writing enum ordinal value: {}", e.ordinal()); buf.ensureWritable(Integer.BYTES); buf.writeInt(e.ordinal()); return Integer.BYTES; } static int getSize() { return Integer.BYTES; } @SuppressWarnings("unchecked") static > E deserialize(ByteBuf buf, Class e) { var val = buf.readInt(); log.trace("Reading enum ordinal value: {}, class: {}", val, e.getSimpleName()); return (E) e.getEnumConstants()[val]; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/EnumSetSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.ParameterizedType; import java.util.EnumSet; import java.util.Objects; import java.util.Set; final class EnumSetSerializer { private static final Logger log = LoggerFactory.getLogger(EnumSetSerializer.class); private EnumSetSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Set> enumSet, RsSerialized annotation) { Objects.requireNonNull(annotation, "Annotation is needed for EnumSet"); var fieldSize = annotation.fieldSize(); return serialize(buf, enumSet, fieldSize); } static int serialize(ByteBuf buf, Set> enumSet, FieldSize fieldSize) { Objects.requireNonNull(enumSet, "Null enumset not supported"); return switch (fieldSize) { case INTEGER -> serializeEnumSetInt(buf, enumSet); case BYTE -> serializeEnumSetByte(buf, enumSet); case SHORT -> serializeEnumSetShort(buf, enumSet); }; } private static int serializeEnumSetInt(ByteBuf buf, Set> enumSet) { if (enumSet.size() > Integer.SIZE) { throw new IllegalArgumentException("EnumSet cannot have more than " + Integer.SIZE + " entries"); } var size = Integer.BYTES; log.trace("Enumset (int): {}", enumSet); buf.ensureWritable(size); var value = 0; for (Enum anEnum : enumSet) { value |= 1 << anEnum.ordinal(); } buf.writeInt(value); return size; } private static int serializeEnumSetByte(ByteBuf buf, Set> enumSet) { if (enumSet.size() > Byte.SIZE) { throw new IllegalArgumentException("EnumSet for a byte cannot have more than " + Byte.SIZE + " entries"); } var size = Byte.BYTES; log.trace("Enumset (byte): {}", enumSet); buf.ensureWritable(size); byte value = 0; for (Enum anEnum : enumSet) { value |= (byte) (1 << anEnum.ordinal()); } buf.writeByte(value); return size; } private static int serializeEnumSetShort(ByteBuf buf, Set> enumSet) { if (enumSet.size() > Short.SIZE) { throw new IllegalArgumentException("EnumSet for a short cannot have more than " + Short.SIZE + " entries"); } var size = Short.BYTES; log.trace("Enumset (short): {}", enumSet); buf.ensureWritable(size); short value = 0; for (Enum anEnum : enumSet) { value |= (short) (1 << anEnum.ordinal()); } buf.writeShort(value); return size; } static > Set deserialize(ByteBuf buf, ParameterizedType type, RsSerialized annotation) { Objects.requireNonNull(annotation, "Annotation is needed for EnumSet"); @SuppressWarnings("unchecked") var enumClass = (Class) type.getActualTypeArguments()[0]; var fieldSize = annotation.fieldSize(); return deserialize(buf, enumClass, fieldSize); } static > Set deserialize(ByteBuf buf, Class e, FieldSize fieldSize) { return switch (fieldSize) { case INTEGER -> deserializeEnumSetInt(buf, e); case BYTE -> deserializeEnumSetByte(buf, e); case SHORT -> deserializeEnumSetShort(buf, e); }; } private static > Set deserializeEnumSetInt(ByteBuf buf, Class e) { var value = buf.readInt(); log.trace("Reading enumSet (int): {}", value); var enumSet = EnumSet.noneOf(e); for (var enumConstant : e.getEnumConstants()) { if ((value & (1 << enumConstant.ordinal())) != 0) { enumSet.add(enumConstant); } } return enumSet; } private static > Set deserializeEnumSetByte(ByteBuf buf, Class e) { var value = buf.readByte(); log.trace("Reading enumSet (byte): {}", value); var enumSet = EnumSet.noneOf(e); for (var enumConstant : e.getEnumConstants()) { if ((value & 0xff & (1 << enumConstant.ordinal())) != 0) { enumSet.add(enumConstant); } } return enumSet; } private static > Set deserializeEnumSetShort(ByteBuf buf, Class e) { var value = buf.readShort(); log.trace("Reading enumSet (long): {}", value); var enumSet = EnumSet.noneOf(e); for (var enumConstant : e.getEnumConstants()) { if ((value & (1 << enumConstant.ordinal())) != 0) { enumSet.add(enumConstant); } } return enumSet; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/FieldSize.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; public enum FieldSize { BYTE, SHORT, INTEGER } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/FloatSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class FloatSerializer { private static final Logger log = LoggerFactory.getLogger(FloatSerializer.class); private FloatSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, float value) { log.trace("Writing float: {}", value); buf.ensureWritable(Float.BYTES); buf.writeFloat(value); return Float.BYTES; } static float deserialize(ByteBuf buf) { var val = buf.readFloat(); log.trace("Reading float: {}", val); return val; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/GxsMetaAndDataResult.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; public final class GxsMetaAndDataResult { private int dataSize; private int metaSize; public int getDataSize() { return dataSize; } public void setDataSize(int dataSize) { this.dataSize = dataSize; } public int getMetaSize() { return metaSize; } public void setMetaSize(int metaSize) { this.metaSize = metaSize; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/GxsMetaAndDataSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsMetaAndData; import java.util.Set; final class GxsMetaAndDataSerializer { private GxsMetaAndDataSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, GxsMetaAndData gxsMetaAndData, Set flags, GxsMetaAndDataResult result) { var dataSize = gxsMetaAndData.writeDataObject(buf, flags); var metaSize = gxsMetaAndData.writeMetaObject(buf, flags); if (result != null) { result.setDataSize(dataSize); result.setMetaSize(metaSize); } return dataSize + metaSize; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/IdentifierSerializer.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.common.id.Identifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.InvocationTargetException; import java.util.Arrays; final class IdentifierSerializer { private static final Logger log = LoggerFactory.getLogger(IdentifierSerializer.class); private IdentifierSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Class identifierClass, Identifier identifier) { log.trace("Writing identifier: {}", identifier); if (identifier == null) { var nullIdentifierArray = getNullIdentifierArray(identifierClass); buf.ensureWritable(nullIdentifierArray.length); buf.writeBytes(nullIdentifierArray); return nullIdentifierArray.length; } else { buf.ensureWritable(identifier.getLength()); buf.writeBytes(identifier.getBytes()); return identifier.getLength(); } } static Identifier deserialize(ByteBuf buf, Class identifierClass) { try { //noinspection PrimitiveArrayArgumentToVarargsMethod var identifier = (Identifier) identifierClass.getDeclaredConstructor(byte[].class).newInstance(ByteArraySerializer.deserialize(buf, getIdentifierLength(identifierClass))); if (Arrays.equals(identifier.getNullIdentifier(), identifier.getBytes())) { return null; } return identifier; } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { throw new IllegalStateException(e.getMessage()); } } static Identifier deserializeWithSize(ByteBuf buf, Class identifierClass, int size) { try { //noinspection PrimitiveArrayArgumentToVarargsMethod var identifier = (Identifier) identifierClass.getDeclaredConstructor(byte[].class).newInstance(ByteArraySerializer.deserialize(buf, size)); if (Arrays.equals(identifier.getNullIdentifier(), identifier.getBytes())) { return null; } return identifier; } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { throw new IllegalStateException(e.getMessage()); } } static int getIdentifierLength(Class identifierClass) { try { var field = identifierClass.getDeclaredField(Identifier.LENGTH_FIELD_NAME); return (int) field.get(null); } catch (NoSuchFieldException | IllegalAccessException _) { throw new IllegalStateException("Missing LENGTH static field in " + identifierClass.getSimpleName()); } } private static byte[] getNullIdentifierArray(Class identifierClass) { // Try finding a static field called "NULL_IDENTIFIER"; try { var field = identifierClass.getDeclaredField(Identifier.NULL_FIELD_NAME); return (byte[]) field.get(null); } catch (NoSuchFieldException | IllegalAccessException _) { // No? Create an identifier instance then a null identifier. This requires // more resources but is the only way for identifiers that have a dynamic length. log.warn("Using slow path to create a null identifier for {}, consider adding a static field called {} with a null instance in it", identifierClass.getSimpleName(), Identifier.NULL_FIELD_NAME); try { var identifier = (Identifier) identifierClass.getDeclaredConstructor().newInstance(); return identifier.getNullIdentifier(); } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { throw new IllegalStateException(e.getMessage()); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/IntSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class IntSerializer { private static final Logger log = LoggerFactory.getLogger(IntSerializer.class); private IntSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, int value) { log.trace("Writing int: {}", value); buf.ensureWritable(Integer.BYTES); buf.writeInt(value); return Integer.BYTES; } static int deserialize(ByteBuf buf) { var val = buf.readInt(); log.trace("Reading int: {}", val); return val; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/ListSerializer.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.ParameterizedType; import java.util.ArrayList; import java.util.List; final class ListSerializer { private static final Logger log = LoggerFactory.getLogger(ListSerializer.class); private ListSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, List list) { var size = Integer.BYTES; buf.ensureWritable(size); if (list != null) { log.trace("Entries in List: {}", list.size()); buf.writeInt(list.size()); for (var data : list) { size += Serializer.serialize(buf, data.getClass(), data, null); } } else { buf.writeInt(0); } return size; } static int serialize(ByteBuf buf, List list, TlvType tlvType) { var size = Integer.BYTES; buf.ensureWritable(size); if (list != null) { log.trace("Entries in List: {} with TlvType {}", list.size(), tlvType); buf.writeInt(list.size()); for (var data : list) { size += TlvSerializer.serialize(buf, tlvType, data); } } else { buf.writeInt(0); } return size; } static List deserialize(ByteBuf buf, List list, ParameterizedType type) { if (list == null) { list = new ArrayList<>(); } var entries = buf.readInt(); var dataClass = (Class) type.getActualTypeArguments()[0]; log.trace("Data class: {}", dataClass.getSimpleName()); while (entries-- > 0) { var dataObject = Serializer.deserialize(buf, dataClass); log.trace("result: {}", dataObject); list.add(dataObject); } return list; } static List deserialize(ByteBuf buf, List list, TlvType tlvType) { if (list == null) { list = new ArrayList<>(); } var entries = buf.readInt(); while (entries-- > 0) { var dataObject = TlvSerializer.deserialize(buf, tlvType); log.trace("result: {} (tlvType: {})", dataObject, tlvType); list.add(dataObject); } return list; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/LongSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class LongSerializer { private static final Logger log = LoggerFactory.getLogger(LongSerializer.class); private LongSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, long value) { log.trace("Writing long: {}", value); buf.ensureWritable(Long.BYTES); buf.writeLong(value); return Long.BYTES; } static long deserialize(ByteBuf buf) { var val = buf.readLong(); log.trace("Reading long: {}", val); return val; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/MapSerializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.ParameterizedType; import java.util.HashMap; import java.util.Map; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class MapSerializer { private static final Logger log = LoggerFactory.getLogger(MapSerializer.class); private MapSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Map map) { var size = 0; if (map != null && !map.isEmpty()) { log.trace("Entries in Map: {}", map.size()); var mapSize = 0; var mapSizeOffset = writeTlv(buf); for (var entry : map.entrySet()) { var entrySizeOffset = writeTlv(buf); var entrySize = 0; log.trace("Writing Key class: {}", entry.getKey().getClass().getSimpleName()); entrySize += writeMapData(buf, entry.getKey()); log.trace("Writing Value class: {}", entry.getValue().getClass().getSimpleName()); entrySize += writeMapData(buf, entry.getValue()); mapSize += writeTlvBack(buf, entrySizeOffset, entrySize); log.trace("Writing total entry size of {}", entrySize); } log.trace("Writing total map size of {}", mapSize); size += writeTlvBack(buf, mapSizeOffset, mapSize); } else { size += writeTlvBack(buf, writeTlv(buf), 0); } return size; } static Map deserialize(ByteBuf buf, Map map, ParameterizedType type) { if (map == null) { map = new HashMap<>(); } var mapSize = readTlv(buf); log.trace("Map size: {}, readerIndex: {}", mapSize, buf.readerIndex()); var mapIndex = buf.readerIndex(); while (buf.readerIndex() < mapIndex + mapSize - TLV_HEADER_SIZE) { log.trace("buf.readerIndex: {}, mapIndex + mapSize: {}", buf.readerIndex(), mapIndex + mapSize); readTlv(buf); var keyClass = (Class) type.getActualTypeArguments()[0]; log.trace("Key class: {}", keyClass.getSimpleName()); var keyObject = readMapData(buf, keyClass); var dataClass = (Class) type.getActualTypeArguments()[1]; log.trace("Data class: {}", dataClass.getSimpleName()); var dataObject = readMapData(buf, dataClass); log.trace("result: {}", dataObject); map.put(keyObject, dataObject); } log.trace("done: buf.readerIndex: {}", buf.readerIndex()); return map; } private static int writeMapData(ByteBuf buf, Object object) { int size; var sizeOffset = writeTlv(buf); size = Serializer.serialize(buf, object.getClass(), object, null); return writeTlvBack(buf, sizeOffset, size); } // XXX: we don't really need to check for the sizes everywhere. first deserialize can check the total size, then the rest just locally. just throw something if deserializing is wrong private static int writeTlvBack(ByteBuf buf, int offset, int size) { size += TLV_HEADER_SIZE; buf.setInt(offset, size); return size; } private static int writeTlv(ByteBuf buf) { buf.ensureWritable(TLV_HEADER_SIZE); buf.writeShort(1); var offset = buf.writerIndex(); buf.writerIndex(offset + 4); return offset; } private static Object readMapData(ByteBuf buf, Class javaClass) { var size = readTlv(buf); // XXX: check size log.trace("Reading map data of size: {}", size); return Serializer.deserialize(buf, javaClass); } private static int readTlv(ByteBuf buf) { if (buf.readShort() != 1) { throw new IllegalArgumentException("Wrong TLV"); } return buf.readInt(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/RsClassSerializedReversed.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import java.lang.annotation.Retention; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; /** * Marks an item class as requiring reverse serialization, that is, * the deepest subclass first, up to the item's first subclass. */ @Retention(RUNTIME) @Target(TYPE) public @interface RsClassSerializedReversed { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/RsSerializable.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import java.util.Set; public interface RsSerializable { int writeObject(ByteBuf buf, Set serializationFlags); void readObject(ByteBuf buf); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/RsSerializableSerializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import java.lang.reflect.InvocationTargetException; import java.util.EnumSet; import java.util.Set; final class RsSerializableSerializer { private RsSerializableSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, RsSerializable rsSerializable) { return rsSerializable.writeObject(buf, EnumSet.noneOf(SerializationFlags.class)); } static int serialize(ByteBuf buf, RsSerializable rsSerializable, Set flags) { return rsSerializable.writeObject(buf, flags); } static Object deserialize(ByteBuf buf, Class javaClass) { try { var instanceObject = javaClass.getDeclaredConstructor().newInstance(); ((RsSerializable) instanceObject).readObject(buf); return instanceObject; } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException _) { throw new IllegalStateException("Unhandled class " + javaClass.getSimpleName()); } } static void deserialize(ByteBuf buf, RsSerializable rsSerializable) { rsSerializable.readObject(buf); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/RsSerialized.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import java.lang.annotation.Retention; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.RetentionPolicy.RUNTIME; /** * Marks an item's field as serializable. */ @Retention(RUNTIME) @Target(FIELD) public @interface RsSerialized { /** * Sets the TLV type, only useful for TLV fields. * * @return the TLV type (default: NONE) */ TlvType tlvType() default TlvType.STR_NONE; /** * Sets the EnumSet's type size. * * @return the EnumSet's type size (default: INTEGER) */ FieldSize fieldSize() default FieldSize.INTEGER; } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/SerializationFlags.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; public enum SerializationFlags { SIGNATURE, SIZE } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/Serializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsMetaAndData; import io.xeres.common.id.Identifier; import io.xeres.common.id.ProfileFingerprint; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.math.BigInteger; import java.util.*; /** * Class to serialize data types into a format compatible with * Retroshare's wire protocol. */ public final class Serializer { private static final Logger log = LoggerFactory.getLogger(Serializer.class); public static final int TLV_HEADER_SIZE = 6; private Serializer() { throw new UnsupportedOperationException("Utility class"); } /** * Serializes an integer. * * @param buf the buffer * @param value the value to serialize * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, int value) { return IntSerializer.serialize(buf, value); } /** * Deserializes an integer. * * @param buf the buffer * @return the value */ public static int deserializeInt(ByteBuf buf) { return IntSerializer.deserialize(buf); } /** * Serializes a short. * * @param buf the buffer * @param value the value to serialize * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, short value) { return ShortSerializer.serialize(buf, value); } /** * Deserializes a short. * * @param buf the buffer * @return the value */ public static short deserializeShort(ByteBuf buf) { return ShortSerializer.deserialize(buf); } /** * Serializes a byte. * * @param buf the buffer * @param value the value to serialize * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, byte value) { return ByteSerializer.serialize(buf, value); } /** * Deserializes a byte. * * @param buf the buffer * @return the value */ public static byte deserializeByte(ByteBuf buf) { return ByteSerializer.deserialize(buf); } /** * Serializes a long. * * @param buf the buffer * @param value the value to serialize * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, long value) { return LongSerializer.serialize(buf, value); } /** * Deserializes a long. * * @param buf the buffer * @return the value */ public static long deserializeLong(ByteBuf buf) { return LongSerializer.deserialize(buf); } /** * Serializes a float. * * @param buf the buffer * @param value the value to serialize * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, float value) { return FloatSerializer.serialize(buf, value); } /** * Deserializes a float. * * @param buf the buffer * @return the value */ public static float deserializeFloat(ByteBuf buf) { return FloatSerializer.deserialize(buf); } /** * Serializes a double. * * @param buf the buffer * @param value the value to serialize * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, double value) { return DoubleSerializer.serialize(buf, value); } /** * Deserializes a double. * * @param buf the buffer * @return the value */ public static double deserializeDouble(ByteBuf buf) { return DoubleSerializer.deserialize(buf); } /** * Serializes a boolean. * * @param buf the buffer * @param value the value to serialize * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, boolean value) { return BooleanSerializer.serialize(buf, value); } /** * Deserializes a boolean. * * @param buf the buffer * @return the value */ public static boolean deserializeBoolean(ByteBuf buf) { return BooleanSerializer.deserialize(buf); } /** * Serializes a string. * * @param buf the buffer * @param value the string * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, String value) { return StringSerializer.serialize(buf, value); } /** * Deserializes a string. * * @param buf the buffer * @return the string */ public static String deserializeString(ByteBuf buf) { return StringSerializer.deserialize(buf); } /** * Serializes an identifier. * * @param buf the buffer * @param identifier the identifier, can be null * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, Identifier identifier) { return IdentifierSerializer.serialize(buf, identifier.getClass(), identifier); } /** * Serializes an identifier. * * @param buf the buffer * @param identifier the identifier, can be null * @param identifierClass the identifier class * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, Identifier identifier, Class identifierClass) { return IdentifierSerializer.serialize(buf, identifierClass, identifier); } /** * Deserializes an identifier. * * @param buf the buffer * @param identifierClass the class of the identifier * @return the identifier */ public static Identifier deserializeIdentifier(ByteBuf buf, Class identifierClass) { return IdentifierSerializer.deserialize(buf, identifierClass); } /** * Deserializes an identifier while specifying its size. *

* This is required for some identifier that can have a varying size, like {@link ProfileFingerprint}. * * @param buf the buffer * @param identifierClass the class of the identifier * @param size the size to deserialize * @return the identifier */ public static Identifier deserializeIdentifierWithSize(ByteBuf buf, Class identifierClass, int size) { return IdentifierSerializer.deserializeWithSize(buf, identifierClass, size); } /** * Serializes a byte array. * * @param buf the buffer * @param a the byte array, can be null * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, byte[] a) { return ByteArraySerializer.serialize(buf, a); } /** * Deserializes a byte array. * * @param buf the buffer * @return the byte array */ public static byte[] deserializeByteArray(ByteBuf buf) { return ByteArraySerializer.deserialize(buf); } /** * Serializes a map. * * @param buf the buffer * @param map the map, can be null * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, Map map) { return MapSerializer.serialize(buf, map); } /** * Deserializes a map. * * @param buf the buffer * @param type the map key type and the map entry type * @return the map */ public static Map deserializeMap(ByteBuf buf, ParameterizedType type) { return MapSerializer.deserialize(buf, null, type); } /** * Serializes a list. * * @param buf the buffer * @param list the list, can be null * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, List list) { return ListSerializer.serialize(buf, list); } public static int serialize(ByteBuf buf, List list, TlvType tlvType) { return ListSerializer.serialize(buf, list, tlvType); } /** * Deserializes a list. * * @param buf the buffer * @param type the list type * @return the list */ public static List deserializeList(ByteBuf buf, ParameterizedType type) { return ListSerializer.deserialize(buf, null, type); } public static List deserializeList(ByteBuf buf, TlvType tlvType) { return ListSerializer.deserialize(buf, null, tlvType); } /** * Serializes an enum set. * * @param buf the buffer * @param enumSet the enum set * @param fieldSize the size of the enum set bitfield * @return the number of bytes taken to serialize */ public static int serialize(ByteBuf buf, Set> enumSet, FieldSize fieldSize) { return EnumSetSerializer.serialize(buf, enumSet, fieldSize); } /** * Deserializes an enum set. * * @param buf the buffer * @param e the enum class * @param fieldSize the size of the enum set bitfield * @return the enum set */ public static > Set deserializeEnumSet(ByteBuf buf, Class e, FieldSize fieldSize) { return EnumSetSerializer.deserialize(buf, e, fieldSize); } /** * Serializes an enum. * * @param buf the buffer * @param e the enum * @return the number of bytes taken */ public static int serialize(ByteBuf buf, Enum e) { return EnumSerializer.serialize(buf, e); } /** * Deserializes an enum. * * @param buf the buffer * @param e the enum class * @return the enum */ public static > E deserializeEnum(ByteBuf buf, Class e) { return EnumSerializer.deserialize(buf, e); } /** * Serializes a TLV. * * @param buf the buffer * @param type the type of the TLV * @param value the value * @return the number of bytes taken */ public static int serialize(ByteBuf buf, TlvType type, Object value) { return TlvSerializer.serialize(buf, type, value); } /** * Deserializes a TLV. * * @param buf the buffer * @param type the type of the TLV * @return the value */ public static Object deserialize(ByteBuf buf, TlvType type) { return TlvSerializer.deserialize(buf, type); } /** * Serializes a TLV binary with a defined type (needed for GXS) * * @param buf the buffer * @param type the type (usually abused to be a service) * @param data the byte array * @return the number of bytes taken */ public static int serializeTlvBinary(ByteBuf buf, int type, byte[] data) { return TlvBinarySerializer.serialize(buf, type, data); } /** * Deserializes a TLV binary with a defined type (needed for GXS) * * @param buf the buffer * @param type the type (usually abused to be a service) * @return the byte array */ public static byte[] deserializeTlvBinary(ByteBuf buf, int type) { return TlvBinarySerializer.deserialize(buf, type); } /** * Serializes all the annotated fields of an object. * * @param buf the buffer * @param object the object with the annotated fields * @return the number of bytes taken */ public static int serializeAnnotatedFields(ByteBuf buf, Object object) { return AnnotationSerializer.serialize(buf, object); } /** * Deserializes all the annotated fields of an object. * * @param buf the buffer * @param object the object with the annotated fields * @return true if at least one field was deserialized */ public static boolean deserializeAnnotatedFields(ByteBuf buf, Object object) { return AnnotationSerializer.deserialize(buf, object); } public static int serializeRsSerializable(ByteBuf buf, RsSerializable rsSerializable, Set flags) { return RsSerializableSerializer.serialize(buf, rsSerializable, flags); } public static void deserializeRsSerializable(ByteBuf buf, RsSerializable rsSerializable) { RsSerializableSerializer.deserialize(buf, rsSerializable); } public static int serializeGxsMetaAndDataItem(ByteBuf buf, GxsMetaAndData gxsMetaAndData, Set flags, GxsMetaAndDataResult result) { return GxsMetaAndDataSerializer.serialize(buf, gxsMetaAndData, flags, result); } static int serialize(ByteBuf buf, Field field, Object object) { return serialize(buf, field.getType(), getField(field, object), field.getAnnotation(RsSerialized.class)); } @SuppressWarnings("unchecked") static int serialize(ByteBuf buf, Class javaClass, Object object, RsSerialized annotation) { var size = 0; log.trace("Serializing..."); if (annotation != null && annotation.tlvType() != TlvType.STR_NONE) { size += TlvSerializer.serialize(buf, annotation.tlvType(), object); } else if (Map.class.isAssignableFrom(javaClass)) { size += MapSerializer.serialize(buf, (Map) object); } else if (List.class.isAssignableFrom(javaClass)) { size += ListSerializer.serialize(buf, (List) object); } else if (EnumSet.class.isAssignableFrom(javaClass) || Set.class.isAssignableFrom(javaClass)) { size += EnumSetSerializer.serialize(buf, (EnumSet) object, annotation); } else if (Enum.class.isAssignableFrom(javaClass)) { size += EnumSerializer.serialize(buf, (Enum) object); } else if (javaClass.equals(int.class) || javaClass.equals(Integer.class)) { Objects.requireNonNull(object, "Null integers not supported"); size += IntSerializer.serialize(buf, (int) object); } else if (javaClass.equals(short.class) || javaClass.equals(Short.class)) { Objects.requireNonNull(object, "Null shorts not supported"); size += ShortSerializer.serialize(buf, (short) object); } else if (javaClass.equals(byte.class) || javaClass.equals(Byte.class)) { Objects.requireNonNull(object, "Null bytes not supported"); size += ByteSerializer.serialize(buf, (byte) object); } else if (javaClass.equals(long.class) || javaClass.equals(Long.class)) { Objects.requireNonNull(object, "Null longs not supported"); size += LongSerializer.serialize(buf, (long) object); } else if (javaClass.equals(float.class) || javaClass.equals(Float.class)) { Objects.requireNonNull(object, "Null floats not supported"); size += FloatSerializer.serialize(buf, (float) object); } else if (javaClass.equals(double.class) || javaClass.equals(Double.class)) { Objects.requireNonNull(object, "Null doubles not supported"); size += DoubleSerializer.serialize(buf, (double) object); } else if (javaClass.equals(boolean.class) || javaClass.equals(Boolean.class)) { Objects.requireNonNull(object, "Null booleans not supported"); size += BooleanSerializer.serialize(buf, (boolean) object); } else if (javaClass.equals(String.class)) { size += StringSerializer.serialize(buf, (String) object); } else if (javaClass.equals(BigInteger.class)) { size += BigIntegerSerializer.serialize(buf, (BigInteger) object); } else if (javaClass.isArray()) { size += ArraySerializer.serialize(buf, javaClass, object); } else if (Identifier.class.isAssignableFrom(javaClass)) { size += IdentifierSerializer.serialize(buf, javaClass, (Identifier) object); } else if (RsSerializable.class.isAssignableFrom(javaClass)) { size += RsSerializableSerializer.serialize(buf, (RsSerializable) object); } else { checkForNonAllowedType(javaClass); size += AnnotationSerializer.serialize(buf, object); } return size; } static void deserialize(ByteBuf buf, Field field, Object object, RsSerialized annotation) { setField(field, object, deserialize(buf, field.getType(), field, object, annotation)); } static Object deserialize(ByteBuf buf, Class javaClass) { return deserialize(buf, javaClass, null, null, null); } @SuppressWarnings("unchecked") private static Object deserialize(ByteBuf buf, Class javaClass, Field field, Object object, RsSerialized annotation) { if (annotation != null && annotation.tlvType() != TlvType.STR_NONE) { return TlvSerializer.deserialize(buf, annotation.tlvType()); } else if (javaClass.equals(int.class) || javaClass.equals(Integer.class)) { return IntSerializer.deserialize(buf); } else if (javaClass.equals(short.class) || javaClass.equals(Short.class)) { return ShortSerializer.deserialize(buf); } else if (javaClass.equals(byte.class) || javaClass.equals(Byte.class)) { return ByteSerializer.deserialize(buf); } else if (javaClass.equals(long.class) || javaClass.equals(Long.class)) { return LongSerializer.deserialize(buf); } else if (javaClass.equals(float.class) || javaClass.equals(Float.class)) { return FloatSerializer.deserialize(buf); } else if (javaClass.equals(double.class) || javaClass.equals(Double.class)) { return DoubleSerializer.deserialize(buf); } else if (javaClass.equals(boolean.class) || javaClass.equals(Boolean.class)) { return BooleanSerializer.deserialize(buf); } else if (javaClass.equals(String.class)) { return StringSerializer.deserialize(buf); } else if (javaClass.equals(BigInteger.class)) { return BigIntegerSerializer.deserialize(buf); } else if (Identifier.class.isAssignableFrom(javaClass)) { return IdentifierSerializer.deserialize(buf, javaClass); } else if (RsSerializable.class.isAssignableFrom(javaClass)) { return RsSerializableSerializer.deserialize(buf, javaClass); } else if (javaClass.isArray()) { return ArraySerializer.deserialize(buf, javaClass); } else if (Map.class.isAssignableFrom(javaClass)) { return MapSerializer.deserialize(buf, (Map) getField(field, object), (ParameterizedType) field.getGenericType()); } else if (List.class.isAssignableFrom(javaClass)) { return ListSerializer.deserialize(buf, (List) getField(field, object), (ParameterizedType) field.getGenericType()); } else if (EnumSet.class.isAssignableFrom(javaClass) || Set.class.isAssignableFrom(javaClass)) { return EnumSetSerializer.deserialize(buf, (ParameterizedType) field.getGenericType(), annotation); } else if (Enum.class.isAssignableFrom(javaClass)) { return EnumSerializer.deserialize(buf, javaClass); } else { checkForNonAllowedType(javaClass); return AnnotationSerializer.deserializeForClass(buf, javaClass); } } private static Object getField(Field field, Object object) { try { return field.get(object); } catch (IllegalAccessException e) { throw new IllegalStateException("Can't access field " + field + ": " + e.getMessage(), e); } } @SuppressWarnings("java:S3011") // Accessibility bypass private static void setField(Field field, Object object, Object value) { try { field.set(object, value); } catch (IllegalAccessException e) { throw new IllegalStateException("Can't set field " + field + ": " + e.getMessage(), e); } } /** * Checks that a class is allowed for serialization. Retroshare is C++ so compound types should be disallowed; but they are used for lists and maps, and we cannot check them here. * * @param javaClass the class to check for support, an IllegalArgumentException is thrown if it is not supported */ private static void checkForNonAllowedType(Class javaClass) { if (javaClass.equals(Character.class) || javaClass.equals(char.class)) { throw new IllegalArgumentException("Class " + javaClass.getSimpleName() + " is not allowed for serialization"); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/SerializerSizeCache.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemUtils; import io.xeres.app.xrs.service.RsService; import java.util.HashMap; import java.util.Map; /** * This class allows to speed up the case when the size of a serialized item is needed frequently. * Only use it with items that cannot have a varying size depending on the item's field values. * Also note that incoming items have their serialized size recorded after deserialization, and outgoing items * return their serialized size when they're written to the wire, making this cache pretty much unnecessary. */ public final class SerializerSizeCache { private static final Map, Integer> cache = new HashMap<>(); private SerializerSizeCache() { throw new UnsupportedOperationException("Utility class"); } /** * Gets the size of an item once it's serialized. *

* The result is cached so make sure this is only used with items whose serialized size cannot vary depending on their content. * * @param item the item * @return the size of the item after serialization, header included */ public static int getItemSize(Item item, RsService service) { return cache.computeIfAbsent(item.getClass(), aClass -> ItemUtils.getItemSerializedSize(item, service)); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/ShortSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class ShortSerializer { private static final Logger log = LoggerFactory.getLogger(ShortSerializer.class); private ShortSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("SameReturnValue") static int serialize(ByteBuf buf, short value) { log.trace("Writing short: {}", value); buf.ensureWritable(Short.BYTES); buf.writeShort(value); return Short.BYTES; } static short deserialize(ByteBuf buf) { var val = buf.readShort(); log.trace("Reading short: {}", val); return val; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/StringSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; final class StringSerializer { private static final Logger log = LoggerFactory.getLogger(StringSerializer.class); private StringSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, String value) { log.trace("Writing string: \"{}\"", value); if (value == null) { buf.ensureWritable(Integer.BYTES); buf.writeInt(0); return Integer.BYTES; } var bytes = value.getBytes(); buf.ensureWritable(Integer.BYTES + bytes.length); buf.writeInt(bytes.length); buf.writeBytes(bytes); return Integer.BYTES + bytes.length; } static String deserialize(ByteBuf buf) { var len = buf.readInt(); log.trace("Reading string of length: {}", len); var out = new byte[len]; buf.readBytes(out); return new String(out); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvAddressSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.net.protocol.PeerAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvAddressSerializer { private static final Logger log = LoggerFactory.getLogger(TlvAddressSerializer.class); private TlvAddressSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, PeerAddress peerAddress) { buf.ensureWritable(peerAddress == null ? TLV_HEADER_SIZE : (TLV_HEADER_SIZE * 2 + 6)); buf.writeShort(ADDRESS.getValue()); if (peerAddress == null) { buf.writeInt(TLV_HEADER_SIZE); return TLV_HEADER_SIZE; } switch (peerAddress.getType()) { case IPV4 -> { buf.writeInt(TLV_HEADER_SIZE * 2 + 6); buf.writeShort(IPV4.getValue()); buf.writeInt(TLV_HEADER_SIZE + 6); var address = peerAddress.getAddressAsBytes().orElseThrow(); // RS expects little endian buf.writeByte(address[3]); buf.writeByte(address[2]); buf.writeByte(address[1]); buf.writeByte(address[0]); buf.writeByte(address[5]); buf.writeByte(address[4]); return TLV_HEADER_SIZE * 2 + 6; } default -> throw new IllegalArgumentException("Unsupported address type " + peerAddress.getType().name()); } } static PeerAddress deserialize(ByteBuf buf) { var type = buf.readUnsignedShort(); log.trace("Address type: {}", type); if (type == ADDRESS.getValue()) { var totalSize = buf.readInt(); // XXX: check size if (totalSize > TLV_HEADER_SIZE) { var addressType = buf.readUnsignedShort(); var addressSize = buf.readInt(); if (addressType == IPV4.getValue()) { if (addressSize != TLV_HEADER_SIZE + 6) { throw new IllegalArgumentException("Wrong IPV4 address size: " + addressSize); } log.trace("reading IPv4 address of {} bytes", addressSize); var address = new byte[6]; // RS stores both in little endian address[3] = buf.readByte(); address[2] = buf.readByte(); address[1] = buf.readByte(); address[0] = buf.readByte(); address[5] = buf.readByte(); address[4] = buf.readByte(); return PeerAddress.fromByteArray(address); } else { log.trace("Skipping unsupported address type {}, size: {}", addressType, addressSize); buf.skipBytes(addressSize - TLV_HEADER_SIZE); return PeerAddress.fromInvalid(); } } } else { throw new IllegalArgumentException("Unrecognized address " + type); } return PeerAddress.fromInvalid(); } static int serializeList(ByteBuf buf, List addresses) { buf.ensureWritable(TLV_HEADER_SIZE); buf.writeShort(ADDRESS_SET.getValue()); var totalSize = TLV_HEADER_SIZE; var totalSizeOffset = buf.writerIndex(); buf.writeInt(0); if (addresses != null) { for (var address : addresses) { var size = TLV_HEADER_SIZE + 12; // long + int below buf.writeShort(ADDRESS_INFO.getValue()); var sizeOffset = buf.writerIndex(); buf.writeInt(0); size += serialize(buf, address); buf.writeLong(0); // XXX: seenTime (64-bits)... we don't have that in PeerAddress... where do we get it from?! buf.writeInt(0); // XXX: source (32-bits)... likewise buf.setInt(sizeOffset, size); totalSize += size; } } buf.setInt(totalSizeOffset, totalSize); return totalSize; } static List deserializeList(ByteBuf buf) { var addresses = new ArrayList(); var totalSize = TlvUtils.checkTypeAndLength(buf, ADDRESS_SET); var index = buf.readerIndex(); while (buf.readerIndex() < index + totalSize) { var size = TlvUtils.checkTypeAndLength(buf, ADDRESS_INFO); if (size > 0) { var peerAddress = deserialize(buf); if (peerAddress.isValid()) { addresses.add(peerAddress); } buf.readLong(); // XXX: seenTime buf.readInt(); // XXX: source } } return addresses; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvBinarySerializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class TlvBinarySerializer { private static final Logger log = LoggerFactory.getLogger(TlvBinarySerializer.class); private TlvBinarySerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, byte[] data) { return serialize(buf, TlvType.STR_NONE, data); } static int serialize(ByteBuf buf, TlvType type, byte[] data) { return serialize(buf, type.getValue(), data); } static int serialize(ByteBuf buf, int type, byte[] data) { if (data == null) { data = new byte[0]; } var len = getSize(data); log.trace("Writing TLV binary data (size: {})", data.length); buf.ensureWritable(len); buf.writeShort(type); buf.writeInt(len); if (data.length > 0) { buf.writeBytes(data); } return len; } static int getSize(byte[] data) { return TLV_HEADER_SIZE + (data != null ? data.length : 0); } static byte[] deserialize(ByteBuf buf) { return deserialize(buf, TlvType.STR_NONE); } static byte[] deserialize(ByteBuf buf, TlvType type) { return deserialize(buf, type.getValue()); } static byte[] deserialize(ByteBuf buf, int type) { log.trace("Reading TLV binary"); var len = TlvUtils.checkTypeAndLength(buf, type); log.trace(" of {} bytes", len); var out = new byte[len]; buf.readBytes(out); return out; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvFileDataSerializer.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.FileData; import io.xeres.app.xrs.common.FileItem; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvFileDataSerializer { private static final Logger log = LoggerFactory.getLogger(TlvFileDataSerializer.class); private TlvFileDataSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, FileData fileData) { log.trace("Writing TlvFileData"); var len = getSize(fileData); buf.ensureWritable(len); buf.writeShort(FILE_DATA.getValue()); buf.writeInt(len); TlvFileItemSerializer.serialize(buf, fileData.fileItem()); TlvSerializer.serialize(buf, LONG_OFFSET, fileData.offset()); TlvBinarySerializer.serialize(buf, BIN_FILE_DATA, fileData.data()); return len; } static int getSize(FileData fileData) { return TLV_HEADER_SIZE + TlvFileItemSerializer.getSize(fileData.fileItem()) + TlvUint64Serializer.getSize() + TlvBinarySerializer.getSize(fileData.data()); } static FileData deserialize(ByteBuf buf) { log.trace("Reading TlvFileData"); TlvUtils.checkTypeAndLength(buf, FILE_DATA); var fileItem = (FileItem) TlvSerializer.deserialize(buf, FILE_ITEM); var offset = (long) TlvSerializer.deserialize(buf, LONG_OFFSET); var data = TlvBinarySerializer.deserialize(buf, BIN_FILE_DATA); return new FileData(fileItem, offset, data); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvFileItemSerializer.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.FileItem; import io.xeres.common.id.Sha1Sum; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvFileItemSerializer { private static final Logger log = LoggerFactory.getLogger(TlvFileItemSerializer.class); private TlvFileItemSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, FileItem fileItem) { log.trace("Writing TlvFileItem"); var len = getSize(fileItem); buf.ensureWritable(len); buf.writeShort(FILE_ITEM.getValue()); buf.writeInt(len); buf.writeLong(fileItem.size()); Serializer.serialize(buf, fileItem.hash()); if (StringUtils.isNotEmpty(fileItem.name())) { TlvSerializer.serialize(buf, STR_NAME, fileItem.name()); } if (StringUtils.isNotEmpty(fileItem.path())) { TlvSerializer.serialize(buf, STR_PATH, fileItem.path()); } if (fileItem.age() != 0) { TlvSerializer.serialize(buf, INT_AGE, fileItem.age()); } return len; } static int getSize(FileItem fileItem) { return TLV_HEADER_SIZE + 8 + Sha1Sum.LENGTH + (StringUtils.isEmpty(fileItem.name()) ? 0 : TlvStringSerializer.getSize(fileItem.name())) + (StringUtils.isEmpty(fileItem.path()) ? 0 : TlvStringSerializer.getSize(fileItem.path())) + (fileItem.age() == 0 ? 0 : TlvUint32Serializer.getSize()); } static FileItem deserialize(ByteBuf buf) { log.trace("Reading TlvFileItem"); var totalSize = TlvUtils.checkTypeAndLength(buf, FILE_ITEM); var index = buf.readerIndex(); var size = Serializer.deserializeLong(buf); var hash = (Sha1Sum) Serializer.deserializeIdentifier(buf, Sha1Sum.class); TlvType tlvType; String name = null; String path = null; var age = 0; while (buf.readerIndex() < index + totalSize && (tlvType = TlvUtils.peekTlvType(buf)) != null) { switch (tlvType) { case STR_NAME -> name = (String) TlvSerializer.deserialize(buf, STR_NAME); case STR_PATH -> path = (String) TlvSerializer.deserialize(buf, STR_PATH); case INT_POPULARITY -> TlvSerializer.deserialize(buf, INT_POPULARITY); case INT_AGE -> age = (int) TlvSerializer.deserialize(buf, INT_AGE); case INT_SIZE -> TlvSerializer.deserialize(buf, INT_SIZE); case SET_HASH -> TlvSerializer.deserialize(buf, SET_HASH); default -> TlvUtils.skipTlv(buf); } } return new FileItem(size, hash, name, path, age); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvFileSetSerializer.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.FileItem; import io.xeres.app.xrs.common.FileSet; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvFileSetSerializer { private static final Logger log = LoggerFactory.getLogger(TlvFileSetSerializer.class); private TlvFileSetSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, FileSet fileSet) { log.trace("Writing TlvFileSet"); var len = getSize(fileSet); buf.ensureWritable(len); buf.writeShort(FILE_SET.getValue()); buf.writeInt(len); fileSet.fileItems() .forEach(fileItem -> TlvFileItemSerializer.serialize(buf, fileItem)); if (StringUtils.isNotEmpty(fileSet.title())) { TlvSerializer.serialize(buf, STR_TITLE, fileSet.title()); } if (StringUtils.isNotEmpty(fileSet.comment())) { TlvSerializer.serialize(buf, STR_COMMENT, fileSet.comment()); } return len; } static int getSize(FileSet fileSet) { return TLV_HEADER_SIZE + fileSet.fileItems().stream().mapToInt(TlvFileItemSerializer::getSize).sum() + (StringUtils.isEmpty(fileSet.title()) ? 0 : TlvStringSerializer.getSize(fileSet.title())) + (StringUtils.isEmpty(fileSet.comment()) ? 0 : TlvStringSerializer.getSize(fileSet.comment())); } static FileSet deserialize(ByteBuf buf) { log.trace("Reading TlvFileSet"); var totalSize = TlvUtils.checkTypeAndLength(buf, FILE_SET); var index = buf.readerIndex(); TlvType tlvType; String title = null; String comment = null; List fileItems = new ArrayList<>(); while (buf.readerIndex() < index + totalSize && (tlvType = TlvUtils.peekTlvType(buf)) != null) { switch (tlvType) { case FILE_ITEM -> fileItems.add(TlvFileItemSerializer.deserialize(buf)); case STR_TITLE -> title = (String) TlvSerializer.deserialize(buf, STR_TITLE); case STR_COMMENT -> comment = (String) TlvSerializer.deserialize(buf, STR_COMMENT); default -> TlvUtils.skipTlv(buf); } } return new FileSet(fileItems, title, comment); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvImageSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.BIN_IMAGE; import static io.xeres.app.xrs.serialization.TlvType.IMAGE; final class TlvImageSerializer { private static final Logger log = LoggerFactory.getLogger(TlvImageSerializer.class); public enum ImageType { AUTO_DETECT, // Retroshare always sends this (supposedly PNG). We assume we have to look into the data to know what it is. PNG, JPEG } private TlvImageSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, byte[] image) { log.trace("Writing image"); var len = getSize(image); buf.ensureWritable(len); buf.writeShort(IMAGE.getValue()); buf.writeInt(len); EnumSerializer.serialize(buf, ImageType.AUTO_DETECT); TlvSerializer.serialize(buf, BIN_IMAGE, image); return len; } static int getSize(byte[] data) { return TLV_HEADER_SIZE + EnumSerializer.getSize() + TlvBinarySerializer.getSize(data); } static byte[] deserialize(ByteBuf buf) { log.trace("Reading image"); TlvUtils.checkTypeAndLength(buf, IMAGE); EnumSerializer.deserialize(buf, ImageType.class); // Not really used return (byte[]) TlvSerializer.deserialize(buf, BIN_IMAGE); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvSecurityKeySerializer.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.SecurityKey; import io.xeres.common.id.GxsId; import io.xeres.common.id.Id; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.xrs.serialization.Serializer.*; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvSecurityKeySerializer { private static final Logger log = LoggerFactory.getLogger(TlvSecurityKeySerializer.class); private TlvSecurityKeySerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, SecurityKey securityKey) { log.trace("Writing TlvRsaKey"); var len = getSize(securityKey); buf.ensureWritable(len); buf.writeShort(SECURITY_KEY.getValue()); buf.writeInt(len); TlvSerializer.serialize(buf, STR_KEY_ID, Id.toString(securityKey.getKeyGxsId())); Serializer.serialize(buf, securityKey.getFlags(), FieldSize.INTEGER); Serializer.serialize(buf, securityKey.getValidFromInTs()); Serializer.serialize(buf, securityKey.getValidToInTs()); TlvSerializer.serialize(buf, KEY_EVP_PKEY, securityKey.getData()); return len; } static int getSize(SecurityKey securityKey) { return TLV_HEADER_SIZE + (TLV_HEADER_SIZE + GxsId.LENGTH * 2) + 4 + 4 + 4 + TLV_HEADER_SIZE + securityKey.getData().length; } static SecurityKey deserialize(ByteBuf buf) { log.trace("Reading TlvRsaKey"); TlvUtils.checkTypeAndLength(buf, SECURITY_KEY); var gxsId = new GxsId(Id.asciiStringToBytes((String) TlvSerializer.deserialize(buf, STR_KEY_ID))); var flags = deserializeEnumSet(buf, SecurityKey.Flags.class, FieldSize.INTEGER); var startTs = deserializeInt(buf); var endTs = deserializeInt(buf); var data = (byte[]) TlvSerializer.deserialize(buf, KEY_EVP_PKEY); return new SecurityKey(gxsId, flags, startTs, endTs, data); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvSecurityKeySetSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.SecurityKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashSet; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvSecurityKeySetSerializer { private static final Logger log = LoggerFactory.getLogger(TlvSecurityKeySetSerializer.class); private static final String GROUP_ID_VALUE = ""; // unused private TlvSecurityKeySetSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Set securityKeys) { log.trace("Writing TlvSecurityKeySet"); var len = getSize(securityKeys); buf.ensureWritable(len); buf.writeShort(SECURITY_KEY_SET.getValue()); buf.writeInt(len); TlvSerializer.serialize(buf, STR_GROUP_ID, GROUP_ID_VALUE); securityKeys.stream() .sorted() .forEach(securityKey -> TlvSerializer.serialize(buf, SECURITY_KEY, securityKey)); return len; } static int getSize(Set securityKeys) { return TLV_HEADER_SIZE + TlvStringSerializer.getSize(GROUP_ID_VALUE) + securityKeys.stream().mapToInt(TlvSecurityKeySerializer::getSize).sum(); } static Set deserialize(ByteBuf buf) { log.trace("Reading TlvSecurityKeySet"); var len = TlvUtils.checkTypeAndLength(buf, SECURITY_KEY_SET); // STR_GROUP_ID must be empty if (!TlvSerializer.deserialize(buf, STR_GROUP_ID).equals(GROUP_ID_VALUE)) { throw new IllegalArgumentException("STR_GROUP_ID is not empty"); } len -= TlvStringSerializer.getSize(""); Set securityKeys = HashSet.newHashSet(2); while (len > 0) { var securityKey = (SecurityKey) TlvSerializer.deserialize(buf, SECURITY_KEY); securityKeys.add(securityKey); len -= TlvSecurityKeySerializer.getSize(securityKey); } return securityKeys; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvSerializer.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.xrs.common.*; import io.xeres.common.id.GxsId; import io.xeres.common.id.Identifier; import io.xeres.common.id.MsgId; import io.xeres.common.id.Sha1Sum; import java.util.List; import java.util.Set; import static io.xeres.app.xrs.serialization.TlvType.SIGNATURE_TYPE; /** * This class if for serializing/deserializing TLVs by: *

    *
  • {@code @RsSerialized} annotations
  • *
  • classes outside the {@code serialization} package
  • *
* For anything else, use the TLV classes directly because they don't require casting of the * return types, and they have the {@code getSize()} method. */ final class TlvSerializer { private TlvSerializer() { throw new UnsupportedOperationException("Utility class"); } @SuppressWarnings("unchecked") static int serialize(ByteBuf buf, TlvType type, Object value) { return switch (type) { case STR_NONE, STR_NAME, STR_MSG, STR_LOCATION, STR_VERSION, STR_HASH_SHA1, STR_DYNDNS, STR_DOM_ADDR, STR_GENID, STR_KEY_ID, STR_GROUP_ID, STR_VALUE, STR_DESCR, STR_PATH, STR_LINK, STR_COMMENT, STR_TITLE, STR_GXS_MESSAGE_COMMENT -> TlvStringSerializer.serialize(buf, type, (String) value); case INT_AGE, INT_POPULARITY, INT_SIZE, INT_BANDWIDTH -> TlvUint32Serializer.serialize(buf, type, (int) value); case LONG_OFFSET -> TlvUint64Serializer.serialize(buf, type, (long) value); case ADDRESS -> TlvAddressSerializer.serialize(buf, (PeerAddress) value); case ADDRESS_SET -> TlvAddressSerializer.serializeList(buf, (List) value); case SIGNATURE -> TlvSignatureSerializer.serialize(buf, (Signature) value); case SET_PGP_ID -> TlvSetSerializer.serializeLong(buf, type, (Set) value); case SET_HASH, SET_GXS_ID, SET_GXS_MSG_ID -> TlvSetSerializer.serializeIdentifier(buf, type, (Set) value); case SET_RECOGN -> TlvStringSetRefSerializer.serialize(buf, type, (List) value); case SIGNATURE_SET -> TlvSignatureSetSerializer.serialize(buf, (Set) value); case SIGNATURE_TYPE -> TlvUint32Serializer.serialize(buf, SIGNATURE_TYPE, (int) value); case SECURITY_KEY -> TlvSecurityKeySerializer.serialize(buf, (SecurityKey) value); case SECURITY_KEY_SET -> TlvSecurityKeySetSerializer.serialize(buf, (Set) value); case IMAGE -> TlvImageSerializer.serialize(buf, (byte[]) value); case FILE_SET -> TlvFileSetSerializer.serialize(buf, (FileSet) value); case FILE_ITEM -> TlvFileItemSerializer.serialize(buf, (FileItem) value); case FILE_DATA -> TlvFileDataSerializer.serialize(buf, (FileData) value); case SIGN_RSA_SHA1, KEY_EVP_PKEY, STR_SIGN, BIN_IMAGE, BIN_FILE_DATA -> TlvBinarySerializer.serialize(buf, type, (byte[]) value); case IPV4, IPV6, ADDRESS_INFO, UNKNOWN -> throw new IllegalArgumentException("Can't use type " + type + " for direct TLV serialization"); }; } static Object deserialize(ByteBuf buf, TlvType type) { return switch (type) { case STR_NONE, STR_NAME, STR_MSG, STR_LOCATION, STR_VERSION, STR_HASH_SHA1, STR_DYNDNS, STR_DOM_ADDR, STR_GENID, STR_KEY_ID, STR_GROUP_ID, STR_VALUE, STR_DESCR, STR_PATH, STR_LINK, STR_COMMENT, STR_TITLE, STR_GXS_MESSAGE_COMMENT -> TlvStringSerializer.deserialize(buf, type); case INT_AGE, INT_POPULARITY, INT_SIZE, INT_BANDWIDTH -> TlvUint32Serializer.deserialize(buf, type); case LONG_OFFSET -> TlvUint64Serializer.deserialize(buf, type); case ADDRESS -> TlvAddressSerializer.deserialize(buf); case ADDRESS_SET -> TlvAddressSerializer.deserializeList(buf); case SIGNATURE -> TlvSignatureSerializer.deserialize(buf); case SET_PGP_ID -> TlvSetSerializer.deserializeLong(buf, type); case SET_HASH -> TlvSetSerializer.deserializeIdentifier(buf, type, Sha1Sum.class); case SET_GXS_ID -> TlvSetSerializer.deserializeIdentifier(buf, type, GxsId.class); case SET_GXS_MSG_ID -> TlvSetSerializer.deserializeIdentifier(buf, type, MsgId.class); case SET_RECOGN -> TlvStringSetRefSerializer.deserialize(buf, type); case SIGNATURE_SET -> TlvSignatureSetSerializer.deserialize(buf); case SIGNATURE_TYPE -> TlvUint32Serializer.deserialize(buf, SIGNATURE_TYPE); case SECURITY_KEY -> TlvSecurityKeySerializer.deserialize(buf); case SECURITY_KEY_SET -> TlvSecurityKeySetSerializer.deserialize(buf); case IMAGE -> TlvImageSerializer.deserialize(buf); case FILE_SET -> TlvFileSetSerializer.deserialize(buf); case FILE_ITEM -> TlvFileItemSerializer.deserialize(buf); case FILE_DATA -> TlvFileDataSerializer.deserialize(buf); case SIGN_RSA_SHA1, KEY_EVP_PKEY, STR_SIGN, BIN_IMAGE, BIN_FILE_DATA -> TlvBinarySerializer.deserialize(buf, type); case IPV4, IPV6, ADDRESS_INFO, UNKNOWN -> throw new IllegalArgumentException("Can't use type " + type + " for direct TLV deserialization"); }; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvSetSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.common.id.Identifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigInteger; import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class TlvSetSerializer { private static final Logger log = LoggerFactory.getLogger(TlvSetSerializer.class); private TlvSetSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serializeLong(ByteBuf buf, TlvType type, Set set) { var len = getLongSize(set); log.trace("Writing set of longs: {}", log.isTraceEnabled() ? Arrays.toString(set.toArray()) : ""); buf.ensureWritable(len); buf.writeShort(type.getValue()); buf.writeInt(len); set.stream() .sorted() .forEach(buf::writeLong); return len; } static int serializeIdentifier(ByteBuf buf, TlvType type, Set set) { var len = getIdentifierSize(set); log.trace("Writing set of identifiers: {}", log.isTraceEnabled() ? Arrays.toString(set.toArray()) : ""); buf.ensureWritable(len); buf.writeShort(type.getValue()); buf.writeInt(len); set.stream() .sorted(Comparator.comparing(identifier -> new BigInteger(1, identifier.getBytes()))) .forEach(identifier -> buf.writeBytes(identifier.getBytes())); return len; } private static int getLongSize(Set set) { return TLV_HEADER_SIZE + Long.BYTES * set.size(); } static int getIdentifierSize(Set set) { if (set.isEmpty()) { return TLV_HEADER_SIZE; } return TLV_HEADER_SIZE + set.stream().findFirst().orElseThrow().getLength() * set.size(); } static Set deserializeLong(ByteBuf buf, TlvType type) { log.trace("Reading set of longs"); var len = TlvUtils.checkTypeAndLength(buf, type); var count = len / Long.BYTES; HashSet set = HashSet.newHashSet(count); while (count-- > 0) { set.add(buf.readLong()); } return set; } static Set deserializeIdentifier(ByteBuf buf, TlvType type, Class identifierClass) { log.trace("Reading set of identifiers"); var len = TlvUtils.checkTypeAndLength(buf, type); var count = len / IdentifierSerializer.getIdentifierLength(identifierClass); HashSet set = HashSet.newHashSet(count); while (count-- > 0) { set.add(IdentifierSerializer.deserialize(buf, identifierClass)); } return set; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvSignatureSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.Signature; import io.xeres.common.id.GxsId; import io.xeres.common.id.Id; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvSignatureSerializer { private static final Logger log = LoggerFactory.getLogger(TlvSignatureSerializer.class); private TlvSignatureSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Signature signature) { log.trace("Writing TlvKeySignature"); var len = getSize(signature); buf.ensureWritable(len); buf.writeShort(SIGNATURE.getValue()); buf.writeInt(len); TlvSerializer.serialize(buf, STR_KEY_ID, Id.toString(signature.getGxsId())); TlvSerializer.serialize(buf, SIGN_RSA_SHA1, signature.getData()); return len; } static int getSize(Signature signature) { return TLV_HEADER_SIZE + (TLV_HEADER_SIZE + GxsId.LENGTH * 2) + TlvBinarySerializer.getSize(signature.getData()); } static Signature deserialize(ByteBuf buf) { log.trace("Reading TlvKeySignature"); TlvUtils.checkTypeAndLength(buf, SIGNATURE); var gxsId = new GxsId(Id.asciiStringToBytes((String) TlvSerializer.deserialize(buf, STR_KEY_ID))); var data = (byte[]) TlvSerializer.deserialize(buf, SIGN_RSA_SHA1); return new Signature(gxsId, data); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvSignatureSetSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.Signature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashSet; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.*; final class TlvSignatureSetSerializer { private static final Logger log = LoggerFactory.getLogger(TlvSignatureSetSerializer.class); private TlvSignatureSetSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, Set signatures) { log.trace("Writing TlvSignatureSet"); var len = getSize(signatures); buf.ensureWritable(len); buf.writeShort(SIGNATURE_SET.getValue()); buf.writeInt(len); signatures.stream() .sorted() .forEach(signature -> { TlvSerializer.serialize(buf, SIGNATURE_TYPE, signature.getType().getValue()); TlvSerializer.serialize(buf, SIGNATURE, signature); }); return len; } static int getSize(Set signatures) { return TLV_HEADER_SIZE + signatures.stream().mapToInt(signature -> TlvUint32Serializer.getSize() + TlvSignatureSerializer.getSize(signature)).sum(); } static Set deserialize(ByteBuf buf) { log.trace("Reading TlvSignatureSet"); var len = TlvUtils.checkTypeAndLength(buf, SIGNATURE_SET); Set signatures = HashSet.newHashSet(2); while (len > 0) { var type = Signature.Type.findByValue((int) TlvSerializer.deserialize(buf, SIGNATURE_TYPE)); var signature = (Signature) TlvSerializer.deserialize(buf, SIGNATURE); signature.setType(type); signatures.add(signature); len -= TlvUint32Serializer.getSize() + TlvSignatureSerializer.getSize(signature); } return signatures; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvStringSerializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class TlvStringSerializer { private static final Logger log = LoggerFactory.getLogger(TlvStringSerializer.class); private TlvStringSerializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, TlvType type, String s) { var len = getSize(s); var bytes = s != null ? s.getBytes() : new byte[0]; log.trace("Writing string ({}): \"{}\"", type, s); buf.ensureWritable(len); buf.writeShort(type.getValue()); buf.writeInt(len); if (bytes.length > 0) { buf.writeBytes(bytes); } return len; } static int getSize(String s) { return TLV_HEADER_SIZE + (s != null ? s.getBytes().length : 0); } static String deserialize(ByteBuf buf, TlvType type) { log.trace("Reading TLV string"); var len = TlvUtils.checkTypeAndLength(buf, type); var out = new byte[len]; buf.readBytes(out); return new String(out); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvStringSetRefSerializer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class TlvStringSetRefSerializer { private static final Logger log = LoggerFactory.getLogger(TlvStringSetRefSerializer.class); private TlvStringSetRefSerializer() { throw new UnsupportedOperationException("Utility class"); } // XXX: warning! serialization has not been tested static int serialize(ByteBuf buf, TlvType type, List refIds) { var len = getSize(refIds); log.trace("Writing refids: {}", log.isTraceEnabled() ? refIds : ""); buf.ensureWritable(len); buf.writeShort(type.getValue()); buf.writeInt(len); refIds.forEach(s -> TlvSerializer.serialize(buf, TlvType.STR_GENID, s)); return len; } static int getSize(List refIds) { return TLV_HEADER_SIZE + refIds.size(); } static List deserialize(ByteBuf buf, TlvType type) { log.trace("Reading refids"); var len = TlvUtils.checkTypeAndLength(buf, type); var listIndex = buf.readerIndex(); List refIds = new ArrayList<>(); while (buf.readerIndex() < listIndex + len) { refIds.add((String) Serializer.deserialize(buf, TlvType.STR_GENID)); } return refIds; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvType.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; public enum TlvType { STR_NONE(0x0), // Used to write strings without TLVs STR_GXS_MESSAGE_COMMENT(0x1), // Used only by GxS comment messages INT_SIZE(0x30), INT_POPULARITY(0x31), INT_AGE(0x32), INT_BANDWIDTH(0x35), LONG_OFFSET(0x41), STR_NAME(0x51), STR_PATH(0x52), STR_VALUE(0x54), STR_COMMENT(0x55), STR_TITLE(0x56), STR_MSG(0x57), STR_LINK(0x59), STR_GENID(0x5a), STR_LOCATION(0x5c), STR_VERSION(0x5f), STR_HASH_SHA1(0x70), STR_DYNDNS(0x83), STR_DOM_ADDR(0x84), IPV4(0x85), IPV6(0x86), STR_GROUP_ID(0xa0), STR_KEY_ID(0xa4), STR_DESCR(0xb3), STR_SIGN(0xb4), KEY_EVP_PKEY(0x110), SIGN_RSA_SHA1(0x120), BIN_IMAGE(0x130), BIN_FILE_DATA(0x140), FILE_ITEM(0x1000), FILE_SET(0x1001), FILE_DATA(0x1002), SET_HASH(0x1022), SET_PGP_ID(0x1023), SET_RECOGN(0x1024), SET_GXS_ID(0x1025), SET_GXS_MSG_ID(0x1028), SECURITY_KEY(0x1040), SECURITY_KEY_SET(0x1041), SIGNATURE(0x1050), SIGNATURE_SET(0x1051), SIGNATURE_TYPE(0x1052), IMAGE(0x1060), ADDRESS_INFO(0x1070), ADDRESS_SET(0x1071), ADDRESS(0x1072), UNKNOWN(0xffff); // Used to signal that an unknown TLV has been found private final int value; TlvType(int value) { this.value = value; } public int getValue() { return value; } /** * Gets a TLV from the value. * * @param value the TLV value * @return the TLV or UNKNOWN if the value is not known (including for NONE and UNKNOWN itself) */ public static TlvType fromValue(int value) { for (TlvType tlvType : values()) { if (tlvType.getValue() == value && tlvType != STR_NONE) { return tlvType; } } return UNKNOWN; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvUint32Serializer.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class TlvUint32Serializer { private TlvUint32Serializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, TlvType type, int value) { var len = getSize(); buf.ensureWritable(len); buf.writeShort(type.getValue()); buf.writeInt(len); buf.writeInt(value); return len; } static int getSize() { return TLV_HEADER_SIZE + Integer.BYTES; } static int deserialize(ByteBuf buf, TlvType type) { var readType = buf.readUnsignedShort(); if (readType != type.getValue()) { throw new IllegalArgumentException("Type " + readType + " does not match " + type); } var len = buf.readInt(); if (len != getSize()) { throw new IllegalArgumentException("Length is wrong: " + len + ", expected: " + getSize()); } return buf.readInt(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvUint64Serializer.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class TlvUint64Serializer { private TlvUint64Serializer() { throw new UnsupportedOperationException("Utility class"); } static int serialize(ByteBuf buf, TlvType type, long value) { var len = getSize(); buf.ensureWritable(len); buf.writeShort(type.getValue()); buf.writeInt(len); buf.writeLong(value); return len; } static int getSize() { return TLV_HEADER_SIZE + Long.BYTES; } static long deserialize(ByteBuf buf, TlvType type) { var readType = buf.readUnsignedShort(); if (readType != type.getValue()) { throw new IllegalArgumentException("Type " + readType + " does not match " + type); } var len = buf.readInt(); if (len != getSize()) { throw new IllegalArgumentException("Length is wrong: " + len + ", expected: " + getSize()); } return buf.readLong(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/serialization/TlvUtils.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.ByteBuf; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; final class TlvUtils { private TlvUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Checks if the buffer contains the right TlvType and if the length is at least TLV_HEADER_SIZE. * * @param buf the ByteBuf containing the incoming data * @param tlvType the TlvType to check against * @return the remaining length after TLV_HEADER_SIZE is subtracted */ static int checkTypeAndLength(ByteBuf buf, TlvType tlvType) { return checkTypeAndLength(buf, tlvType.getValue()); } /** * Checks if the buffer contains the right TLV type and if the length is at least TLV_HEADER_SIZE. * This function is needed in addition to the one above because Retroshare abuses some TLVs to store the service type in them. * * @param buf the ByteBuf containing the incoming data * @param tlvType the TLV type to check against, as an int * @return the remaining length after TLV_HEADER_SIZE is subtracted */ static int checkTypeAndLength(ByteBuf buf, int tlvType) { var readType = buf.readUnsignedShort(); if (readType != tlvType) { throw new IllegalArgumentException("Type " + readType + " does not match " + tlvType); } var len = buf.readInt(); if (len < TLV_HEADER_SIZE) { throw new IllegalArgumentException("Length " + len + " is smaller than the header size (6)"); } return len - TLV_HEADER_SIZE; } /** * Checks the next buffer to get the TLV type. * * @param buf the ByteBuf containing the incoming data * @return the TLV type or null if not found or if the buffer is empty */ static TlvType peekTlvType(ByteBuf buf) { if (buf.readableBytes() < TLV_HEADER_SIZE) { return null; } return TlvType.fromValue(buf.getUnsignedShort(buf.readerIndex())); } /** * Skips the TLV. * * @param buf the ByteBuf containing the TLV */ static void skipTlv(ByteBuf buf) { if (buf.readableBytes() < TLV_HEADER_SIZE) { throw new IllegalArgumentException("Can't skip the TLV because there's not enough bytes to represent one"); } buf.readUnsignedShort(); var size = buf.readInt(); buf.skipBytes(size); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/DefaultItem.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; import io.xeres.app.xrs.item.Item; /** * An item that is not part of any service. * Is used when there's no service that maps to an item. * Will just be disposed by the pipeline. */ public final class DefaultItem extends Item { @Override public int getServiceType() { return io.xeres.common.protocol.xrs.RsServiceType.NONE.getType(); } @Override public int getSubType() { return 0; } @Override public DefaultItem clone() { return (DefaultItem) super.clone(); } @Override public String toString() { return "DefaultItem{}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/RsService.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; import io.xeres.app.application.events.NetworkReadyEvent; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.xrs.item.Item; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.springframework.context.annotation.DependsOn; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; /** * Base class for "Retroshare services". * These services have a unique number assigned which directs matching packets to them. *

* Note: this class has a natural ordering that is inconsistent with equals. */ @DependsOn({"rsServiceRegistry"}) public abstract class RsService implements Comparable { public abstract io.xeres.common.protocol.xrs.RsServiceType getServiceType(); /** * Handle incoming items. You can use JPA calls in there if your implementation is annotated with @Transactional. * * @param sender the peer sending the item * @param item the item */ public abstract void handleItem(PeerConnection sender, Item item); private final RsServiceRegistry rsServiceRegistry; private boolean enabled; private boolean initialized; protected RsService(RsServiceRegistry rsServiceRegistry) { this.rsServiceRegistry = rsServiceRegistry; } public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.OFF; } /** * Sent once upon startup when the service is enabled and the network is ready. Good place to initialize * executors, etc... *

* Keep in mind that your service can receive some packets before initialize() is called. */ public void initialize() { // Do nothing by default } /** * Sent once when the application is exiting but before closing the connections. * Good place to send last messages (for example, leaving a room, etc...). */ public void shutdown(PeerConnection peerConnection) { // Do nothing by default } /** * Sent once when the application is exiting. Good place to perform spring boot related cleanups * since the beans are all still available. */ public void shutdown() { // Do nothing by default } /** * Sent once when the application is almost done exiting. Good place to remove any * executor setup in initialize(). */ public void cleanup() { // Do nothing by default } public void initialize(PeerConnection peerConnection) { throw new IllegalStateException("Implement initialize() method if you override getInitPriority() to be anything else than OFF"); } @PostConstruct private void init() { enabled = rsServiceRegistry.registerService(this); } @EventListener public void init(NetworkReadyEvent unused) { if (enabled && !initialized) { initialized = true; initialize(); addSlavesIfNeeded(); } } private void addSlavesIfNeeded() { if (RsServiceMaster.class.isAssignableFrom(getClass())) { //noinspection rawtypes,unchecked rsServiceRegistry.getSlaves(this).forEach(rsServiceSlave -> ((RsServiceMaster) this).addRsSlave(rsServiceSlave)); } } @EventListener public void onApplicationEvent(ContextClosedEvent unused) { if (enabled) { shutdown(); } } @PreDestroy private void destroy() { if (enabled) { cleanup(); } } @Override @SuppressWarnings("java:S1210") public int compareTo(RsService o) { return Integer.compare(getInitPriority().ordinal(), o.getInitPriority().ordinal()); } @Override public String toString() { return getServiceType().getName(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/RsServiceInitPriority.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; /** * Priority for running service initializations. Except when OFF (default), * contains a time range with random triggering in between, to increase handshake * chances between peers. */ public enum RsServiceInitPriority { OFF(0, 0), LOW(11, 20), NORMAL(6, 10), HIGH(3, 5), IMMEDIATE(1, 2); private final int minTime; private final int maxTime; RsServiceInitPriority(int minTime, int maxTime) { this.minTime = minTime; this.maxTime = maxTime; } public int getMinTime() { return minTime; } public int getMaxTime() { return maxTime; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/RsServiceMaster.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; /** * This interface allows to implement dependencies between services, that is, one master service * has a list of clients that it can handle. Each master service or client needs to implement this interface * which can of course be extended. * * @see RsServiceSlave */ public interface RsServiceMaster { /** * Adds a slave service to a master service. The master service is responsible to handle them. * * @param slave the slave service to add to the master */ void addRsSlave(T slave); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/RsServiceRegistry.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.RawItem; import io.xeres.app.xrs.service.gxs.GxsRsService; import io.xeres.app.xrs.service.gxs.item.DynamicServiceType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.core.env.Environment; import org.springframework.core.type.filter.AssignableTypeFilter; import org.springframework.stereotype.Component; import java.lang.reflect.InvocationTargetException; import java.util.*; import static org.apache.commons.collections4.ListUtils.emptyIfNull; @Component public class RsServiceRegistry { private static final Logger log = LoggerFactory.getLogger(RsServiceRegistry.class); private static final String SERVICE_PACKAGE = "io.xeres.app.xrs.service"; private static final String RS_SERVICE_CLASS_SUFFIX = "RsService"; private final Set enabledServiceClasses = new HashSet<>(); private final Map services = new HashMap<>(); private final Map> masterServices = new HashMap<>(); private final Map>> itemClassesWaiting = new HashMap<>(); private final Map> itemClassesGxsWaiting = new HashMap<>(); private final Map> itemClasses = new HashMap<>(); public RsServiceRegistry(Environment environment) { var provider = new ClassPathScanningCandidateComponentProvider(false); provider.addIncludeFilter(new AssignableTypeFilter(RsService.class)); var scannedServiceClasses = provider.findCandidateComponents(SERVICE_PACKAGE); provider.resetFilters(false); provider.addIncludeFilter(new AssignableTypeFilter(Item.class)); var scannedItemClasses = provider.findCandidateComponents(SERVICE_PACKAGE); registerServices(environment, scannedServiceClasses); registerItems(scannedItemClasses); } /** * Records which services are enabled in the properties file. * * @param environment the environment * @param scannedServiceClasses the service classes */ private void registerServices(Environment environment, Set scannedServiceClasses) { for (var bean : scannedServiceClasses) { try { @SuppressWarnings("unchecked") var serviceClass = (Class) Class.forName(bean.getBeanClassName()); var serviceName = serviceClass.getSimpleName(); var propertyName = "xrs.service." + serviceName.substring(0, serviceName.length() - RS_SERVICE_CLASS_SUFFIX.length()).toLowerCase(Locale.ROOT) + ".enabled"; if (environment.getProperty(propertyName, Boolean.class, false)) { enabledServiceClasses.add(serviceName); } } catch (ClassNotFoundException e) { throw new RuntimeException(e); } } } /** * Adds all item classes, they will be enabled later when the service is confirmed to be enabled * * @param scannedItemClasses the item classes */ private void registerItems(Set scannedItemClasses) { for (var bean : scannedItemClasses) { Class itemClass = null; try { //noinspection unchecked itemClass = (Class) Class.forName(bean.getBeanClassName()); var item = (Item) itemClass.getConstructor().newInstance(); //noinspection StatementWithEmptyBody if (GxsGroupItem.class.isAssignableFrom(itemClass) || GxsMessageItem.class.isAssignableFrom(itemClass)) { // For GxsGroup and GxsMessage items, we ignore them because they can only be received within transactions // (but the real reason is that their subtype clashes with GxsExchange subtypes) } else if (DynamicServiceType.class.isAssignableFrom(itemClass)) { // For DynamicServiceType (mostly GxsExchange) items, we don't know their ServiceType yet because they are shared. itemClassesGxsWaiting.put(item.getSubType(), itemClass); } else { itemClassesWaiting.computeIfAbsent(item.getServiceType(), v -> new HashMap<>()).put(item.getSubType(), itemClass); } } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException | ClassNotFoundException e) { if (itemClass != null) { throw new IllegalArgumentException(itemClass.getSimpleName() + " requires a public constructor with no parameters"); } else { throw new RuntimeException(e); } } } } public List getServices() { return new ArrayList<>(services.values()); } public RsService getServiceFromType(int type) { return services.get(type); } public boolean registerService(RsService rsService) { var serviceType = rsService.getServiceType().getType(); if (!enabledServiceClasses.contains(rsService.getClass().getSimpleName())) { return false; // the service is disabled } services.put(serviceType, rsService); if (RsServiceSlave.class.isAssignableFrom(rsService.getClass())) { var master = ((RsServiceSlave) rsService).getMasterServiceType(); masterServices.computeIfAbsent(master.getType(), v -> new ArrayList<>()).add((RsServiceSlave) rsService); } if (GxsRsService.class.isAssignableFrom(rsService.getClass())) { itemClassesGxsWaiting.forEach((subType, itemClass) -> itemClasses.put(serviceType << 16 | subType, itemClass)); } else { var itemClassMap = itemClassesWaiting.remove(serviceType); if (itemClassMap != null) { itemClassMap.forEach((subType, itemClass) -> itemClasses.put(serviceType << 16 | subType, itemClass)); } } return true; } List getSlaves(RsService rsService) { if (!RsServiceMaster.class.isAssignableFrom(rsService.getClass())) { throw new IllegalArgumentException("Master service " + rsService + " doesn't implement RsServiceMaster interface"); } return emptyIfNull(masterServices.get(rsService.getServiceType().getType())); } /** * Builds an item. * * @param rawItem the {@link RawItem} to deserialize from * @return the {@link Item} * @see io.xeres.app.xrs.serialization.Serializer Serializer */ public Item buildIncomingItem(RawItem rawItem) { var version = rawItem.getPacketVersion(); var service = rawItem.getPacketService(); var subType = rawItem.getPacketSubType(); if (version == 2) { var itemClass = itemClasses.get(service << 16 | subType); if (itemClass != null) { try { var item = itemClass.getConstructor().newInstance(); if (DynamicServiceType.class.isAssignableFrom(item.getClass())) { ((DynamicServiceType) item).setServiceType(service); } return item; } catch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e) { log.error("Couldn't create item: {}", e.getMessage()); } } else { log.warn("Couldn't create item (service: {}, subtype: {}): no mapping found", service, subType); } } else { log.warn("Packet version {} is not supported", version); } return new DefaultItem(); // will just get disposed } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/RsServiceSlave.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; /** * This interface allows to mark a service as a slave of some master. * * @see RsServiceMaster */ public interface RsServiceSlave { /** * Registers this service as a slave of another service. * * @return the master service this service is slave of */ io.xeres.common.protocol.xrs.RsServiceType getMasterServiceType(); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/bandwidth/BandwidthRsService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.bandwidth; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.bandwidth.item.BandwidthAllowedItem; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.rest.statistics.DataCounterPeer; import io.xeres.common.rest.statistics.DataCounterStatisticsResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import static io.xeres.app.net.peer.PeerConnection.KEY_BANDWIDTH; import static io.xeres.common.protocol.xrs.RsServiceType.BANDWIDTH_CONTROL; @Component public class BandwidthRsService extends RsService { private static final Logger log = LoggerFactory.getLogger(BandwidthRsService.class); private static final double BANDWIDTH_UTILIZATION = 0.75; private final PeerConnectionManager peerConnectionManager; private long currentBandwidth; BandwidthRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager) { super(rsServiceRegistry); this.peerConnectionManager = peerConnectionManager; } @Override public RsServiceType getServiceType() { return BANDWIDTH_CONTROL; } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.HIGH; } @Override public void initialize() { Thread.ofVirtual().name("Bandwidth Finder").start(() -> { currentBandwidth = BandwidthUtils.findBandwidth(); log.info("Found bandwidth of {} bps", currentBandwidth); }); } @Override public void initialize(PeerConnection peerConnection) { peerConnection.schedule( () -> sendBandwidthCapabilities(peerConnection) , 10, TimeUnit.SECONDS ); } private void sendBandwidthCapabilities(PeerConnection peerConnection) { if (currentBandwidth != 0L) { log.debug("Sending Bandwidth of {} bit/s to peer {}", currentBandwidth, peerConnection); peerConnectionManager.writeItem(peerConnection, new BandwidthAllowedItem((long) (currentBandwidth * BANDWIDTH_UTILIZATION / 8)), this); // RS wants bytes/s, and it defaults to 75% of the bandwidth } } @Override public void handleItem(PeerConnection sender, Item item) { if (item instanceof BandwidthAllowedItem bandwidthAllowedItem) { log.debug("Allowed bandwidth for peer {}: {} bytes/s", sender, bandwidthAllowedItem.getAllowedBandwidth()); sender.putPeerData(KEY_BANDWIDTH, bandwidthAllowedItem.getAllowedBandwidth()); } } @Transactional(readOnly = true) public DataCounterStatisticsResponse getDataCounterStatistics() { List peers = new ArrayList<>(); peerConnectionManager.doForAllPeers(peerConnection -> peers.add(new DataCounterPeer(peerConnection.getLocation().getId(), peerConnection.getLocation().getProfile().getName() + "@" + peerConnection.getLocation().getSafeName(), peerConnection.getSentCounter(), peerConnection.getReceivedCounter())), null); return new DataCounterStatisticsResponse(peers); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/bandwidth/BandwidthUtils.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.bandwidth; import io.xeres.common.util.OsUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Comparator; import java.util.regex.Pattern; final class BandwidthUtils { private static final Logger log = LoggerFactory.getLogger(BandwidthUtils.class); private static final Pattern LINUX_BANDWIDTH_PATTERN = Pattern.compile("\\d+.", Pattern.DOTALL); private static final Pattern MACOS_BANDWIDTH_PATTERN = Pattern.compile("(\\d+)baseT"); private BandwidthUtils() { throw new UnsupportedOperationException("Utility class"); } /// Tries to find the maximum bandwidth of a host. /// /// Note: this doesn't take into account any possible router on the LAN. /// /// @return the maximum bandwidth in bps, or 0 if not found or not available public static long findBandwidth() { if (SystemUtils.IS_OS_WINDOWS) { var result = OsUtils.shellExecute("powershell.exe", "-Command", "(Get-Counter '\\Network Interface(*)\\Current Bandwidth').CounterSamples.CookedValue"); return searchBandwidthOnWindows(result); } else if (SystemUtils.IS_OS_LINUX) { // Get default interface var iface = OsUtils.shellExecute("sh", "-c", "ip route show default | awk '/default/ {print $5}'").trim(); if (!iface.isEmpty()) { var result = OsUtils.shellExecute("cat", "/sys/class/net/" + iface + "/speed"); return searchBandwidthOnLinux(result); } } else if (SystemUtils.IS_OS_MAC) { // Use en0 as default, or detect default interface var result = OsUtils.shellExecute("ifconfig", "en0"); return searchBandwidthOnMac(result); } return 0L; } static long searchBandwidthOnWindows(String input) { if (!StringUtils.isBlank(input)) { try { return input.lines() .map(Long::parseLong) .max(Comparator.naturalOrder()) .orElse(0L); } catch (NumberFormatException e) { log.error("Couldn't parse windows interface bandwidth output: {}", e.getMessage()); } } return 0L; } static long searchBandwidthOnLinux(String input) { if (!StringUtils.isBlank(input) && LINUX_BANDWIDTH_PATTERN.matcher(input).matches()) { return Long.parseLong(input.trim()) * 1_000_000L; // Convert Mbps to bps } return 0L; } static long searchBandwidthOnMac(String input) { if (!StringUtils.isBlank(input)) { // Find "media:" line and extract speed var mediaLine = input.lines() .filter(line -> line.contains("media:")) .findFirst(); if (mediaLine.isPresent()) { var matcher = MACOS_BANDWIDTH_PATTERN.matcher(mediaLine.get()); if (matcher.find()) { return Long.parseLong(matcher.group(1)) * 1_000_000L; // Convert Mbps to bps } } } return 0L; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/bandwidth/item/BandwidthAllowedItem.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.bandwidth.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.protocol.xrs.RsServiceType; public class BandwidthAllowedItem extends Item { @RsSerialized(tlvType = TlvType.INT_BANDWIDTH) private int allowedBandwidth; @SuppressWarnings("unused") public BandwidthAllowedItem() { } public BandwidthAllowedItem(long allowedBandwidth) { this.allowedBandwidth = toUnsignedIntSaturated(allowedBandwidth); } @Override public int getServiceType() { return RsServiceType.BANDWIDTH_CONTROL.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return ItemPriority.REALTIME.getPriority(); } public long getAllowedBandwidth() { return Integer.toUnsignedLong(allowedBandwidth); } private static int toUnsignedIntSaturated(long value) { if (value >= 4_294_967_296L) { return Integer.MIN_VALUE; // Maximum value of an unsigned int } else { return (int) (value & 0xFFFFFFFFL); } } @Override public BandwidthAllowedItem clone() { return (BandwidthAllowedItem) super.clone(); } @Override public String toString() { return "BandwidthAllowedItem{" + "allowedBandwidth=" + allowedBandwidth + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/board/BoardRsService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.board; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.gxs.*; import io.xeres.app.database.repository.GxsBoardGroupRepository; import io.xeres.app.database.repository.GxsBoardMessageRepository; import io.xeres.app.database.repository.GxsCommentMessageRepository; import io.xeres.app.database.repository.GxsVoteMessageRepository; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.notification.board.BoardNotificationService; import io.xeres.app.util.GxsUtils; import io.xeres.app.xrs.common.CommentMessageItem; import io.xeres.app.xrs.common.VoteMessageItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.board.item.BoardGroupItem; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.app.xrs.service.gxs.GxsAuthentication; import io.xeres.app.xrs.service.gxs.GxsHelperService; import io.xeres.app.xrs.service.gxs.GxsRsService; import io.xeres.app.xrs.service.gxs.GxsTransactionManager; import io.xeres.app.xrs.service.gxs.item.GxsSyncMessageRequestItem; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.gxs.GxsGroupConstants; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.image.ImageUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; import static io.xeres.app.util.GxsUtils.IMAGE_MAX_INPUT_SIZE; import static io.xeres.app.util.GxsUtils.MAXIMUM_GXS_MESSAGE_SIZE; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_AUTHOR; import static io.xeres.common.protocol.xrs.RsServiceType.GXS_BOARDS; @Component public class BoardRsService extends GxsRsService { private static final int IMAGE_MESSAGE_WIDTH = 640; private static final int IMAGE_MESSAGE_HEIGHT = 480; private static final Duration SYNCHRONIZATION_INITIAL_DELAY = Duration.ofMinutes(1); private static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1); private final GxsBoardGroupRepository gxsBoardGroupRepository; private final GxsBoardMessageRepository gxsBoardMessageRepository; private final GxsHelperService gxsHelperService; private final DatabaseSessionManager databaseSessionManager; private final BoardNotificationService boardNotificationService; private final GxsCommentMessageRepository gxsCommentMessageRepository; private final GxsVoteMessageRepository gxsVoteMessageRepository; public BoardRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, GxsBoardGroupRepository gxsBoardGroupRepository, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsBoardMessageRepository gxsBoardMessageRepository, GxsHelperService gxsHelperService, BoardNotificationService boardNotificationService, GxsCommentMessageRepository gxsCommentMessageRepository, GxsVoteMessageRepository gxsVoteMessageRepository) { super(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService); this.gxsBoardGroupRepository = gxsBoardGroupRepository; this.gxsBoardMessageRepository = gxsBoardMessageRepository; this.gxsHelperService = gxsHelperService; this.databaseSessionManager = databaseSessionManager; this.boardNotificationService = boardNotificationService; this.gxsCommentMessageRepository = gxsCommentMessageRepository; this.gxsVoteMessageRepository = gxsVoteMessageRepository; } @Override public RsServiceType getServiceType() { return GXS_BOARDS; } @Override protected GxsAuthentication getAuthentication() { // Anybody can post on a board return new GxsAuthentication.Builder() .withRequirements(EnumSet.of(ROOT_NEEDS_AUTHOR, CHILD_NEEDS_AUTHOR)) .build(); } @Override public void initialize(PeerConnection peerConnection) { super.initialize(peerConnection); peerConnection.scheduleWithFixedDelay( () -> syncMessages(peerConnection), SYNCHRONIZATION_INITIAL_DELAY.toSeconds(), SYNCHRONIZATION_DELAY.toSeconds(), TimeUnit.SECONDS ); } @Override protected void syncMessages(PeerConnection recipient) { try (var ignored = new DatabaseSession(databaseSessionManager)) { // Request new messages for all subscribed groups findAllSubscribedGroups().forEach(boardGroupItem -> { var request = new GxsSyncMessageRequestItem(boardGroupItem.getGxsId(), gxsHelperService.getLastPeerMessagesUpdate(recipient.getLocation(), boardGroupItem.getGxsId(), getServiceType()), ChronoUnit.YEARS.getDuration()); log.debug("Asking {} for new messages in {} ({}) since {}, last updated: {}", recipient, boardGroupItem.getName(), boardGroupItem.getGxsId(), log.isDebugEnabled() ? Instant.ofEpochSecond(request.getLimit()) : null, log.isDebugEnabled() ? Instant.ofEpochSecond(request.getLastUpdated()) : null); peerConnectionManager.writeItem(recipient, request, this); }); } } // XXX: don't forget about the comments and votes! @Override protected List onAvailableGroupListRequest(PeerConnection recipient) { return findAllSubscribedGroups(); } @Override protected List onGroupListRequest(Set ids) { return findAllGroups(ids); } @Override protected Set onAvailableGroupListResponse(Map ids) { // We want new boards as well as updated ones var existingMap = findAllGroups(ids.keySet()).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished)); ids.entrySet().removeIf(gxsIdInstantEntry -> { var existing = existingMap.get(gxsIdInstantEntry.getKey()); return existing != null && !gxsIdInstantEntry.getValue().isAfter(existing); }); return ids.keySet(); } @Override protected boolean onGroupReceived(BoardGroupItem item) { log.debug("Received {}, saving/updating...", item); return true; } @Override protected void onGroupsSaved(List items) { boardNotificationService.addOrUpdateGroups(items); } @Override protected List onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since) { return findAllMessagesInGroupSince(gxsId, since); // Don't return old messages, they're unimportant } @Override protected List onMessageListRequest(GxsId gxsId, Set msgIds) { return findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds); } @Transactional(readOnly = true) @Override protected List onMessageListResponse(GxsId gxsId, Set msgIds) { var existing = findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds).stream() .map(GxsMessageItem::getMsgId) .collect(Collectors.toSet()); msgIds.removeAll(existing); return msgIds.stream().toList(); } @Override protected boolean onMessageReceived(BoardMessageItem item) { if (item.hasImage()) { // Set the dimensions in the database so that images don't cause layout // problems when displaying them in long lists without fixed size. var dimension = ImageUtils.getImageDimension(new ByteArrayInputStream(item.getImage())); if (dimension != null) { item.setImageWidth((int) dimension.getWidth()); item.setImageHeight((int) dimension.getHeight()); } } log.debug("Received message {}, saving...", item); return true; } @Override protected void onMessagesSaved(List items) { boardNotificationService.addOrUpdateMessages(items); } @Override protected boolean onCommentReceived(CommentMessageItem item) { return true; } @Override protected void onCommentsSaved(List items) { // XXX: boardNotificationService.addBoardComments(items); } @Override protected boolean onVoteReceived(VoteMessageItem item) { return true; } @Override protected void onVotesSaved(List items) { // XXX: boardNotificationService.addBoardVotes(items); } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { super.handleItem(sender, item); // This is required for the @Transactional to work } public Optional findById(long id) { return gxsBoardGroupRepository.findById(id); } public List findAllGroups() { return gxsBoardGroupRepository.findAll(); } public List findAllSubscribedGroups() { return gxsBoardGroupRepository.findAllBySubscribedIsTrue(); } public List findAllGroups(Set gxsIds) { return gxsBoardGroupRepository.findAllByGxsIdIn(gxsIds); } public List findAllMessagesInGroupSince(GxsId gxsId, Instant since) { return gxsBoardMessageRepository.findAllByGxsIdAndPublishedAfterAndHiddenFalse(gxsId, since); } public List findAllMessages(GxsId gxsId, Set msgIds) { return gxsBoardMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(gxsId, msgIds); } public List findAllMessagesIncludingOlds(GxsId gxsId, Set msgIds) { return gxsBoardMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds); } public List findAllMessagesVotesAndCommentsIncludingOlds(GxsId gxsId, Set msgIds) { var messages = findAllMessagesIncludingOlds(gxsId, msgIds); var votes = gxsVoteMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds); var comments = gxsCommentMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds); return Stream.of(messages.stream(), votes.stream(), comments.stream()) .flatMap(stream -> stream) .collect(Collectors.toList()); } @Transactional public Page findAllMessages(long groupId, Pageable pageable) { var boardGroup = gxsBoardGroupRepository.findById(groupId).orElseThrow(); return gxsBoardMessageRepository.findAllByGxsIdAndHiddenFalse(boardGroup.getGxsId(), pageable); } public Optional findMessageById(long id) { return gxsBoardMessageRepository.findById(id); } /** * Finds all messages. Prefer the other variants as this one is slower. * * @param msgIds the list of message ids * @return the messages */ public List findAllMessages(Set msgIds) { return gxsBoardMessageRepository.findAllByMsgIdInAndHiddenFalse(msgIds); } public List findAllMessages(long groupId, Set msgIds) { var boardGroup = gxsBoardGroupRepository.findById(groupId).orElseThrow(); return gxsBoardMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(boardGroup.getGxsId(), msgIds); } public int getUnreadCount(long groupId) { var boardGroupItem = gxsBoardGroupRepository.findById(groupId).orElseThrow(); return gxsBoardMessageRepository.countUnreadMessages(boardGroupItem.getGxsId()); } @Transactional public long createBoardGroup(GxsId identity, String name, String description, MultipartFile imageFile) throws IOException { var group = createGroup(name, false); group.setDescription(description); if (imageFile != null && !imageFile.isEmpty()) { group.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE)); } if (identity != null) { group.setAuthorGxsId(identity); } group.setCircleType(GxsCircleType.PUBLIC); // XXX: implement "YOUR_FRIENDS_ONLY"? but based on trust instead group.setSignatureFlags(Set.of(GxsSignatureFlags.NONE_REQUIRED, GxsSignatureFlags.AUTHENTICATION_REQUIRED)); group.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC)); //boardGroupItem.setInternalCircle(); XXX: needs that for "YOUR_FRIENDS_ONLY". check what RS does for createBoardV2(), how it is called group.setSubscribed(true); group = saveBoard(group); boardNotificationService.addOrUpdateGroups(List.of(group)); return group.getId(); } @Transactional public void updateBoardGroup(long groupId, String name, String description, MultipartFile imageFile, boolean updateImage) throws IOException { var boardGroupItem = gxsBoardGroupRepository.findById(groupId).orElseThrow(); boardGroupItem.setName(name); boardGroupItem.setDescription(description); if (updateImage) { if (imageFile != null) { if (!imageFile.isEmpty()) { boardGroupItem.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE)); } } else { boardGroupItem.setImage(null); // Remove the image } } boardGroupItem = saveBoard(boardGroupItem); boardNotificationService.addOrUpdateGroups(List.of(boardGroupItem)); } @Transactional public BoardGroupItem saveBoard(BoardGroupItem boardGroupItem) { signGroupIfNeeded(boardGroupItem); var savedBoard = gxsBoardGroupRepository.save(boardGroupItem); gxsHelperService.setLastServiceGroupsUpdateNow(GXS_BOARDS); peerConnectionManager.doForAllPeers(this::sendSyncNotification, this); return savedBoard; } @Transactional public long createBoardMessage(IdentityGroupItem author, long boardId, String title, String content, String link, MultipartFile imageFile) throws IOException { int size = title.length(); var group = gxsBoardGroupRepository.findById(boardId).orElseThrow(); var builder = new MessageBuilder(group, author, title); if (StringUtils.isNotBlank(content)) { builder.getMessageItem().setContent(content); size += content.length(); } if (StringUtils.isNotEmpty(link)) { builder.getMessageItem().setLink(link); size += link.length(); } if (imageFile != null && !imageFile.isEmpty()) { if (imageFile.getSize() >= IMAGE_MAX_INPUT_SIZE) { throw new IllegalArgumentException("Board message image size is bigger than " + IMAGE_MAX_INPUT_SIZE + " bytes"); } var image = ImageUtils.limitMaximumImageSize(ImageIO.read(imageFile.getInputStream()), IMAGE_MESSAGE_WIDTH * IMAGE_MESSAGE_HEIGHT); var imageOut = new ByteArrayOutputStream(); if (!ImageUtils.writeImageAsJpeg(image, MAXIMUM_GXS_MESSAGE_SIZE - size, imageOut)) { throw new IllegalArgumentException("Couldn't write the image. Unsupported format?"); } var data = imageOut.toByteArray(); builder.getMessageItem().setImage(data); size += data.length; } if (size >= MAXIMUM_GXS_MESSAGE_SIZE) { throw new IllegalArgumentException("The message is too large. Reduce the content and/or the image."); } var boardMessageItem = saveMessage(builder); boardNotificationService.addOrUpdateMessages(List.of(boardMessageItem)); peerConnectionManager.doForAllPeers(this::sendSyncNotification, this); return boardMessageItem.getId(); } private BoardMessageItem saveMessage(MessageBuilder messageBuilder) { var boardMessageItem = messageBuilder.build(); boardMessageItem.setId(gxsBoardMessageRepository.findByGxsIdAndMsgId(boardMessageItem.getGxsId(), boardMessageItem.getMsgId()).orElse(boardMessageItem).getId()); // XXX: not sure we should be able to overwrite a message. in which case is it correct? maybe throw? var savedMessage = gxsBoardMessageRepository.save(boardMessageItem); markOriginalMessageAsHidden(List.of(savedMessage)); var boardGroupItem = gxsBoardGroupRepository.findByGxsId(boardMessageItem.getGxsId()).orElseThrow(); boardGroupItem.setLastUpdated(Instant.now()); gxsBoardGroupRepository.save(boardGroupItem); return savedMessage; } @Transactional public void subscribeToBoardGroup(long id) { var boardGroupItem = findById(id).orElseThrow(); boardGroupItem.setSubscribed(true); gxsHelperService.setLastServiceGroupsUpdateNow(GXS_BOARDS); // We don't need to send a sync notify here because it's not urgent. // The peers will poll normally to show if there's a new group available. } @Transactional public void unsubscribeFromBoardGroup(long id) { var boardGroupItem = findById(id).orElseThrow(); boardGroupItem.setSubscribed(false); } @Transactional public void setMessageReadState(long messageId, boolean read) { var message = gxsBoardMessageRepository.findById(messageId).orElseThrow(); message.setRead(read); var group = gxsBoardGroupRepository.findByGxsId(message.getGxsId()).orElseThrow(); boardNotificationService.setMessageReadState(group.getId(), message.getId(), read); } @Transactional public void setAllGroupMessagesReadState(long groupId, boolean read) { var group = gxsBoardGroupRepository.findById(groupId).orElseThrow(); gxsBoardMessageRepository.setAllGroupMessagesReadState(group.getGxsId(), read); boardNotificationService.setGroupMessagesReadState(groupId, read); } @Override public void shutdown() { boardNotificationService.shutdown(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/board/item/BoardGroupItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.board.item; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.id.GxsId; import io.xeres.common.util.ByteUnitUtils; import jakarta.persistence.Entity; import org.apache.commons.lang3.ArrayUtils; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.deserialize; import static io.xeres.app.xrs.serialization.Serializer.serialize; import static io.xeres.app.xrs.serialization.TlvType.STR_DESCR; @Entity(name = "board_group") public class BoardGroupItem extends GxsGroupItem { private String description; private byte[] image; public BoardGroupItem() { // Needed for JPA } public BoardGroupItem(GxsId gxsId, String name) { setGxsId(gxsId); setName(name); updatePublished(); } @Override public int getSubType() { return 2; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public boolean hasImage() { return image != null; } public byte[] getImage() { return image; } public void setImage(byte[] image) { if (ArrayUtils.isNotEmpty(image)) { this.image = image; } else { this.image = null; } } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, STR_DESCR, description); if (hasImage()) { size += serialize(buf, TlvType.IMAGE, image); } return size; } @Override public void readDataObject(ByteBuf buf) { description = (String) deserialize(buf, STR_DESCR); if (buf.isReadable()) { setImage((byte[]) deserialize(buf, TlvType.IMAGE)); } } @Override public BoardGroupItem clone() { return (BoardGroupItem) super.clone(); } @Override public String toString() { return "BoardGroupItem{" + super.toString() + ", image=" + (image != null ? ("yes, " + ByteUnitUtils.fromBytes(image.length)) : "no") + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/board/item/BoardMessageItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.board.item; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.util.ByteUnitUtils; import jakarta.persistence.Entity; import jakarta.persistence.Transient; import org.apache.commons.lang3.ArrayUtils; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.deserialize; import static io.xeres.app.xrs.serialization.Serializer.serialize; import static io.xeres.app.xrs.serialization.TlvType.STR_LINK; import static io.xeres.app.xrs.serialization.TlvType.STR_MSG; @Entity(name = "board_message") public class BoardMessageItem extends GxsMessageItem { @Transient public static final BoardMessageItem EMPTY = new BoardMessageItem(); private String link; private String content; private byte[] image; private int imageWidth; private int imageHeight; private boolean read; public BoardMessageItem() { // Needed for JPA } public BoardMessageItem(GxsId gxsId, MsgId msgId, String name) { setGxsId(gxsId); setMsgId(msgId); setName(name); updatePublished(); } @Override public int getSubType() { return 3; } public String getLink() { return link; } public void setLink(String link) { this.link = link; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public boolean hasImage() { return image != null; } public byte[] getImage() { return image; } public void setImage(byte[] image) { if (ArrayUtils.isNotEmpty(image)) { this.image = image; } else { this.image = null; } } public int getImageWidth() { return imageWidth; } public void setImageWidth(int imageWidth) { this.imageWidth = imageWidth; } public int getImageHeight() { return imageHeight; } public void setImageHeight(int imageHeight) { this.imageHeight = imageHeight; } public boolean isRead() { return read; } public void setRead(boolean read) { this.read = read; } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, STR_LINK, link); size += serialize(buf, STR_MSG, content); if (hasImage()) { size += serialize(buf, TlvType.IMAGE, image); } return size; } @Override public void readDataObject(ByteBuf buf) { link = (String) deserialize(buf, STR_LINK); content = (String) deserialize(buf, STR_MSG); if (buf.isReadable()) { setImage((byte[]) deserialize(buf, TlvType.IMAGE)); } } @Override public BoardMessageItem clone() { return (BoardMessageItem) super.clone(); } @Override public String toString() { return "BoardMessageItem{" + super.toString() + ", image=" + (image != null ? ("yes, " + ByteUnitUtils.fromBytes(image.length)) : "no") + ", read=" + read + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/channel/ChannelRsService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.channel; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.gxs.*; import io.xeres.app.database.repository.GxsChannelGroupRepository; import io.xeres.app.database.repository.GxsChannelMessageRepository; import io.xeres.app.database.repository.GxsCommentMessageRepository; import io.xeres.app.database.repository.GxsVoteMessageRepository; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.notification.channel.ChannelNotificationService; import io.xeres.app.util.GxsUtils; import io.xeres.app.xrs.common.CommentMessageItem; import io.xeres.app.xrs.common.FileItem; import io.xeres.app.xrs.common.VoteMessageItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.channel.item.ChannelGroupItem; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.app.xrs.service.gxs.GxsAuthentication; import io.xeres.app.xrs.service.gxs.GxsHelperService; import io.xeres.app.xrs.service.gxs.GxsRsService; import io.xeres.app.xrs.service.gxs.GxsTransactionManager; import io.xeres.app.xrs.service.gxs.item.GxsSyncMessageRequestItem; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.gxs.GxsGroupConstants; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.image.ImageUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; import static io.xeres.app.util.GxsUtils.IMAGE_MAX_INPUT_SIZE; import static io.xeres.app.util.GxsUtils.MAXIMUM_GXS_MESSAGE_SIZE; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_PUBLISH; import static io.xeres.common.protocol.xrs.RsServiceType.GXS_CHANNELS; @Component public class ChannelRsService extends GxsRsService { private static final int IMAGE_MESSAGE_WIDTH = 128; // XXX: how much?! it's some aspect ratio thing, see below private static final int IMAGE_MESSAGE_HEIGHT = 128; // XXX: ditto... private static final Duration SYNCHRONIZATION_INITIAL_DELAY = Duration.ofSeconds(90); private static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1); private final GxsChannelGroupRepository gxsChannelGroupRepository; private final GxsChannelMessageRepository gxsChannelMessageRepository; private final GxsHelperService gxsHelperService; private final DatabaseSessionManager databaseSessionManager; private final ChannelNotificationService channelNotificationService; private final GxsCommentMessageRepository gxsCommentMessageRepository; private final GxsVoteMessageRepository gxsVoteMessageRepository; public ChannelRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsHelperService gxsHelperService, GxsChannelGroupRepository gxsChannelGroupRepository, GxsChannelMessageRepository gxsChannelMessageRepository, GxsHelperService gxsHelperService1, DatabaseSessionManager databaseSessionManager1, ChannelNotificationService channelNotificationService, GxsCommentMessageRepository gxsCommentMessageRepository, GxsVoteMessageRepository gxsVoteMessageRepository) { super(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService); this.gxsChannelGroupRepository = gxsChannelGroupRepository; this.gxsChannelMessageRepository = gxsChannelMessageRepository; this.gxsHelperService = gxsHelperService1; this.databaseSessionManager = databaseSessionManager1; this.channelNotificationService = channelNotificationService; this.gxsCommentMessageRepository = gxsCommentMessageRepository; this.gxsVoteMessageRepository = gxsVoteMessageRepository; } // XXX: don't forget about the comments and votes! @Override public RsServiceType getServiceType() { return GXS_CHANNELS; } @Override protected GxsAuthentication getAuthentication() { // Only the channel owner can write new posts return new GxsAuthentication.Builder() .withRequirements(EnumSet.of(ROOT_NEEDS_PUBLISH, CHILD_NEEDS_AUTHOR)) .build(); } @Override public void initialize(PeerConnection peerConnection) { super.initialize(peerConnection); peerConnection.scheduleWithFixedDelay( () -> syncMessages(peerConnection), SYNCHRONIZATION_INITIAL_DELAY.toSeconds(), SYNCHRONIZATION_DELAY.toSeconds(), TimeUnit.SECONDS ); } @Override protected void syncMessages(PeerConnection recipient) { try (var ignored = new DatabaseSession(databaseSessionManager)) { // Request new messages for all subscribed groups findAllSubscribedGroups().forEach(channelGroupItem -> { var request = new GxsSyncMessageRequestItem(channelGroupItem.getGxsId(), gxsHelperService.getLastPeerMessagesUpdate(recipient.getLocation(), channelGroupItem.getGxsId(), getServiceType()), ChronoUnit.YEARS.getDuration()); log.debug("Asking {} for new messages in {} ({}) since {}, last updated: {}", recipient, channelGroupItem.getName(), request.getGxsId(), log.isDebugEnabled() ? Instant.ofEpochSecond(request.getLimit()) : null, log.isDebugEnabled() ? Instant.ofEpochSecond(request.getLastUpdated()) : null); peerConnectionManager.writeItem(recipient, request, this); }); } } @Override protected List onAvailableGroupListRequest(PeerConnection recipient) { return findAllSubscribedGroups(); } @Override protected List onGroupListRequest(Set ids) { return findAllGroups(ids); } @Override protected Set onAvailableGroupListResponse(Map ids) { // We want new channels as well as updated ones var existingMap = findAllGroups(ids.keySet()).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished)); ids.entrySet().removeIf(gxsIdInstantEntry -> { var existing = existingMap.get(gxsIdInstantEntry.getKey()); return existing != null && !gxsIdInstantEntry.getValue().isAfter(existing); }); return ids.keySet(); } @Override protected boolean onGroupReceived(ChannelGroupItem item) { log.debug("Received {}, saving/updating...", item); return true; } @Override protected void onGroupsSaved(List items) { channelNotificationService.addOrUpdateGroups(items); } @Override protected List onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since) { return findAllMessagesInGroupSince(gxsId, since); // Don't return old messages, they're unimportant } @Override protected List onMessageListRequest(GxsId gxsId, Set msgIds) { return findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds); } @Override protected List onMessageListResponse(GxsId gxsId, Set msgIds) { var existing = findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds).stream() .map(GxsMessageItem::getMsgId) .collect(Collectors.toSet()); msgIds.removeAll(existing); return msgIds.stream().toList(); } @Override protected boolean onMessageReceived(ChannelMessageItem item) { if (item.hasImage()) { // Set the dimensions in the database so that images don't cause layout // problems when displaying them in long lists without fixed size. var dimension = ImageUtils.getImageDimension(new ByteArrayInputStream(item.getImage())); if (dimension != null) { item.setImageWidth((int) dimension.getWidth()); item.setImageHeight((int) dimension.getHeight()); } } log.debug("Received message {}, saving...", item); return true; } @Override protected void onMessagesSaved(List items) { channelNotificationService.addOrUpdateMessages(items); } @Override protected boolean onCommentReceived(CommentMessageItem item) { return true; } @Override protected void onCommentsSaved(List items) { // XXX: channelNotificationService.addChannelComments(items); } @Override protected boolean onVoteReceived(VoteMessageItem item) { return true; } @Override protected void onVotesSaved(List items) { // XXX: channelNotificationService.addChannelVotes(items); } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { super.handleItem(sender, item); // This is required for the @Transactional to work } public Optional findById(long id) { return gxsChannelGroupRepository.findById(id); } public List findAllGroups() { return gxsChannelGroupRepository.findAll(); } public List findAllSubscribedGroups() { return gxsChannelGroupRepository.findAllBySubscribedIsTrue(); } public List findAllGroups(Set gxsIds) { return gxsChannelGroupRepository.findAllByGxsIdIn(gxsIds); } public List findAllMessagesInGroupSince(GxsId gxsId, Instant since) { return gxsChannelMessageRepository.findAllByGxsIdAndPublishedAfterAndHiddenFalse(gxsId, since); } public List findAllMessages(GxsId gxsId, Set msgIds) { return gxsChannelMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(gxsId, msgIds); } public List findAllMessagesIncludingOlds(GxsId gxsId, Set msgIds) { return gxsChannelMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds); } public List findAllMessagesVotesAndCommentsIncludingOlds(GxsId gxsId, Set msgIds) { var messages = findAllMessagesIncludingOlds(gxsId, msgIds); var votes = gxsVoteMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds); var comments = gxsCommentMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds); return Stream.of(messages.stream(), votes.stream(), comments.stream()) .flatMap(stream -> stream) .collect(Collectors.toList()); } /** * Finds all messages. Prefer the other variants as this one is slower. * * @param msgIds the list of message ids * @return the messages */ public List findAllMessages(Set msgIds) { return gxsChannelMessageRepository.findAllByMsgIdInAndHiddenFalse(msgIds); } public List findAllMessages(long groupId, Set msgIds) { var channelGroup = gxsChannelGroupRepository.findById(groupId).orElseThrow(); return gxsChannelMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(channelGroup.getGxsId(), msgIds); } @Transactional public Page findAllMessages(long groupId, Pageable pageable) { var channelGroup = gxsChannelGroupRepository.findById(groupId).orElseThrow(); return gxsChannelMessageRepository.findAllByGxsIdAndHiddenFalse(channelGroup.getGxsId(), pageable); } public Optional findMessageById(long id) { return gxsChannelMessageRepository.findById(id); } public int getUnreadCount(long groupId) { var channelGroupItem = gxsChannelGroupRepository.findById(groupId).orElseThrow(); return gxsChannelMessageRepository.countUnreadMessages(channelGroupItem.getGxsId()); } @Transactional public long createChannelGroup(GxsId identity, String name, String description, MultipartFile imageFile) throws IOException { var group = createGroup(name, true); group.setDescription(description); if (imageFile != null && !imageFile.isEmpty()) { group.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE)); } if (identity != null) { group.setAuthorGxsId(identity); } group.setCircleType(GxsCircleType.PUBLIC); // XXX: implement "YOUR_FRIENDS_ONLY"? but based on trust instead group.setSignatureFlags(Set.of(GxsSignatureFlags.NONE_REQUIRED, GxsSignatureFlags.AUTHENTICATION_REQUIRED)); // XXX: correct? group.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC)); //channelGroupItem.setInternalCircle(); XXX: needs that for "YOUR_FRIENDS_ONLY". check what RS does for createBoardV2(), how it is called group.setSubscribed(true); group = saveChannel(group); channelNotificationService.addOrUpdateGroups(List.of(group)); return group.getId(); } @Transactional public void updateChannelGroup(long groupId, String name, String description, MultipartFile imageFile, boolean updateImage) throws IOException { var channelGroupItem = gxsChannelGroupRepository.findById(groupId).orElseThrow(); channelGroupItem.setName(name); channelGroupItem.setDescription(description); if (updateImage) { if (imageFile != null) { if (!imageFile.isEmpty()) { channelGroupItem.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE)); } } else { channelGroupItem.setImage(null); // Remove the image } } channelGroupItem = saveChannel(channelGroupItem); channelNotificationService.addOrUpdateGroups(List.of(channelGroupItem)); } private ChannelGroupItem saveChannel(ChannelGroupItem channelGroupItem) { signGroupIfNeeded(channelGroupItem); var savedChannel = gxsChannelGroupRepository.save(channelGroupItem); gxsHelperService.setLastServiceGroupsUpdateNow(GXS_CHANNELS); peerConnectionManager.doForAllPeers(this::sendSyncNotification, this); return savedChannel; } @Transactional public long createChannelMessage(IdentityGroupItem author, long channelId, String title, String content, MultipartFile imageFile, List files, long originalId) throws IOException { int size = title.length(); var group = gxsChannelGroupRepository.findById(channelId).orElseThrow(); var builder = new MessageBuilder(group, author, title); if (StringUtils.isNotBlank(content)) { builder.getMessageItem().setContent(content); size += content.length(); } // XXX: for the image, there are 3 aspect ratio: 1:1, 3:4 and 16:9 (and auto, which picks up the closest one of the original image?) if (imageFile != null && !imageFile.isEmpty()) { if (imageFile.getSize() >= IMAGE_MAX_INPUT_SIZE) { throw new IllegalArgumentException("Board message image size is bigger than " + IMAGE_MAX_INPUT_SIZE + " bytes"); } var image = ImageUtils.limitMaximumImageSize(ImageIO.read(imageFile.getInputStream()), IMAGE_MESSAGE_WIDTH * IMAGE_MESSAGE_HEIGHT); var imageOut = new ByteArrayOutputStream(); if (!ImageUtils.writeImageAsJpeg(image, MAXIMUM_GXS_MESSAGE_SIZE - size, imageOut)) { throw new IllegalArgumentException("Couldn't write the image. Unsupported format?"); } var data = imageOut.toByteArray(); builder.getMessageItem().setImage(data); size += data.length; } builder.getMessageItem().setFiles(files); if (originalId != 0L) { builder.originalMsgId(gxsChannelMessageRepository.findById(originalId).orElseThrow().getMsgId()); } if (size >= MAXIMUM_GXS_MESSAGE_SIZE) { throw new IllegalArgumentException("The message is too large. Reduce the content."); } var channelMessageItem = saveMessage(builder); channelNotificationService.addOrUpdateMessages(List.of(channelMessageItem)); peerConnectionManager.doForAllPeers(this::sendSyncNotification, this); return channelMessageItem.getId(); } private ChannelMessageItem saveMessage(MessageBuilder messageBuilder) { var channelMessageItem = messageBuilder.build(); channelMessageItem.setId(gxsChannelMessageRepository.findByGxsIdAndMsgId(channelMessageItem.getGxsId(), channelMessageItem.getMsgId()).orElse(channelMessageItem).getId()); // XXX: not sure we should be able to overwrite a message. in which case is it correct? maybe throw? var savedMessage = gxsChannelMessageRepository.save(channelMessageItem); markOriginalMessageAsHidden(List.of(savedMessage)); var channelGroupItem = gxsChannelGroupRepository.findByGxsId(channelMessageItem.getGxsId()).orElseThrow(); channelGroupItem.setLastUpdated(Instant.now()); gxsChannelGroupRepository.save(channelGroupItem); return savedMessage; } @Transactional public void subscribeToChannelGroup(long id) { var channelGroupItem = findById(id).orElseThrow(); channelGroupItem.setSubscribed(true); gxsHelperService.setLastServiceGroupsUpdateNow(GXS_CHANNELS); // We don't need to send a sync notify here because it's not urgent. // The peers will poll normally to show if there's a new group available. } @Transactional public void unsubscribeFromChannelGroup(long id) { var channelGroupItem = findById(id).orElseThrow(); channelGroupItem.setSubscribed(false); } @Transactional public void setMessageReadState(long messageId, boolean read) { var message = gxsChannelMessageRepository.findById(messageId).orElseThrow(); message.setRead(read); var group = gxsChannelGroupRepository.findByGxsId(message.getGxsId()).orElseThrow(); channelNotificationService.setMessageReadState(group.getId(), message.getId(), read); } @Transactional public void setAllGroupMessagesReadState(long groupId, boolean read) { var group = gxsChannelGroupRepository.findById(groupId).orElseThrow(); gxsChannelMessageRepository.setAllGroupMessagesReadState(group.getGxsId(), read); channelNotificationService.setGroupMessagesReadState(groupId, read); } @Override public void shutdown() { channelNotificationService.shutdown(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/channel/item/ChannelGroupItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.channel.item; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.id.GxsId; import io.xeres.common.util.ByteUnitUtils; import jakarta.persistence.Entity; import org.apache.commons.lang3.ArrayUtils; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.deserialize; import static io.xeres.app.xrs.serialization.Serializer.serialize; import static io.xeres.app.xrs.serialization.TlvType.STR_DESCR; @Entity(name = "channel_group") public class ChannelGroupItem extends GxsGroupItem { private String description; private byte[] image; public ChannelGroupItem() { // Needed for JPA } public ChannelGroupItem(GxsId gxsId, String name) { setGxsId(gxsId); setName(name); updatePublished(); } @Override public int getSubType() { return 2; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public boolean hasImage() { return image != null; } public byte[] getImage() { return image; } public void setImage(byte[] image) { if (ArrayUtils.isNotEmpty(image)) { this.image = image; } else { this.image = null; } } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, STR_DESCR, description); size += serialize(buf, TlvType.IMAGE, image); // Images are not optional for channels (but can be empty) return size; } @Override public void readDataObject(ByteBuf buf) { description = (String) deserialize(buf, STR_DESCR); setImage((byte[]) deserialize(buf, TlvType.IMAGE)); } @Override public ChannelGroupItem clone() { return (ChannelGroupItem) super.clone(); } @Override public String toString() { return "ChannelGroupItem{" + super.toString() + ", image=" + (image != null ? ("yes, " + ByteUnitUtils.fromBytes(image.length)) : "no") + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/channel/item/ChannelMessageItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.channel.item; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.common.FileItem; import io.xeres.app.xrs.common.FileSet; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.util.ByteUnitUtils; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.Transient; import org.apache.commons.lang3.ArrayUtils; import java.util.ArrayList; import java.util.List; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.deserialize; import static io.xeres.app.xrs.serialization.Serializer.serialize; import static io.xeres.app.xrs.serialization.TlvType.FILE_SET; import static io.xeres.app.xrs.serialization.TlvType.STR_MSG; @Entity(name = "channel_message") public class ChannelMessageItem extends GxsMessageItem { @Transient public static final ChannelMessageItem EMPTY = new ChannelMessageItem(); private String content; @ElementCollection private List files = new ArrayList<>(); private String title; // Optional field related to fileset. Not used in practice? private String comment; // Optional field related to fileset. Not used in practice? private byte[] image; private int imageWidth; private int imageHeight; private boolean read; public ChannelMessageItem() { // Needed for JPA } public ChannelMessageItem(GxsId gxsId, MsgId msgId, String name) { setGxsId(gxsId); setMsgId(msgId); setName(name); updatePublished(); } @Override public int getSubType() { return 3; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public List getFiles() { return files; } public void setFiles(List files) { this.files = files; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getComment() { return comment; } public void setComment(String comment) { this.comment = comment; } public boolean hasImage() { return image != null; } public boolean hasFiles() { return files != null && !files.isEmpty(); } public byte[] getImage() { return image; } public void setImage(byte[] image) { if (ArrayUtils.isNotEmpty(image)) { this.image = image; } else { this.image = null; } } public int getImageWidth() { return imageWidth; } public void setImageWidth(int imageWidth) { this.imageWidth = imageWidth; } public int getImageHeight() { return imageHeight; } public void setImageHeight(int imageHeight) { this.imageHeight = imageHeight; } public boolean isRead() { return read; } public void setRead(boolean read) { this.read = read; } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, STR_MSG, content); size += serialize(buf, FILE_SET, new FileSet(files, title, comment)); size += serialize(buf, TlvType.IMAGE, image); // Images are not optional for channels (but can be empty) return size; } @Override public void readDataObject(ByteBuf buf) { content = (String) deserialize(buf, STR_MSG); var fileSet = (FileSet) deserialize(buf, FILE_SET); files = fileSet.fileItems(); title = fileSet.title(); comment = fileSet.comment(); setImage((byte[]) deserialize(buf, TlvType.IMAGE)); } @Override public ChannelMessageItem clone() { return (ChannelMessageItem) super.clone(); } @Override public String toString() { return "ChannelMessageItem{" + super.toString() + ", image=" + (image != null ? ("yes, " + ByteUnitUtils.fromBytes(image.length)) : "no") + ", read=" + read + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/ChatBacklogService.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.app.database.model.chat.ChatBacklog; import io.xeres.app.database.model.chat.ChatRoomBacklog; import io.xeres.app.database.model.chat.DistantChatBacklog; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.repository.*; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import org.springframework.data.domain.Limit; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; import java.util.List; @Service public class ChatBacklogService { private static final Duration MAXIMUM_DURATION = Duration.ofDays(31); private final ChatBacklogRepository chatBacklogRepository; private final ChatRoomBacklogRepository chatRoomBacklogRepository; private final DistantChatBacklogRepository distantChatBacklogRepository; private final LocationRepository locationRepository; private final ChatRoomRepository chatRoomRepository; private final GxsIdentityRepository gxsIdentityRepository; ChatBacklogService(ChatBacklogRepository chatBacklogRepository, ChatRoomBacklogRepository chatRoomBacklogRepository, DistantChatBacklogRepository distantChatBacklogRepository, LocationRepository locationRepository, ChatRoomRepository chatRoomRepository, GxsIdentityRepository gxsIdentityRepository) { this.chatBacklogRepository = chatBacklogRepository; this.chatRoomBacklogRepository = chatRoomBacklogRepository; this.distantChatBacklogRepository = distantChatBacklogRepository; this.locationRepository = locationRepository; this.chatRoomRepository = chatRoomRepository; this.gxsIdentityRepository = gxsIdentityRepository; } @Transactional public void storeIncomingChatRoomMessage(long chatRoomId, GxsId from, String nickname, String message) { var chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow(); chatRoomBacklogRepository.save(new ChatRoomBacklog(chatRoom, from, nickname, message)); } @Transactional public void storeOutgoingChatRoomMessage(long chatRoomId, String nickname, String message) { var chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow(); chatRoomBacklogRepository.save(new ChatRoomBacklog(chatRoom, nickname, message)); } @Transactional(readOnly = true) public List getChatRoomMessages(long chatRoomId, Instant from, int maxLines) { var chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow(); return chatRoomBacklogRepository.findAllByRoomAndCreatedAfterOrderByCreatedDesc(chatRoom, from, Limit.of(maxLines)).reversed(); } @Transactional public void deleteChatRoomMessages(long chatRoomId) { var chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow(); chatRoomBacklogRepository.deleteAllByRoom(chatRoom); } @Transactional public void storeIncomingMessage(LocationIdentifier from, String message) { var location = locationRepository.findByLocationIdentifier(from).orElseThrow(); chatBacklogRepository.save(new ChatBacklog(location, false, message)); } @Transactional public void storeOutgoingMessage(LocationIdentifier to, String message) { var location = locationRepository.findByLocationIdentifier(to).orElseThrow(); chatBacklogRepository.save(new ChatBacklog(location, true, message)); } public List getMessages(Location with, Instant from, int maxLines) { return chatBacklogRepository.findAllByLocationAndCreatedAfterOrderByCreatedDesc(with, from, Limit.of(maxLines)).reversed(); } @Transactional public void deleteMessages(Location of) { chatBacklogRepository.deleteAllByLocation(of); } @Transactional public void storeIncomingDistantMessage(GxsId from, String message) { var gxsId = gxsIdentityRepository.findByGxsId(from).orElseThrow(); distantChatBacklogRepository.save(new DistantChatBacklog(gxsId, false, message)); } @Transactional public void storeOutgoingDistantMessage(GxsId to, String message) { var gxsId = gxsIdentityRepository.findByGxsId(to).orElseThrow(); distantChatBacklogRepository.save(new DistantChatBacklog(gxsId, true, message)); } public List getDistantMessages(IdentityGroupItem with, Instant from, int maxLines) { return distantChatBacklogRepository.findAllByIdentityGroupItemAndCreatedAfterOrderByCreatedDesc(with, from, Limit.of(maxLines)).reversed(); } @Transactional public void deleteDistantMessages(IdentityGroupItem of) { distantChatBacklogRepository.deleteAllByIdentityGroupItem(of); } @Transactional public void cleanup() { chatBacklogRepository.deleteAllByCreatedBefore(Instant.now().minus(MAXIMUM_DURATION)); chatRoomBacklogRepository.deleteAllByCreatedBefore(Instant.now().minus(MAXIMUM_DURATION)); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/ChatFlags.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.common.annotation.RsDeprecated; public enum ChatFlags { /** * Set for all direct, distant and chat room messages. */ PRIVATE, /** * Set when requesting an avatar. The message must be empty * and set to private too. Xeres uses it to get the remote icon * when opening a private/distant chat window. */ REQUEST_AVATAR, /** * No longer used. */ @RsDeprecated CONTAINS_AVATAR, /** * Set if we changed our avatar (not used by Xeres). */ AVATAR_AVAILABLE, /** * Used to send status strings in a ChatStatusItem (currently not used by Xeres). */ CUSTOM_STATE, /** * Set for broadcast messages. */ PUBLIC, /** * Used to request a custom string in a ChatStatusItem (currently not used by Xeres). */ REQUEST_CUSTOM_STATE, /** * Used to tell we have or changed a status string in a ChatStatusItem * (currently not used by Xeres). */ CUSTOM_STATE_AVAILABLE, /** * Used to tell that this is a large message that is split and needs * to be reassembled. */ PARTIAL_MESSAGE, /** * Always set for ChatRoomMessageItem. */ LOBBY, /** * No longer used. Uses Gxs Tunnels instead. */ @RsDeprecated CLOSING_DISTANT_CONNECTION, /** * No longer used. Uses turtle instead. */ @RsDeprecated ACK_DISTANT_CONNECTION, /** * No longer used. */ @RsDeprecated KEEP_ALIVE, /** * Set for distant chats to refuse a connection. Currently not used by Xeres. */ CONNECTION_REFUSED } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/ChatRoom.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.service.chat.item.VisibleChatRoomInfo; import io.xeres.common.id.GxsId; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.chat.ChatRoomInfo; import io.xeres.common.message.chat.RoomType; import java.time.Duration; import java.time.Instant; import java.util.EnumSet; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class ChatRoom { private static final long USER_INACTIVITY_TIMEOUT = Duration.ofMinutes(3).toSeconds(); private final long id; private final String name; private final String topic; private final Set participatingLocations = ConcurrentHashMap.newKeySet(); private GxsId ownGxsId; private final Map users = new ConcurrentHashMap<>(); private final int userCount; private Instant lastActivity; private Instant lastSeen = Instant.now(); private final RoomType type; private final boolean signed; private final MessageCache messageCache = new MessageCache(); private LocationIdentifier virtualPeerId; // XXX: check if we need that... private int connectionChallengeCount; private Instant lastConnectionChallenge = Instant.EPOCH; private boolean joinedRoomPacketSent; private Instant lastKeepAlivePacket = Instant.EPOCH; private final Set previouslyKnownLocations = ConcurrentHashMap.newKeySet(); public ChatRoom(long id, String name, String topic, RoomType type, int userCount, boolean isSigned) { this.id = id; this.name = name; this.topic = topic; this.type = type; this.userCount = userCount; // XXX: use that if available, other gxsId.size() which is more precise signed = isSigned; } /** * Get as a RoomInfo structure, used for displaying in the UI * * @return a RoomInfo */ public ChatRoomInfo getAsRoomInfo() { return new ChatRoomInfo( id, name, type, topic, getUserCount(), signed ); } /** * Get as a VisibleChatRoomInfo, used for serialization as RS protocol * * @return a VisibleChatRoomInfo */ public VisibleChatRoomInfo getAsVisibleChatRoomInfo() { return new VisibleChatRoomInfo( id, name, topic, getUserCount(), getRoomFlags() ); } private int getUserCount() { var size = users.size(); return size > 0 ? size : userCount; } public long getId() { return id; } public String getName() { return name; } public String getTopic() { return topic; } public boolean hasParticipatingLocations() { return !participatingLocations.isEmpty(); } public Set getParticipatingLocations() { return participatingLocations; } public boolean addParticipatingLocation(Location location) { return participatingLocations.add(location); } public void removeParticipatingLocation(Location location) { participatingLocations.remove(location); } public void recordPreviouslyKnownLocation(Location location) { previouslyKnownLocations.add(location.getLocationIdentifier()); } public boolean isPreviouslyKnownLocation(Location location) { return previouslyKnownLocations.contains(location.getLocationIdentifier()); } public void setOwnGxsId(GxsId gxsId) { ownGxsId = gxsId; } public void addUser(GxsId user) { users.put(user, Instant.now().getEpochSecond()); } public void userActivity(GxsId user) { users.replace(user, Instant.now().getEpochSecond()); } public void removeUser(GxsId user) { users.remove(user); } public Set getExpiredUsers() { var now = Instant.now().getEpochSecond(); Set expiredUsers = new HashSet<>(); users.forEach((user, timestamp) -> { if (timestamp + USER_INACTIVITY_TIMEOUT < now && !user.equals(ownGxsId)) { expiredUsers.add(user); } }); return expiredUsers; } public void clearUsers() { users.clear(); } public Instant getLastActivity() { return lastActivity; } public void updateActivity() { lastActivity = Instant.now(); } public Instant getLastSeen() { return lastSeen; } public void updateLastSeen() { lastSeen = Instant.now(); } public LocationIdentifier getVirtualPeerId() { return virtualPeerId; } public int getConnectionChallengeCount() { return connectionChallengeCount; } public int getConnectionChallengeCountAndIncrease() { return connectionChallengeCount++; } public void resetConnectionChallengeCount() { connectionChallengeCount = 0; lastConnectionChallenge = Instant.now(); } public Instant getLastConnectionChallenge() { return lastConnectionChallenge; } public boolean isJoinedRoomPacketSent() { return joinedRoomPacketSent; } public void setJoinedRoomPacketSent(boolean joinedRoomPacketSent) { this.joinedRoomPacketSent = joinedRoomPacketSent; } public void setLastKeepAlivePacket(Instant lastKeepAlivePacket) { this.lastKeepAlivePacket = lastKeepAlivePacket; } public Instant getLastKeepAlivePacket() { return lastKeepAlivePacket; } public boolean isPublic() { return type == RoomType.PUBLIC; } public boolean isPrivate() { return type == RoomType.PRIVATE; } public boolean isSigned() { return signed; } public long getNewMessageId() { return messageCache.getNewMessageId(); } public void incrementConnectionChallengeCount() { connectionChallengeCount++; } MessageCache getMessageCache() { return messageCache; } public Set getRoomFlags() { var roomFlags = EnumSet.noneOf(RoomFlags.class); if (type == RoomType.PUBLIC) { roomFlags.add(RoomFlags.PUBLIC); } if (signed) { roomFlags.add(RoomFlags.PGP_SIGNED); } return roomFlags; } @Override public String toString() { return "ChatRoom{" + "id=" + Id.toStringLowerCase(id) + ", name='" + name + '\'' + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/ChatRoomService.java ================================================ package io.xeres.app.xrs.service.chat; import io.xeres.app.database.repository.ChatRoomRepository; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; /** * Helper service to manage chat room subscriptions and so on. */ @Service class ChatRoomService { private final ChatRoomRepository chatRoomRepository; public ChatRoomService(ChatRoomRepository chatRoomRepository) { this.chatRoomRepository = chatRoomRepository; } @Transactional public io.xeres.app.database.model.chat.ChatRoom createChatRoom(io.xeres.app.xrs.service.chat.ChatRoom chatRoom, IdentityGroupItem identityGroupItem) { return chatRoomRepository.save(io.xeres.app.database.model.chat.ChatRoom.createChatRoom(chatRoom, identityGroupItem)); } @Transactional public io.xeres.app.database.model.chat.ChatRoom subscribeToChatRoomAndJoin(io.xeres.app.xrs.service.chat.ChatRoom chatRoom, IdentityGroupItem identityGroupItem) { var entity = chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom.getId(), identityGroupItem).orElseGet(() -> createChatRoom(chatRoom, identityGroupItem)); entity.setSubscribed(true); entity.setJoined(true); return entity; } @Transactional public io.xeres.app.database.model.chat.ChatRoom unsubscribeFromChatRoomAndLeave(long chatRoomId, IdentityGroupItem identityGroupItem) { var foundRoom = chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoomId, identityGroupItem); foundRoom.ifPresent(subscribedRoom -> { subscribedRoom.setSubscribed(false); subscribedRoom.setJoined(false); subscribedRoom.clearLocations(); }); return foundRoom.orElse(null); } public void deleteChatRoom(long chatRoomId, IdentityGroupItem identityGroupItem) { chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoomId, identityGroupItem).ifPresent(chatRoomRepository::delete); } public List getAllChatRoomsPendingToSubscribe() { return chatRoomRepository.findAllBySubscribedTrueAndJoinedFalse(); // Remember joined is set to false on startup } public void markAllChatRoomsAsLeft() { chatRoomRepository.putAllJoinedToFalse(); } @Transactional public void syncParticipatingLocations(io.xeres.app.xrs.service.chat.ChatRoom chatRoom) { var room = chatRoomRepository.findByRoomId(chatRoom.getId()).orElseThrow(); room.clearLocations(); chatRoom.getParticipatingLocations().forEach(room::addLocation); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/ChatRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.app.application.events.PeerConnectedEvent; import io.xeres.app.application.events.PeerDisconnectedEvent; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.*; import io.xeres.app.service.script.ScriptService; import io.xeres.app.xrs.common.Signature; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemUtils; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.chat.item.*; import io.xeres.app.xrs.service.gxstunnel.GxsTunnelRsClient; import io.xeres.app.xrs.service.gxstunnel.GxsTunnelRsService; import io.xeres.app.xrs.service.gxstunnel.GxsTunnelStatus; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.Id; import io.xeres.common.id.Identifier; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.*; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.ExecutorUtils; import io.xeres.common.util.SecureRandomUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import static io.xeres.common.location.Availability.AVAILABLE; import static io.xeres.common.location.Availability.OFFLINE; import static io.xeres.common.message.MessagePath.*; import static io.xeres.common.message.MessageType.*; import static io.xeres.common.protocol.xrs.RsServiceType.CHAT; import static io.xeres.common.protocol.xrs.RsServiceType.GXS_TUNNELS; import static io.xeres.common.tray.TrayNotificationType.BROADCAST; @Component public class ChatRsService extends RsService implements GxsTunnelRsClient { private static final Logger log = LoggerFactory.getLogger(ChatRsService.class); /** * Time between housekeeping runs to clean up the message cache and so on. */ private static final Duration HOUSEKEEPING_DELAY = Duration.ofSeconds(10); /** * Maximum time to keep message records. */ private static final Duration KEEP_MESSAGE_RECORD_MAX = Duration.ofMinutes(20); /** * Maximum of chat rooms accepted by a peer. * XXX: should be incremented one day */ private static final int CHATROOM_LIST_MAX = 50; /** * When to refresh nearby chat rooms by asking peers. */ private static final Duration CHATROOM_NEARBY_REFRESH_INITIAL_MIN = Duration.ofSeconds(0); private static final Duration CHATROOM_NEARBY_REFRESH_INITIAL_MAX = Duration.ofSeconds(5); private static final Duration CHATROOM_NEARBY_REFRESH = Duration.ofMinutes(2); /** * When to remove nearby chat rooms when no peers have them anymore. */ private static final Duration CHATROOM_NEARBY_TIMEOUT = Duration.ofMinutes(3); /** * Time after which a keep alive packet is sent. */ private static final Duration KEEPALIVE_DELAY = Duration.ofMinutes(2); /** * Minimum time between connection challenges. */ private static final Duration CONNECTION_CHALLENGE_MIN_DELAY = Duration.ofSeconds(15); /** * Minimum number of connection challenge counts before one * can be sent. */ private static final int CONNECTION_CHALLENGE_COUNT_MIN = 20; /** * Maximum time difference allowed for messages in the past (this doesn't * account for KEEP_MESSAGE_RECORD_MAX for the total). */ private static final Duration TIME_DRIFT_PAST_MAX = Duration.ofSeconds(100); /** * Maximum time difference allowed for messages in the future. */ private static final Duration TIME_DRIFT_FUTURE_MAX = Duration.ofMinutes(10); /** * Content sent with a typing notification. Note that Retroshare displays * the text directly. */ private static final String MESSAGE_TYPING_CONTENT = "is typing..."; private static final int KEY_PARTIAL_MESSAGE_LIST = 1; /** * Retroshare puts some limit here. */ private static final int AVATAR_SIZE_MAX = 32767; private static final int DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID = 0xa0001; private final Map chatRooms = new ConcurrentHashMap<>(); private final Map availableChatRooms = new ConcurrentHashMap<>(); private final Map invitedChatRooms = new ConcurrentHashMap<>(); private final Map distantChatContacts = new ConcurrentHashMap<>(); @Override public RsServiceType getMasterServiceType() { return GXS_TUNNELS; } private enum Invitation { PLAIN, FROM_CHALLENGE } private final RsServiceRegistry rsServiceRegistry; private final LocationService locationService; private final PeerConnectionManager peerConnectionManager; private final MessageService messageService; private final IdentityService identityService; private final DatabaseSessionManager databaseSessionManager; private final IdentityManager identityManager; private final UiBridgeService uiBridgeService; private final ChatRoomService chatRoomService; private final ChatBacklogService chatBacklogService; private final UnHtmlService unHtmlService; private final ScriptService scriptService; private ScheduledExecutorService executorService; private GxsTunnelRsService gxsTunnelRsService; ChatRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, LocationService locationService, MessageService messageService, IdentityService identityService, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, UiBridgeService uiBridgeService, ChatRoomService chatRoomService, ChatBacklogService chatBacklogService, UnHtmlService unHtmlService, ScriptService scriptService) { super(rsServiceRegistry); this.locationService = locationService; this.peerConnectionManager = peerConnectionManager; this.messageService = messageService; this.identityService = identityService; this.databaseSessionManager = databaseSessionManager; this.identityManager = identityManager; this.uiBridgeService = uiBridgeService; this.chatRoomService = chatRoomService; this.chatBacklogService = chatBacklogService; this.rsServiceRegistry = rsServiceRegistry; this.unHtmlService = unHtmlService; this.scriptService = scriptService; } @Override public RsServiceType getServiceType() { return CHAT; } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.HIGH; } @Override public int onGxsTunnelInitialization(GxsTunnelRsService gxsTunnelRsService) { this.gxsTunnelRsService = gxsTunnelRsService; return DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID; } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { if (item instanceof ChatRoomListRequestItem) { handleChatRoomListRequestItem(sender); } else if (item instanceof ChatRoomListItem chatRoomListItem) { handleChatRoomListItem(sender, chatRoomListItem); } else if (item instanceof ChatMessageItem chatMessageItem) { handleChatMessageItem(sender, chatMessageItem); } else if (item instanceof ChatRoomMessageItem chatRoomMessageItem) { handleChatRoomMessageItem(sender, chatRoomMessageItem); } else if (item instanceof ChatStatusItem chatStatusItem) { handleChatStatusItem(sender, chatStatusItem); } else if (item instanceof ChatRoomInviteItem chatRoomInviteItem) { handleChatRoomInviteItem(sender, chatRoomInviteItem); } else if (item instanceof ChatRoomEventItem chatRoomEventItem) { handleChatRoomEventItem(sender, chatRoomEventItem); } else if (item instanceof ChatRoomConnectChallengeItem chatRoomConnectChallengeItem) { handleChatRoomConnectChallengeItem(sender, chatRoomConnectChallengeItem); } else if (item instanceof ChatRoomUnsubscribeItem chatRoomUnsubscribeItem) { handleChatRoomUnsubscribeItem(sender, chatRoomUnsubscribeItem); } else if (item instanceof ChatAvatarItem chatAvatarItem) { handleChatAvatarItem(sender, chatAvatarItem); } else if (item instanceof ChatRoomInviteOldItem chatRoomInviteOldItem) { handleChatRoomInviteOldItem(sender, chatRoomInviteOldItem); } } @Override public void onGxsTunnelDataReceived(Location tunnelId, byte[] data) { var destination = gxsTunnelRsService.getGxsFromTunnel(tunnelId); if (destination == null) { log.error("Cannot get tunnel info from {}", tunnelId); return; } var distantLocation = distantChatContacts.computeIfAbsent(destination, _ -> new DistantLocation(tunnelId, destination)); var item = ItemUtils.deserializeItem(data, rsServiceRegistry); switch (item) { case ChatMessageItem chatMessageItem -> handleChatMessageItem(distantLocation, chatMessageItem); case ChatAvatarItem chatAvatarItem -> handleChatAvatarItem(distantLocation, chatAvatarItem); case ChatStatusItem chatStatusItem -> handleChatStatusItem(distantLocation, chatStatusItem); default -> log.error("Unknown item {}", item); } } @Override public boolean onGxsTunnelDataAuthorization(GxsId sender, Location tunnelId, boolean clientSide) { //noinspection IfStatementWithIdenticalBranches if (clientSide) { return true; } // XXX: add code for refusing distant chats return true; } @Override public void onGxsTunnelStatusChanged(Location tunnelId, GxsId destination, GxsTunnelStatus status) { switch (status) { case UNKNOWN -> log.warn("Don't know how to handle {}", status); case CAN_TALK -> messageService.sendToConsumers(chatDistantDestination(), CHAT_AVAILABILITY, destination, AVAILABLE); case TUNNEL_DOWN, REMOTELY_CLOSED -> messageService.sendToConsumers(chatDistantDestination(), CHAT_AVAILABILITY, destination, OFFLINE); } } @Override @SuppressWarnings("java:S1905") public void initialize() { try (var ignored = new DatabaseSession(databaseSessionManager)) { chatRoomService.markAllChatRoomsAsLeft(); subscribeToAllSavedRooms(); } executorService = ExecutorUtils.createFixedRateExecutor(this::manageChatRooms, getInitPriority().getMaxTime() + HOUSEKEEPING_DELAY.toSeconds() / 2, HOUSEKEEPING_DELAY.toSeconds()); } @Override public void shutdown() { chatRooms.forEach((_, chatRoom) -> { chatRoomService.syncParticipatingLocations(chatRoom); sendChatRoomEvent(chatRoom, ChatRoomEvent.PEER_LEFT); }); chatBacklogService.cleanup(); } @Override public void cleanup() { ExecutorUtils.cleanupExecutor(executorService); } @Override public void initialize(PeerConnection peerConnection) { peerConnection.scheduleAtFixedRate( () -> askForNearbyChatRooms(peerConnection), ThreadLocalRandom.current().nextLong(CHATROOM_NEARBY_REFRESH_INITIAL_MIN.toSeconds(), CHATROOM_NEARBY_REFRESH_INITIAL_MAX.toSeconds() + 1), CHATROOM_NEARBY_REFRESH.toSeconds(), TimeUnit.SECONDS ); } private void manageChatRooms() { chatRooms.forEach((_, chatRoom) -> { log.debug("Cleanup of room {}", chatRoom); // Remove old messages chatRoom.getMessageCache().purge(); // Remove inactive gxsIds chatRoom.getExpiredUsers().forEach(user -> { chatRoom.removeUser(user); sendChatRoomTimeoutToConsumers(chatRoom.getId(), user, !chatRoom.hasParticipatingLocations()); }); sendKeepAliveIfNeeded(chatRoom); sendConnectionChallengeIfNeeded(chatRoom); sendJoinEventIfNeeded(chatRoom); }); removeUnseenRooms(); } /** * Removes rooms that haven't been seen for a while. */ private void removeUnseenRooms() { var now = Instant.now(); if (availableChatRooms.entrySet().removeIf(entry -> entry.getValue().getLastSeen().plus(CHATROOM_NEARBY_TIMEOUT).isBefore(now))) { refreshChatRoomsInClients(); } } /** * Asks a peer for the list of chat rooms he's subscribed to. * * @param peerConnection the peer */ private void askForNearbyChatRooms(PeerConnection peerConnection) { log.debug("Asking for nearby chat rooms..."); peerConnectionManager.writeItem(peerConnection, new ChatRoomListRequestItem(), this); } /** * Sends a keep alive event to the room. Allows other users to know we're in it. * * @param chatRoom the chat room */ private void sendKeepAliveIfNeeded(ChatRoom chatRoom) { var now = Instant.now(); if (Duration.between(chatRoom.getLastKeepAlivePacket(), now).compareTo(KEEPALIVE_DELAY) > 0) { log.debug("Sending keepalive event to chatroom {}", chatRoom); sendChatRoomEvent(chatRoom, ChatRoomEvent.KEEP_ALIVE); chatRoom.setLastKeepAlivePacket(now); } } /** * Sends a connection challenge. Can be used to know if the peer is relaying a private room that we're also subscribed to. * * @param chatRoom the chat room */ private void sendConnectionChallengeIfNeeded(ChatRoom chatRoom) { if (chatRoom.getConnectionChallengeCountAndIncrease() > CONNECTION_CHALLENGE_COUNT_MIN && Duration.between(chatRoom.getLastConnectionChallenge(), Instant.now()).compareTo(CONNECTION_CHALLENGE_MIN_DELAY) > 0) { chatRoom.resetConnectionChallengeCount(); var recentMessage = chatRoom.getMessageCache().getRecentMessage(); if (recentMessage == 0) { log.debug("No message in cache to send connection challenge to room {}. Not enough activity?", chatRoom); return; } // Send connection challenge to all connected friends log.debug("Sending connection challenge for room {}", chatRoom); peerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, new ChatRoomConnectChallengeItem(peerConnection.getLocation().getLocationIdentifier(), chatRoom.getId(), recentMessage), this), this); } } /** * Sends a join event so others can know we joined the chat room. * * @param chatRoom the chat room */ private void sendJoinEventIfNeeded(ChatRoom chatRoom) { if (!chatRoom.isJoinedRoomPacketSent() && chatRoom.hasParticipatingLocations()) { sendChatRoomEvent(chatRoom, ChatRoomEvent.PEER_JOINED); chatRoom.setJoinedRoomPacketSent(true); } } /** * Subscribes to all rooms that are saved in the database. */ private void subscribeToAllSavedRooms() { log.debug("Subscribing to all saved rooms..."); chatRoomService.getAllChatRoomsPendingToSubscribe().forEach(savedRoom -> { var chatRoom = new ChatRoom( savedRoom.getRoomId(), savedRoom.getName(), savedRoom.getTopic(), savedRoom.getFlags().contains(RoomFlags.PUBLIC) ? RoomType.PUBLIC : RoomType.PRIVATE, 1, savedRoom.getFlags().contains(RoomFlags.PGP_SIGNED) ); savedRoom.getLocations().forEach(chatRoom::recordPreviouslyKnownLocation); availableChatRooms.put(chatRoom.getId(), chatRoom); joinChatRoom(chatRoom.getId()); }); refreshChatRoomsInClients(); } private ChatRoomLists buildChatRoomLists() { var chatRoomLists = new ChatRoomLists(); chatRooms.forEach((_, chatRoom) -> chatRoomLists.addSubscribed(chatRoom.getAsRoomInfo())); availableChatRooms.forEach((_, chatRoom) -> chatRoomLists.addAvailable(chatRoom.getAsRoomInfo())); invitedChatRooms.forEach((_, chatRoom) -> { if (chatRoom.isPrivate()) // Public rooms can be invited to too, so skip them here { chatRoomLists.addAvailable(chatRoom.getAsRoomInfo()); } }); return chatRoomLists; } public ChatRoomContext getChatRoomContext() { try (var ignored = new DatabaseSession(databaseSessionManager)) { var ownIdentity = identityService.getOwnIdentity(); return new ChatRoomContext(buildChatRoomLists(), new ChatRoomUser(ownIdentity.getName(), ownIdentity.getGxsId(), ownIdentity.getId())); } } /** * Handles the reception of the list of chat room the peer is subscribed to. * * @param peerConnection the peer * @param item the ChatRoomListItem */ private void handleChatRoomListItem(PeerConnection peerConnection, ChatRoomListItem item) { log.debug("Received chat room list from {}: {}", peerConnection, item); if (item.getChatRooms().size() > CHATROOM_LIST_MAX) { log.warn("Location {} is sending a chat room list of {} items, which is bigger than the allowed {}", peerConnection, item.getChatRooms().size(), CHATROOM_LIST_MAX); } item.getChatRooms().stream() .limit(CHATROOM_LIST_MAX) .forEach(itemRoom -> { var chatRoom = availableChatRooms.getOrDefault(itemRoom.getId(), new ChatRoom( itemRoom.getId(), itemRoom.getName(), itemRoom.getTopic(), itemRoom.getFlags().contains(RoomFlags.PUBLIC) ? RoomType.PUBLIC : RoomType.PRIVATE, itemRoom.getCount(), // XXX: we should update current chatroom with max(current_count, remote_count) itemRoom.getFlags().contains(RoomFlags.PGP_SIGNED))); // If we're subscribed to the chat room but the friend is not participating, invite him if (chatRoom.addParticipatingLocation(peerConnection.getLocation()) && chatRooms.containsKey(chatRoom.getId())) { inviteLocationToChatRoom(peerConnection.getLocation(), chatRoom, Invitation.PLAIN); } updateRooms(chatRoom); chatRoomService.getAllChatRoomsPendingToSubscribe().stream() .filter(pendingChatRoom -> pendingChatRoom.getRoomId() == chatRoom.getId()) .findFirst() .ifPresent(pendingChatRoom -> joinChatRoom(pendingChatRoom.getRoomId())); }); refreshChatRoomsInClients(); } private void updateRooms(ChatRoom chatRoom) { chatRoom.updateLastSeen(); availableChatRooms.put(chatRoom.getId(), chatRoom); chatRooms.replace(chatRoom.getId(), chatRoom); } private void refreshChatRoomsInClients() { messageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_LIST, buildChatRoomLists()); } private void handleChatRoomListRequestItem(PeerConnection peerConnection) { var chatRoomListItem = new ChatRoomListItem(chatRooms.values().stream() .filter(chatRoom -> chatRoom.isPublic() || chatRoom.isPreviouslyKnownLocation(peerConnection.getLocation()) || chatRoom.getParticipatingLocations().contains(peerConnection.getLocation())) .map(ChatRoom::getAsVisibleChatRoomInfo) .toList()); log.debug("Received chat room list request from {}, sending back {}", peerConnection, chatRoomListItem); peerConnectionManager.writeItem(peerConnection, chatRoomListItem, this); } private void handleChatRoomMessageItem(PeerConnection peerConnection, ChatRoomMessageItem item) { log.debug("Received chat room message from peer {}: {}", peerConnection, item); if (!validateExpiration(item.getSendTime())) { log.warn("Received chat room message from peer {} failed time validation, dropping", peerConnection); } if (!validateAndBounceItem(peerConnection, item)) { return; } var chatRoom = chatRooms.get(item.getRoomId()); // And display the message for us var user = item.getSignature().getGxsId(); chatRoom.userActivity(user); var message = parseIncomingText(item.getMessage()); chatBacklogService.storeIncomingChatRoomMessage(item.getRoomId(), user, item.getSenderNickname(), message); scriptService.sendEvent("chatRoomMessage", Map.of( "roomId", item.getRoomId(), "gxsId", user, "nickname", item.getSenderNickname(), "content", message)); sendChatRoomMessageToConsumers(item.getRoomId(), user, item.getSenderNickname(), message); chatRoom.incrementConnectionChallengeCount(); } private void handleChatRoomEventItem(PeerConnection peerConnection, ChatRoomEventItem item) { log.debug("Received chat room event item from peer {}: {}", peerConnection, item); if (!validateExpiration(item.getSendTime())) { log.warn("Received chat room event from peer {} failed time validation, dropping", peerConnection); } if (!validateAndBounceItem(peerConnection, item)) { return; } // XXX: add routing clue var chatRoom = chatRooms.get(item.getRoomId()); var user = item.getSignature().getGxsId(); if (item.getEventType() == ChatRoomEvent.PEER_LEFT.getCode()) { chatRoom.removeUser(user); scriptService.sendEvent("chatRoomLeave", Map.of( "roomId", item.getRoomId(), "gxsId", user, "nickname", item.getSenderNickname() )); sendChatRoomEventToConsumers(item.getRoomId(), CHAT_ROOM_USER_LEAVE, user, item.getSenderNickname()); } else if (item.getEventType() == ChatRoomEvent.PEER_JOINED.getCode()) { chatRoom.addUser(user); scriptService.sendEvent("chatRoomJoin", Map.of( "roomId", item.getRoomId(), "gxsId", user, "nickname", item.getSenderNickname() )); sendChatRoomEventToConsumers(item.getRoomId(), CHAT_ROOM_USER_JOIN, user, item.getSenderNickname(), identityManager.getGxsGroup(peerConnection, user)); chatRoom.setLastKeepAlivePacket(Instant.EPOCH); // send a keep alive event to the participant so that he knows we are in the room } else if (item.getEventType() == ChatRoomEvent.KEEP_ALIVE.getCode()) { chatRoom.addUser(user); // KEEP_ALIVE is also used to add users sendChatRoomEventToConsumers(item.getRoomId(), CHAT_ROOM_USER_KEEP_ALIVE, user, item.getSenderNickname(), identityManager.getGxsGroup(peerConnection, user)); } else if (item.getEventType() == ChatRoomEvent.PEER_STATUS.getCode()) { chatRoom.userActivity(user); sendChatRoomTypingNotificationToConsumers(item.getRoomId(), user, item.getSenderNickname()); } } private void sendChatRoomEventToConsumers(long roomId, MessageType messageType, GxsId gxsId, String nickname, IdentityGroupItem identityGroupItem) { var chatRoomUserEvent = new ChatRoomUserEvent(gxsId, nickname, identityGroupItem != null ? identityGroupItem.getId() : 0L); messageService.sendToConsumers(chatRoomDestination(), messageType, roomId, chatRoomUserEvent); } private void sendChatRoomEventToConsumers(long roomId, MessageType messageType, GxsId gxsId, String nickname) { sendChatRoomEventToConsumers(roomId, messageType, gxsId, nickname, null); } private void sendChatRoomTypingNotificationToConsumers(long roomId, GxsId gxsId, String nickname) { var chatRoomMessage = new ChatRoomMessage(nickname, gxsId, null); messageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_TYPING_NOTIFICATION, roomId, chatRoomMessage); } private void sendChatRoomTimeoutToConsumers(long roomId, GxsId gxsId, boolean split) { var chatRoomTimeoutEvent = new ChatRoomTimeoutEvent(gxsId, split); messageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_USER_TIMEOUT, roomId, chatRoomTimeoutEvent); } private void sendChatRoomMessageToConsumers(long roomId, GxsId gxsId, String nickname, String content) { var chatRoomMessage = new ChatRoomMessage(nickname, gxsId, content); messageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_MESSAGE, roomId, chatRoomMessage); } private void sendInviteToClient(LocationIdentifier locationIdentifier, long roomId, String roomName, String roomTopic) { if (invitedChatRooms.containsKey(roomId)) { return; // Don't show multiple requesters } var chatRoomInvite = new ChatRoomInviteEvent(locationIdentifier.toString(), roomName, roomTopic); messageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_INVITE, roomId, chatRoomInvite); } @SuppressWarnings("BooleanMethodIsAlwaysInverted") private boolean validateAndBounceItem(PeerConnection peerConnection, ChatRoomBounce item) { if (!chatRooms.containsKey(item.getRoomId())) { log.error("We're not subscribed to chat room id {}, dropping item {}", log.isErrorEnabled() ? Id.toStringLowerCase(item.getRoomId()) : null, item); return false; } if (isBanned(item.getSignature().getGxsId())) { log.debug("Dropping item from banned entity {}", item.getSignature().getGxsId()); return false; } if (!validateBounceSignature(peerConnection, item)) { log.error("Invalid signature for item {} from peer {}, gxsId: {}, dropping", item, peerConnection, item.getSignature().getGxsId()); return false; } // XXX: add routing clue (ie. best peer for channel) return bounce(peerConnection, item); } private void handleChatRoomUnsubscribeItem(PeerConnection peerConnection, ChatRoomUnsubscribeItem item) { log.debug("Received unsubscribe item from {}: {}", peerConnection, item); var chatRoom = chatRooms.get(item.getRoomId()); if (chatRoom == null) { log.error("Cannot unsubscribe peer from chat room {} as we're not in it", log.isErrorEnabled() ? Id.toStringLowerCase(item.getRoomId()) : null); return; } chatRoom.removeParticipatingLocation(peerConnection.getLocation()); chatRoom.recordPreviouslyKnownLocation(peerConnection.getLocation()); } private void handleChatRoomInviteOldItem(PeerConnection peerConnection, ChatRoomInviteOldItem item) { log.debug("Received deprecated invite from {}: {}", peerConnection, item); // We do nothing because current RS sends that event for compatibility } private void handleChatRoomInviteItem(PeerConnection peerConnection, ChatRoomInviteItem item) { log.debug("Received invite from {}: {}", peerConnection, item); var chatRoom = chatRooms.get(item.getRoomId()); if (chatRoom != null) { if (!item.isConnectionChallenge() && (chatRoom.isPublic() != item.isPublic() || chatRoom.isSigned() != item.isSigned())) { log.debug("Not a matching item"); return; } log.debug("Adding peer {} to chat room {}", peerConnection, chatRoom); chatRoom.addParticipatingLocation(peerConnection.getLocation()); } else { if (!item.isConnectionChallenge()) { log.debug("Chat room invite, prompting user..."); var invitedChatRoom = new ChatRoom( item.getRoomId(), item.getRoomName(), item.getRoomTopic(), item.isPublic() ? RoomType.PUBLIC : RoomType.PRIVATE, 1, item.isSigned()); invitedChatRoom.addParticipatingLocation(peerConnection.getLocation()); sendInviteToClient(peerConnection.getLocation().getLocationIdentifier(), item.getRoomId(), item.getRoomName(), item.getRoomTopic()); invitedChatRooms.put(invitedChatRoom.getId(), invitedChatRoom); refreshChatRoomsInClients(); scriptService.sendEvent("chatRoomInvite", Map.of( "location", peerConnection.getLocation().getLocationIdentifier().toString(), "roomId", item.getRoomId(), "roomName", item.getRoomName(), "roomTopic", item.getRoomTopic(), "roomIsPublic", item.isPublic(), "roomUserCount", 1, "roomIsSigned", item.isSigned() )); } } } @SuppressWarnings("StatementWithEmptyBody") private void handleChatStatusItem(PeerConnection peerConnection, ChatStatusItem item) { // There's a whole protocol with the flags (REQUEST_CUSTOM_STATE, CUSTOM_STATE and CUSTOM_STATE_AVAILABLE) // to change the status string; but it seems all RS does is send the typing state every // 5 seconds while the user is typing. if (item.getFlags().contains(ChatFlags.REQUEST_CUSTOM_STATE)) { // XXX: send the custom string } else if (item.getFlags().contains(ChatFlags.CUSTOM_STATE)) { // XXX: store the string } else if (item.getFlags().contains(ChatFlags.CUSTOM_STATE_AVAILABLE)) { // XXX: send a custom string request } else { log.debug("Got status item from peer {}: {}", peerConnection, item); if (MESSAGE_TYPING_CONTENT.equals(item.getStatus())) { messageService.sendToConsumers(chatPrivateDestination(), CHAT_TYPING_NOTIFICATION, peerConnection.getLocation().getLocationIdentifier(), new ChatMessage()); } else { log.warn("Unknown status item from peer {}, status: {}, flags: {}", peerConnection, item.getStatus(), item.getFlags()); } } } private void handleChatStatusItem(DistantLocation distantLocation, ChatStatusItem item) { log.debug("Got status item from distant peer {}: {}", distantLocation, item); if (MESSAGE_TYPING_CONTENT.equals(item.getStatus())) { messageService.sendToConsumers(chatDistantDestination(), CHAT_TYPING_NOTIFICATION, distantLocation.getGxsId(), new ChatMessage()); } else { log.warn("Unknown status item from distant peer {}, status: {}, flags: {}", distantLocation, item.getStatus(), item.getFlags()); } } private void handleChatMessageItem(PeerConnection peerConnection, ChatMessageItem item) { log.debug("Received chat message item from {}: {}", peerConnection, item); if (item.isPrivate()) { if (item.isAvatarRequest()) { handleAvatarRequest(peerConnection); } else { if (item.isPartial()) { handlePartialMessage(peerConnection, item); } else { handleMessage(peerConnection, item); } } } else if (item.isBroadcast()) { uiBridgeService.showTrayNotification(BROADCAST, "Broadcast from " + peerConnection.getLocation().getProfile().getName() + "@" + peerConnection.getLocation().getSafeName() + ": " + parseIncomingText(item.getMessage())); } } private void handleChatMessageItem(DistantLocation distantLocation, ChatMessageItem item) { log.debug("Received distant chat message item from {}: {}", distantLocation, item); if (!item.isPrivate()) { log.debug("Item type {} not supported", item); return; } if (item.isAvatarRequest()) { handleAvatarRequest(distantLocation); } else { if (item.isPartial()) { handlePartialMessage(distantLocation, item); } else { handleMessage(distantLocation, item); } } } private void handleAvatarRequest(PeerConnection peerConnection) { var ownImage = getOwnImage(); if (ownImage != null) { peerConnectionManager.writeItem(peerConnection, new ChatAvatarItem(ownImage), this); } } private void handleAvatarRequest(DistantLocation distantLocation) { var ownImage = getOwnImage(); if (ownImage != null) { gxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, ItemUtils.serializeItem(new ChatAvatarItem(ownImage), this)); } } private byte[] getOwnImage() { var ownImage = identityService.getOwnIdentity().getImage(); if (ownImage != null && ownImage.length <= AVATAR_SIZE_MAX) { return ownImage; } return null; } private void handleMessage(PeerConnection peerConnection, ChatMessageItem item) { var message = item.getMessage(); var messageList = peerConnection.getServiceData(this, KEY_PARTIAL_MESSAGE_LIST); if (messageList.isPresent()) { @SuppressWarnings("unchecked") var existingList = (List) messageList.get(); existingList.add(message); message = String.join("", existingList); peerConnection.removeServiceData(this, KEY_PARTIAL_MESSAGE_LIST); } var from = peerConnection.getLocation().getLocationIdentifier(); var chatMessage = new ChatMessage(parseIncomingText(message)); chatBacklogService.storeIncomingMessage(from, chatMessage.getContent()); scriptService.sendEvent("chatPrivateMessage", Map.of( "location", from.toString(), "content", chatMessage.getContent() )); messageService.sendToConsumers(chatPrivateDestination(), CHAT_PRIVATE_MESSAGE, from, chatMessage); } private void handleMessage(DistantLocation distantLocation, ChatMessageItem item) { var message = item.getMessage(); if (distantLocation.hasMessages()) { distantLocation.addMessage(message); message = distantLocation.getAllMessages(); distantLocation.clearMessages(); } var from = distantLocation.getGxsId(); var chatMessage = new ChatMessage(parseIncomingText(message)); chatBacklogService.storeIncomingDistantMessage(from, chatMessage.getContent()); scriptService.sendEvent("chatDistantMessage", Map.of( "gxsId", from.toString(), "content", chatMessage.getContent() )); messageService.sendToConsumers(chatDistantDestination(), CHAT_PRIVATE_MESSAGE, from, chatMessage); } private void handlePartialMessage(PeerConnection peerConnection, ChatMessageItem item) { var messageList = peerConnection.getServiceData(this, KEY_PARTIAL_MESSAGE_LIST); if (messageList.isEmpty()) { List newMessageList = new ArrayList<>(); newMessageList.add(item.getMessage()); peerConnection.putServiceData(this, KEY_PARTIAL_MESSAGE_LIST, newMessageList); } else { //noinspection unchecked ((List) messageList.get()).add(item.getMessage()); } } private void handlePartialMessage(DistantLocation distantLocation, ChatMessageItem item) { distantLocation.addMessage(item.getMessage()); } private void handleChatAvatarItem(PeerConnection peerConnection, ChatAvatarItem item) { if (!isAvatarValid(item)) { log.debug("Avatar from {} is null or too big", peerConnection); return; } var chatAvatar = new ChatAvatar(item.getImageData()); messageService.sendToConsumers(chatPrivateDestination(), CHAT_AVATAR, peerConnection.getLocation().getLocationIdentifier(), chatAvatar); } private void handleChatAvatarItem(DistantLocation distantLocation, ChatAvatarItem item) { if (!isAvatarValid(item)) { log.debug("Distant avatar from {} is null or too big", distantLocation); return; } var chatAvatar = new ChatAvatar(item.getImageData()); messageService.sendToConsumers(chatDistantDestination(), CHAT_AVATAR, distantLocation.getGxsId(), chatAvatar); } private boolean isAvatarValid(ChatAvatarItem item) { return item.getImageData() != null && item.getImageData().length <= AVATAR_SIZE_MAX; } /** * Allows to know if a peer is participating in a private chat room and if it is, add it as participating in the room. * For example A, B and C are connected together. If B sends a challenge to A, and it matches (because B is connected through C), A will know that B is on that private * channel and can forward directly to it. * * @param peerConnection the peer connection * @param item the challenge item */ private void handleChatRoomConnectChallengeItem(PeerConnection peerConnection, ChatRoomConnectChallengeItem item) { log.debug("Received chat room connect challenge from {}: {}", peerConnection, item); var locationIdentifier = peerConnection.getLocation().getLocationIdentifier(); chatRooms.values().stream() .filter(chatRoom -> chatRoom.getMessageCache().hasConnectionChallenge(locationIdentifier, chatRoom.getId(), item.getChallengeCode())) .findAny() .ifPresent(chatRoom -> { log.debug("Challenge accepted for chatroom {}, sending connection request to peer {}", chatRoom, peerConnection); chatRoom.addParticipatingLocation(peerConnection.getLocation()); inviteLocationToChatRoom(peerConnection.getLocation(), chatRoom, Invitation.FROM_CHALLENGE); }); } private void inviteLocationToChatRoom(Location location, ChatRoom chatRoom, Invitation invitation) { log.debug("Invite location {} to chatRoom {} with invitation {}", location, chatRoom, invitation); var item = new ChatRoomInviteItem( chatRoom.getId(), chatRoom.getName(), chatRoom.getTopic(), invitation == Invitation.FROM_CHALLENGE ? EnumSet.of(RoomFlags.CHALLENGE) : chatRoom.getRoomFlags()); peerConnectionManager.writeItem(location, item, this); } private void signalChatRoomLeave(Location location, ChatRoom chatRoom) { var item = new ChatRoomUnsubscribeItem(chatRoom.getId()); peerConnectionManager.writeItem(location, item, this); } private void initializeBounce(ChatRoom chatRoom, ChatRoomBounce bounce) { try (var ignored = new DatabaseSession(databaseSessionManager)) { var ownIdentity = identityService.getOwnIdentity(); bounce.setRoomId(chatRoom.getId()); bounce.setMessageId(chatRoom.getNewMessageId()); bounce.setSenderNickname(ownIdentity.getName()); // XXX: we should use the identity in chatRoom.getGxsId() once we have multiple identities support done properly var signature = identityService.signData(ownIdentity, getBounceData(bounce)); bounce.setSignature(new Signature(ownIdentity.getGxsId(), signature)); } } private boolean bounce(ChatRoomBounce bounce) { return bounce(null, bounce); } private boolean bounce(PeerConnection peerConnection, ChatRoomBounce bounce) { var chatRoom = chatRooms.get(bounce.getRoomId()); if (chatRoom == null) { log.error("Can't send to chat room {}, we're not subscribed to it", log.isErrorEnabled() ? Id.toStringLowerCase(bounce.getRoomId()) : null); return false; } if (peerConnection != null) { chatRoom.addParticipatingLocation(peerConnection.getLocation()); // If we didn't receive the list yet, it means he's participating still } if (chatRoom.getMessageCache().exists(bounce.getMessageId())) { log.debug("Message id {} already received, dropping", bounce.getMessageId()); return false; } chatRoom.getMessageCache().add(bounce.getMessageId()); chatRoom.updateActivity(); // XXX: check for antiflood // Send to everyone except the originating peer var iterator = chatRoom.getParticipatingLocations().iterator(); while (iterator.hasNext()) { var location = iterator.next(); if (peerConnection == null || !Objects.equals(location, peerConnection.getLocation())) { var status = peerConnectionManager.writeItem(location, bounce.clone(), this); // Netty frees sent items so we need to clone if (status.isDone() && !status.isSuccess()) { iterator.remove(); // Failed to write, it means the location disconnected, so we need to remove it from our participating locations } } } chatRoom.incrementConnectionChallengeCount(); return true; } private boolean validateBounceSignature(PeerConnection peerConnection, ChatRoomBounce bounce) { var gxsGroup = identityManager.getGxsGroup(peerConnection, bounce.getSignature().getGxsId()); if (gxsGroup != null) { var publicKey = gxsGroup.getAdminPublicKey(); if (publicKey == null) { log.debug("{} has no public admin key, not validating", bounce.getSenderNickname()); return false; } return RSA.verify(publicKey, bounce.getSignature().getData(), getBounceData(bounce)); } log.debug("No key yet for verification, passing through"); return true; // if we don't have the identity yet, we let the item pass because it could be valid, and it's impossible to impersonate an identity this way } private static boolean isBanned(GxsId gxsId) { // XXX: implement by using the reputation level return false; } private byte[] getBounceData(ChatRoomBounce chatRoomBounce) { return ItemUtils.serializeItemForSignature(chatRoomBounce, this); } /** * Checks if a message is well within our own time. * * @param sendTime the time the message was sent at, in seconds from 1970-01-01 UTC * @return true if within bounds */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") private static boolean validateExpiration(int sendTime) { var now = Instant.now(); if (sendTime < now.getEpochSecond() + TIME_DRIFT_PAST_MAX.toSeconds() - KEEP_MESSAGE_RECORD_MAX.toSeconds()) { return false; } //noinspection RedundantIfStatement if (sendTime > now.getEpochSecond() + TIME_DRIFT_FUTURE_MAX.toSeconds()) { return false; } return true; } /** * Sends a broadcast message to all connected peers. * * @param message the message */ public void sendBroadcastMessage(String message) { var chatMessageItem = new ChatMessageItem(message, EnumSet.of(ChatFlags.PUBLIC)); peerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, chatMessageItem, this), this); } /** * Sends a private message to a peer. * * @param identifier the identifier (LocationIdentifier or GxsId) * @param message the message */ public void sendPrivateMessage(Identifier identifier, String message) { switch (identifier) { case LocationIdentifier locationIdentifier -> sendPrivateMessageToLocation(locationIdentifier, message); case GxsId gxsId -> sendPrivateMessageToGxsId(gxsId, message); default -> throw new IllegalStateException("Unexpected value: " + identifier); } } private void sendPrivateMessageToLocation(LocationIdentifier locationIdentifier, String message) { var location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow(); chatBacklogService.storeOutgoingMessage(location.getLocationIdentifier(), message); peerConnectionManager.writeItem(location, new ChatMessageItem(message, EnumSet.of(ChatFlags.PRIVATE)), this); } private void sendPrivateMessageToGxsId(GxsId gxsId, String message) { var distantLocation = distantChatContacts.get(gxsId); if (distantLocation == null) { log.error("Cannot find distantLocation for gxsId {} when sending private message", gxsId); return; } var identity = identityService.findByGxsId(gxsId).orElseThrow(); chatBacklogService.storeOutgoingDistantMessage(identity.getGxsId(), message); var data = ItemUtils.serializeItem(new ChatMessageItem(message, EnumSet.of(ChatFlags.PRIVATE)), this); gxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, data); } /** * Sends a typing notification for private messages to a peer. * * @param identifier the identifier */ public void sendPrivateTypingNotification(Identifier identifier) { switch (identifier) { case LocationIdentifier locationIdentifier -> sendPrivateTypingNotificationToLocation(locationIdentifier); case GxsId gxsId -> sendPrivateTypingNotificationToGxsId(gxsId); default -> throw new IllegalStateException("Unexpected value: " + identifier); } } private void sendPrivateTypingNotificationToLocation(LocationIdentifier locationIdentifier) { var location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow(); peerConnectionManager.writeItem(location, new ChatStatusItem(MESSAGE_TYPING_CONTENT, EnumSet.of(ChatFlags.PRIVATE)), this); } private void sendPrivateTypingNotificationToGxsId(GxsId gxsId) { var distantLocation = distantChatContacts.get(gxsId); if (distantLocation == null) { log.error("Cannot find distantLocation for gxsId {} when sending typing notification", gxsId); return; } var data = ItemUtils.serializeItem(new ChatStatusItem(MESSAGE_TYPING_CONTENT, EnumSet.of(ChatFlags.PRIVATE)), this); gxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, data); } public void sendAvatarRequest(Identifier identifier) { switch (identifier) { case LocationIdentifier locationIdentifier -> sendAvatarRequestToLocation(locationIdentifier); case GxsId gxsId -> sendAvatarRequestToGxsId(gxsId); default -> throw new IllegalStateException("Unexpected value: " + identifier); } } private void sendAvatarRequestToLocation(LocationIdentifier locationIdentifier) { var location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow(); peerConnectionManager.writeItem(location, new ChatMessageItem("", EnumSet.of(ChatFlags.PRIVATE, ChatFlags.REQUEST_AVATAR)), this); } private void sendAvatarRequestToGxsId(GxsId gxsId) { var distantLocation = distantChatContacts.get(gxsId); if (distantLocation == null) { log.error("Cannot find distantLocation for gxsId: {} when sending avatar request", gxsId); return; } var data = ItemUtils.serializeItem(new ChatMessageItem("", EnumSet.of(ChatFlags.PRIVATE, ChatFlags.REQUEST_AVATAR)), this); gxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, data); } public Location createDistantChat(IdentityGroupItem identityGroupItem) { var ownIdentity = identityService.getOwnIdentity(); var tunnelId = gxsTunnelRsService.requestSecuredTunnel(ownIdentity.getGxsId(), identityGroupItem.getGxsId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID); if (tunnelId != null) { log.debug("Creating distant chat tunnel for identity {}, resulting tunnelId: {}", identityGroupItem.getGxsId(), tunnelId.getLocationIdentifier()); distantChatContacts.put(identityGroupItem.getGxsId(), new DistantLocation(tunnelId, identityGroupItem.getGxsId())); } return tunnelId; } public boolean closeDistantChat(IdentityGroupItem identityGroupItem) { var location = distantChatContacts.remove(identityGroupItem.getGxsId()); if (location == null) { log.debug("Failed to close distant chat for identityGroupItem {}", identityGroupItem); return false; } gxsTunnelRsService.closeExistingTunnel(location.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID); return true; } /** * Sets the status message (the one appearing at the top of the profile peer; for example, "I'm eating", "Gone for a walk", etc...). * * @param message the status message */ public void setStatusMessage(String message) { peerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, new ChatStatusItem(message, EnumSet.of(ChatFlags.CUSTOM_STATE)), this), this); } /** * Sends a message to a chat room. * * @param chatRoomId the id of the chat room * @param message the message */ public void sendChatRoomMessage(long chatRoomId, String message) { var chatRoomMessageItem = new ChatRoomMessageItem(message); var chatRoom = chatRooms.get(chatRoomId); if (chatRoom == null) { log.warn("Chatroom {} doesn't exist. Not sending the message.", log.isWarnEnabled() ? Id.toStringLowerCase(chatRoomId) : null); return; } initializeBounce(chatRoom, chatRoomMessageItem); chatBacklogService.storeOutgoingChatRoomMessage(chatRoomId, chatRoomMessageItem.getSenderNickname(), message); bounce(chatRoomMessageItem); } public void sendChatRoomTypingNotification(long chatRoomId) { var chatRoom = chatRooms.get(chatRoomId); if (chatRoom == null) { log.warn("Chatroom {} doesn't exist. Not sending the typing notification.", log.isWarnEnabled() ? Id.toStringLowerCase(chatRoomId) : null); return; } sendChatRoomEvent(chatRoom, ChatRoomEvent.PEER_STATUS, MESSAGE_TYPING_CONTENT); } /** * Joins a chat room. * * @param chatRoomId the id of the chat room */ public void joinChatRoom(long chatRoomId) { log.debug("Joining chat room {}", log.isDebugEnabled() ? Id.toStringLowerCase(chatRoomId) : null); if (chatRooms.containsKey(chatRoomId)) { log.debug("Already in the chatroom"); return; } var chatRoom = getAvailableChatRoom(chatRoomId); if (chatRoom == null) { log.warn("Chatroom {} doesn't exist, can't join.", log.isWarnEnabled() ? Id.toStringLowerCase(chatRoomId) : null); return; } chatRooms.put(chatRoomId, chatRoom); try (var ignored = new DatabaseSession(databaseSessionManager)) // XXX: ugly, it's because we can be called from a lambda.. make it take the arguments later (needed for multi identity support) { var ownIdentity = identityService.getOwnIdentity(); chatRoom.setOwnGxsId(ownIdentity.getGxsId()); chatRoomService.subscribeToChatRoomAndJoin(chatRoom, ownIdentity); chatRoom.getParticipatingLocations().forEach(location -> inviteLocationToChatRoom(location, chatRoom, Invitation.PLAIN)); messageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_JOIN, chatRoom.getId(), new ChatRoomMessage()); chatRoom.addUser(ownIdentity.getGxsId()); sendJoinEventIfNeeded(chatRoom); // Add ourselves in the UI so that we're shown as joining sendChatRoomEventToConsumers(chatRoom.getId(), CHAT_ROOM_USER_JOIN, ownIdentity.getGxsId(), ownIdentity.getName(), ownIdentity); } } private ChatRoom getAvailableChatRoom(long chatRoomId) { var chatRoom = availableChatRooms.get(chatRoomId); if (chatRoom == null) { chatRoom = invitedChatRooms.remove(chatRoomId); } return chatRoom; } /** * Leaves a chat room. * * @param chatRoomId the id of the chat room */ public void leaveChatRoom(long chatRoomId) { log.debug("Leaving chat room {}", log.isDebugEnabled() ? Id.toStringLowerCase(chatRoomId) : null); var chatRoomToRemove = chatRooms.remove(chatRoomId); if (chatRoomToRemove == null) { log.debug("Can't leave a chatroom we aren't into"); return; } chatRoomToRemove.clearUsers(); sendChatRoomEvent(chatRoomToRemove, ChatRoomEvent.PEER_LEFT); chatRoomToRemove.setJoinedRoomPacketSent(false); // in the case we rejoin immediately chatRoomService.unsubscribeFromChatRoomAndLeave(chatRoomId, identityService.getOwnIdentity()); // XXX: allow multiple identities chatRoomToRemove.getParticipatingLocations().forEach(peer -> signalChatRoomLeave(peer, chatRoomToRemove)); messageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_LEAVE, chatRoomToRemove.getId(), new ChatRoomMessage()); } public long createChatRoom(String roomName, String topic, Set flags, boolean signedIdentities) { var newChatRoom = new ChatRoom( createUniqueRoomId(), roomName, topic, flags.contains(RoomFlags.PUBLIC) ? RoomType.PUBLIC : RoomType.PRIVATE, 1, signedIdentities); availableChatRooms.put(newChatRoom.getId(), newChatRoom); refreshChatRoomsInClients(); joinChatRoom(newChatRoom.getId()); // XXX: we could invite friends in there... supply a list of friends as parameter return newChatRoom.getId(); } public void inviteLocationsToChatRoom(long chatRoomId, Set ids) { var chatRoom = chatRooms.get(chatRoomId); if (chatRoom == null) { log.error("Cannot invite to unsubscribed chatroom {}", chatRoomId); return; } peerConnectionManager.doForAllPeers(peerConnection -> { if (ids.contains(peerConnection.getLocation().getLocationIdentifier())) { inviteLocationToChatRoom(peerConnection.getLocation(), chatRoom, Invitation.PLAIN); } }, this); } @EventListener public void onPeerConnectedEvent(PeerConnectedEvent event) { messageService.sendToConsumers(chatPrivateDestination(), CHAT_AVAILABILITY, event.locationIdentifier(), AVAILABLE); } @EventListener public void onPeerDisconnectedEvent(PeerDisconnectedEvent event) { messageService.sendToConsumers(chatPrivateDestination(), CHAT_AVAILABILITY, event.locationIdentifier(), OFFLINE); } private long createUniqueRoomId() { long newId; do { newId = SecureRandomUtils.nextLong(); } while (availableChatRooms.containsKey(newId) || chatRooms.containsKey(newId) || invitedChatRooms.containsKey(newId)); return newId; } /** * Send a chat room event to the participating peers. * * @param chatRoom the chat room * @param event the event */ private void sendChatRoomEvent(ChatRoom chatRoom, ChatRoomEvent event) { sendChatRoomEvent(chatRoom, event, ""); } /** * Send a chat room event to the participating peers. * * @param chatRoom the chat room * @param event the event * @param status the status, if empty prefer {@linkplain #sendChatRoomEvent(ChatRoom, ChatRoomEvent) the overloaded alternative} */ private void sendChatRoomEvent(ChatRoom chatRoom, ChatRoomEvent event, String status) { var chatRoomEvent = new ChatRoomEventItem(event, status); initializeBounce(chatRoom, chatRoomEvent); log.debug("Sending chat room event {}", chatRoomEvent); bounce(chatRoomEvent); } private String parseIncomingText(String text) { return unHtmlService.cleanupMessage(text); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/DistantLocation.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import java.util.ArrayList; import java.util.List; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.GxsId; class DistantLocation { private final Location tunnelId; private final GxsId gxsId; private final List messageList; public DistantLocation(Location tunnelId, GxsId gxsId) { this.tunnelId = tunnelId; this.gxsId = gxsId; messageList = new ArrayList<>(); } public Location getTunnelId() { return tunnelId; } public GxsId getGxsId() { return gxsId; } public boolean hasMessages() { return !messageList.isEmpty(); } public void addMessage(String message) { messageList.add(message); } public String getAllMessages() { return String.join("", messageList); } public void clearMessages() { messageList.clear(); } @Override public String toString() { return "DistantLocation{" + "tunnelId=" + tunnelId + ", gxsId=" + gxsId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/MessageCache.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.app.crypto.hash.chat.ChatChallenge; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.util.SecureRandomUtils; import java.time.Instant; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; class MessageCache { private static final int CONNECTION_CHALLENGE_MAX_TIME = 30; // maximum age in seconds a message can be used in a connection challenge private static final int LIFETIME_MAX = 1200; // maximum age of a message in seconds private final Map messages = new ConcurrentHashMap<>(); /** * Checks if a message has been recorded already. If yes, update * its own time to prevent echoes. * * @param id the id of the message to check * @return true if it exists */ public boolean exists(long id) { return messages.replace(id, (int) Instant.now().getEpochSecond()) != null; } /** * Adds a message id to the cache. * * @param id the message id */ public void add(long id) { messages.put(id, (int) Instant.now().getEpochSecond()); } /** * Updates the time of a message id. * * @param id the message id */ public void update(long id) { add(id); } /** * Gets a new unique message id * * @return the message id */ public long getNewMessageId() { long newId; do { newId = SecureRandomUtils.nextLong(); } while (messages.containsKey(newId)); return newId; } /** * Checks if this message cache contains a challenge code. * * @param locationIdentifier the location identifier of the peer * @param chatRoomId the chat room id * @param challengeCode the challenge code to be matched against * @return true if challengeCode is in one of a suitable message */ public boolean hasConnectionChallenge(LocationIdentifier locationIdentifier, long chatRoomId, long challengeCode) { var now = (int) Instant.now().getEpochSecond(); for (var message : messages.entrySet()) { if (message.getValue() + CONNECTION_CHALLENGE_MAX_TIME + 5 > now && challengeCode == ChatChallenge.code(locationIdentifier, chatRoomId, message.getKey())) { return true; } } return false; } /** * Returns a recent message id. * * @return the message id of a recent message. If there's nothing suitable, return 0 */ public long getRecentMessage() { var now = (int) Instant.now().getEpochSecond(); for (var message : messages.entrySet()) { if (message.getValue() + CONNECTION_CHALLENGE_MAX_TIME > now) { return message.getKey(); } } return 0L; } /** * Removes all messages older than LIFETIME_MAX seconds. */ public void purge() { var now = (int) Instant.now().getEpochSecond(); messages.entrySet().removeIf(entry -> entry.getValue() + LIFETIME_MAX < now); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/RoomFlags.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.common.annotation.RsDeprecated; public enum RoomFlags { /** * A room that is automatically subscribed to (joined). */ AUTO_SUBSCRIBE, /** * Not used. Do not remove. */ @RsDeprecated UNUSED, /** * A public chat room. */ PUBLIC, CHALLENGE, /** * Signed chat room. */ PGP_SIGNED } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatAvatarItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Id; import io.xeres.common.protocol.xrs.RsServiceType; public class ChatAvatarItem extends Item { @RsSerialized private byte[] imageData; @SuppressWarnings("unused") public ChatAvatarItem() { } public ChatAvatarItem(byte[] imageData) { this.imageData = imageData; } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 3; } @Override public int getPriority() { return ItemPriority.BACKGROUND.getPriority(); } public byte[] getImageData() { return imageData; } @Override public ChatAvatarItem clone() { return (ChatAvatarItem) super.clone(); } @Override public String toString() { return "ChatAvatarItem{" + "imageData=" + Id.toString(imageData) + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatMessageItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.chat.ChatFlags; import io.xeres.common.protocol.xrs.RsServiceType; import java.time.Instant; import java.util.Set; import static io.xeres.app.xrs.serialization.TlvType.STR_MSG; import static io.xeres.app.xrs.service.chat.ChatFlags.*; public class ChatMessageItem extends Item { @RsSerialized private Set flags; @RsSerialized private int sendTime; @RsSerialized(tlvType = STR_MSG) private String message; @SuppressWarnings("unused") public ChatMessageItem() { } public ChatMessageItem(String message, Set flags) { this.message = message; sendTime = (int) Instant.now().getEpochSecond(); this.flags = flags; } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public Set getFlags() { return flags; } public int getSendTime() { return sendTime; } public String getMessage() { return message; } public boolean isPrivate() { return flags.contains(PRIVATE); } public boolean isBroadcast() { return flags.contains(PUBLIC); } public boolean isPartial() { return flags.contains(PARTIAL_MESSAGE); } public boolean isAvatarRequest() { return flags.contains(REQUEST_AVATAR); } @Override public ChatMessageItem clone() { return (ChatMessageItem) super.clone(); } @Override public String toString() { return "ChatMessageItem{" + "flags=" + flags + ", sendTime=" + sendTime + ", message='" + message + '\'' + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomBounce.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.common.Signature; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.common.id.Id; import java.util.Set; import static io.xeres.app.xrs.serialization.TlvType.SIGNATURE; import static io.xeres.app.xrs.serialization.TlvType.STR_NAME; public abstract class ChatRoomBounce extends Item { private long roomId; private long messageId; private String senderNickname; private Signature signature; @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } int writeBounceableObject(ByteBuf buf, Set flags) { var size = 0; size += Serializer.serialize(buf, roomId); size += Serializer.serialize(buf, messageId); size += Serializer.serialize(buf, STR_NAME, senderNickname); if (!flags.contains(SerializationFlags.SIGNATURE)) { size += Serializer.serialize(buf, SIGNATURE, signature); } return size; } void readBounceableObject(ByteBuf buf) { roomId = Serializer.deserializeLong(buf); messageId = Serializer.deserializeLong(buf); senderNickname = (String) Serializer.deserialize(buf, STR_NAME); signature = (Signature) Serializer.deserialize(buf, SIGNATURE); } public long getRoomId() { return roomId; } public void setRoomId(long roomId) { this.roomId = roomId; } public long getMessageId() { return messageId; } public void setMessageId(long messageId) { this.messageId = messageId; } public String getSenderNickname() { return senderNickname; } public void setSenderNickname(String senderNickname) { this.senderNickname = senderNickname; } public Signature getSignature() { return signature; } public void setSignature(Signature signature) { this.signature = signature; } @Override public String toString() { return "ChatRoomBounce{" + "roomId=" + Id.toStringLowerCase(roomId) + ", messageId=" + Id.toStringLowerCase(messageId) + ", senderNickname='" + senderNickname + '\'' + ", signature=[something]" + '}'; } @Override public ChatRoomBounce clone() { return (ChatRoomBounce) super.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomConfigItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; public class ChatRoomConfigItem extends Item { @RsSerialized private long roomId; @RsSerialized private int flags; // XXX: which flags? @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 21; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public long getRoomId() { return roomId; } public int getFlags() { return flags; } @Override public ChatRoomConfigItem clone() { return (ChatRoomConfigItem) super.clone(); } @Override public String toString() { return "ChatRoomConfigItem{" + "roomId=" + roomId + ", flags=" + flags + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomConnectChallengeItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.crypto.hash.chat.ChatChallenge; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.xrs.RsServiceType; public class ChatRoomConnectChallengeItem extends Item { @RsSerialized private long challengeCode; @SuppressWarnings("unused") public ChatRoomConnectChallengeItem() { } public ChatRoomConnectChallengeItem(LocationIdentifier locationIdentifier, long chatRoomId, long messageId) { challengeCode = ChatChallenge.code(locationIdentifier, chatRoomId, messageId); } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 9; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public long getChallengeCode() { return challengeCode; } @Override public ChatRoomConnectChallengeItem clone() { return (ChatRoomConnectChallengeItem) super.clone(); } @Override public String toString() { return "ChatRoomConnectChallengeItem{" + "challengeCode=" + Long.toUnsignedString(challengeCode) + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomEvent.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import java.util.Arrays; public enum ChatRoomEvent { PEER_LEFT(1), PEER_STATUS(2), PEER_JOINED(3), PEER_CHANGE_NICKNAME(4), KEEP_ALIVE(5); private final int code; ChatRoomEvent(int code) { this.code = code; } public byte getCode() { return (byte) code; } public static String getFromCode(int code) { return Arrays.stream(ChatRoomEvent.values()) .filter(chatRoomEvent -> chatRoomEvent.getCode() == code) .findAny() .map(Enum::name) .orElse(""); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomEventItem.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.common.protocol.xrs.RsServiceType; import java.time.Instant; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.*; import static io.xeres.app.xrs.serialization.TlvType.STR_NAME; public class ChatRoomEventItem extends ChatRoomBounce implements RsSerializable { private byte eventType; private String status; private int sendTime; @SuppressWarnings("unused") public ChatRoomEventItem() { } public ChatRoomEventItem(ChatRoomEvent event, String status) { eventType = event.getCode(); this.status = status; sendTime = (int) Instant.now().getEpochSecond(); } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 24; } public byte getEventType() { return eventType; } public String getStatus() { return status; } public int getSendTime() { return sendTime; } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, eventType); size += serialize(buf, STR_NAME, status); size += serialize(buf, sendTime); size += writeBounceableObject(buf, serializationFlags); return size; } @Override public void readObject(ByteBuf buf) { eventType = deserializeByte(buf); status = (String) deserialize(buf, STR_NAME); sendTime = deserializeInt(buf); readBounceableObject(buf); } @Override public String toString() { return "ChatRoomEventItem{" + "eventType=" + ChatRoomEvent.getFromCode(eventType) + ", status='" + status + '\'' + ", sendTime=" + sendTime + ", super=" + super.toString() + '}'; } @Override public ChatRoomEventItem clone() { return (ChatRoomEventItem) super.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomInviteItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.chat.RoomFlags; import io.xeres.common.id.Id; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.Set; import static io.xeres.app.xrs.serialization.TlvType.STR_NAME; import static io.xeres.app.xrs.service.chat.RoomFlags.*; public class ChatRoomInviteItem extends Item { @RsSerialized private long roomId; @RsSerialized(tlvType = STR_NAME) private String roomName; @RsSerialized(tlvType = STR_NAME) private String roomTopic; @RsSerialized private Set roomFlags; @SuppressWarnings("unused") public ChatRoomInviteItem() { } public ChatRoomInviteItem(long roomId, String roomName, String roomTopic, Set roomFlags) { this.roomId = roomId; this.roomName = roomName; this.roomTopic = roomTopic; this.roomFlags = roomFlags; } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 27; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public long getRoomId() { return roomId; } public String getRoomName() { return roomName; } public String getRoomTopic() { return roomTopic; } public Set getRoomFlags() { return roomFlags; } @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean isConnectionChallenge() { return roomFlags.contains(CHALLENGE); } public boolean isPublic() { return roomFlags.contains(PUBLIC); } public boolean isSigned() { return roomFlags.contains(PGP_SIGNED); } @Override public ChatRoomInviteItem clone() { return (ChatRoomInviteItem) super.clone(); } @Override public String toString() { return "ChatRoomInviteItem{" + "roomId=" + Id.toString(roomId) + ", roomName='" + roomName + '\'' + ", roomTopic='" + roomTopic + '\'' + ", roomFlags=" + roomFlags + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomInviteOldItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.chat.RoomFlags; import io.xeres.common.annotation.RsDeprecated; import io.xeres.common.id.Id; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.Set; import static io.xeres.app.xrs.serialization.TlvType.STR_NAME; import static io.xeres.app.xrs.service.chat.RoomFlags.*; /** * Since Retroshare 0.6.5, ChatRoomInviteItem is used instead and provides the missing 'topic' parameter. * Note that Retroshare still sends it for compatibility reasons. We don't do it, though. * This class solely exists to avoid warnings in the logs. */ @RsDeprecated(since = "0.6.5") public class ChatRoomInviteOldItem extends Item { @RsSerialized private long roomId; @RsSerialized(tlvType = STR_NAME) private String roomName; @RsSerialized private Set roomFlags; @SuppressWarnings("unused") public ChatRoomInviteOldItem() { } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 26; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public long getRoomId() { return roomId; } public String getRoomName() { return roomName; } public Set getRoomFlags() { return roomFlags; } public boolean isConnectionChallenge() { return roomFlags.contains(CHALLENGE); } public boolean isPublic() { return roomFlags.contains(PUBLIC); } public boolean isSigned() { return roomFlags.contains(PGP_SIGNED); } @Override public ChatRoomInviteOldItem clone() { return (ChatRoomInviteOldItem) super.clone(); } @Override public String toString() { return "ChatRoomInviteItem{" + "roomId=" + Id.toString(roomId) + ", roomName='" + roomName + '\'' + ", roomFlags=" + roomFlags + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomListItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.ArrayList; import java.util.List; public class ChatRoomListItem extends Item { @RsSerialized private final List chatRooms = new ArrayList<>(); @SuppressWarnings("unused") public ChatRoomListItem() { } public ChatRoomListItem(List chatRooms) { this.chatRooms.addAll(chatRooms); } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 25; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public List getChatRooms() { return chatRooms; } @Override public ChatRoomListItem clone() { return (ChatRoomListItem) super.clone(); } @Override public String toString() { return "ChatRoomListItem{" + "chatRooms=" + chatRooms + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomListRequestItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.common.protocol.xrs.RsServiceType; public class ChatRoomListRequestItem extends Item { // This is an empty item @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 13; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } @Override public ChatRoomListRequestItem clone() { return (ChatRoomListRequestItem) super.clone(); } @Override public String toString() { return "ChatRoomListRequestItem{}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomMessageItem.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.serialization.FieldSize; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.service.chat.ChatFlags; import io.xeres.common.protocol.xrs.RsServiceType; import java.time.Instant; import java.util.EnumSet; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.*; import static io.xeres.app.xrs.serialization.TlvType.STR_MSG; import static io.xeres.app.xrs.service.chat.ChatFlags.LOBBY; import static io.xeres.app.xrs.service.chat.ChatFlags.PRIVATE; public class ChatRoomMessageItem extends ChatRoomBounce implements RsSerializable { private Set flags; private int sendTime; private String message; private long parentMessageId; @SuppressWarnings("unused") public ChatRoomMessageItem() { } public ChatRoomMessageItem(String message) { flags = EnumSet.of(LOBBY, PRIVATE); sendTime = (int) Instant.now().getEpochSecond(); this.message = message; parentMessageId = 0L; } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 23; } public Set getFlags() { return flags; } public int getSendTime() { return sendTime; } public String getMessage() { return message; } public long getParentMessageId() { return parentMessageId; } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, flags, FieldSize.INTEGER); size += serialize(buf, sendTime); size += serialize(buf, STR_MSG, message); size += serialize(buf, parentMessageId); size += writeBounceableObject(buf, serializationFlags); return size; } @Override public void readObject(ByteBuf buf) { flags = deserializeEnumSet(buf, ChatFlags.class, FieldSize.INTEGER); sendTime = deserializeInt(buf); message = (String) deserialize(buf, STR_MSG); parentMessageId = deserializeLong(buf); readBounceableObject(buf); } @Override public ChatRoomMessageItem clone() { return (ChatRoomMessageItem) super.clone(); } @Override public String toString() { return "ChatRoomMessageItem{" + "flags=" + flags + ", sendTime=" + sendTime + ", message='" + message + '\'' + ", parentMessageId=" + parentMessageId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomUnsubscribeItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; public class ChatRoomUnsubscribeItem extends Item { @RsSerialized private long roomId; @SuppressWarnings("unused") public ChatRoomUnsubscribeItem() { } public ChatRoomUnsubscribeItem(long roomId) { this.roomId = roomId; } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 10; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public long getRoomId() { return roomId; } @Override public ChatRoomUnsubscribeItem clone() { return (ChatRoomUnsubscribeItem) super.clone(); } @Override public String toString() { return "ChatRoomUnsubscribeItem{" + "roomId=" + roomId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatStatusItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.chat.ChatFlags; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.Set; import static io.xeres.app.xrs.serialization.TlvType.STR_MSG; public class ChatStatusItem extends Item { @RsSerialized private Set flags; @RsSerialized(tlvType = STR_MSG) private String status; @SuppressWarnings("unused") public ChatStatusItem() { } public ChatStatusItem(String status, Set flags) { this.status = status; this.flags = flags; } @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 4; } @Override public int getPriority() { return ItemPriority.BACKGROUND.getPriority(); } public Set getFlags() { return flags; } public String getStatus() { return status; } @Override public ChatStatusItem clone() { return (ChatStatusItem) super.clone(); } @Override public String toString() { return "ChatStatusItem{" + "flags=" + flags + ", status='" + status + '\'' + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/PrivateChatMessageConfigItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.xrs.RsServiceType; import static io.xeres.app.xrs.serialization.TlvType.STR_MSG; public class PrivateChatMessageConfigItem extends Item { @RsSerialized private LocationIdentifier locationIdentifier; @RsSerialized private int chatFlags; // XXX: enumsets @RsSerialized private int configFlags; // XXX: use an enumSet @RsSerialized private int sendTime; @RsSerialized(tlvType = STR_MSG) private String message; @RsSerialized int receiveTime; @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 5; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public LocationIdentifier getLocationId() { return locationIdentifier; } public int getChatFlags() { return chatFlags; } public int getConfigFlags() { return configFlags; } public int getSendTime() { return sendTime; } public String getMessage() { return message; } public int getReceiveTime() { return receiveTime; } @Override public PrivateChatMessageConfigItem clone() { return (PrivateChatMessageConfigItem) super.clone(); } @Override public String toString() { return "PrivateChatMessageConfigItem{" + "locationIdentifier=" + locationIdentifier + ", chatFlags=" + chatFlags + ", configFlags=" + configFlags + ", sendTime=" + sendTime + ", message='" + message + '\'' + ", receiveTime=" + receiveTime + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/PrivateOutgoingMapItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.Map; public class PrivateOutgoingMapItem extends Item { @RsSerialized private Map store; @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 28; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } public Map getStore() { return store; } @Override public PrivateOutgoingMapItem clone() { return (PrivateOutgoingMapItem) super.clone(); } @Override public String toString() { return "PrivateOutgoingMapItem{" + "store=" + store + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/SubscribedChatRoomConfigItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.chat.RoomFlags; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.Map; import java.util.Set; public class SubscribedChatRoomConfigItem extends Item { @RsSerialized private long roomId; @RsSerialized private String roomName; @RsSerialized private String roomTopic; @RsSerialized private Set participatingLocations; // XXX: do we serialize Sets yet? no, see #19 @RsSerialized private GxsId gxsId; @RsSerialized private Set flags; @RsSerialized private Map gxsIds; @RsSerialized private long lastActivity; @Override public int getServiceType() { return RsServiceType.CHAT.getType(); } @Override public int getSubType() { return 29; } public long getRoomId() { return roomId; } public String getRoomName() { return roomName; } public String getRoomTopic() { return roomTopic; } public Set getParticipatingLocations() { return participatingLocations; } public GxsId getGxsId() { return gxsId; } public Set getFlags() { return flags; } public Map getGxsIds() { return gxsIds; } public long getLastActivity() { return lastActivity; } @Override public SubscribedChatRoomConfigItem clone() { return (SubscribedChatRoomConfigItem) super.clone(); } @Override public String toString() { return "SubscribedChatRoomConfigItem{" + "roomId=" + roomId + ", roomName='" + roomName + '\'' + ", roomTopic='" + roomTopic + '\'' + ", participatingLocations=" + participatingLocations + ", gxsId=" + gxsId + ", flags=" + flags + ", gxsIds=" + gxsIds + ", lastActivity=" + lastActivity + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/chat/item/VisibleChatRoomInfo.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat.item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.chat.RoomFlags; import io.xeres.common.id.Id; import java.util.Set; import static io.xeres.app.xrs.serialization.TlvType.STR_NAME; public class VisibleChatRoomInfo { @RsSerialized private long id; @RsSerialized(tlvType = STR_NAME) private String name; @RsSerialized(tlvType = STR_NAME) private String topic; @RsSerialized private int count; @RsSerialized private Set flags; public VisibleChatRoomInfo() { // Required } public VisibleChatRoomInfo(long id, String name, String topic, int count, Set roomFlags) { this.id = id; this.name = name; this.topic = topic; this.count = count; flags = roomFlags; } public long getId() { return id; } public String getName() { return name; } public String getTopic() { return topic; } public int getCount() { return count; } public Set getFlags() { return flags; } @Override public String toString() { return "VisibleChatRoomInfo{" + "id=" + Id.toStringLowerCase(id) + ", name='" + name + '\'' + ", topic='" + topic + '\'' + ", count=" + count + ", flags=" + flags + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/discovery/DiscoveryRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.discovery; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.IdentityService; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.discovery.item.DiscoveryContactItem; import io.xeres.app.xrs.service.discovery.item.DiscoveryIdentityListItem; import io.xeres.app.xrs.service.discovery.item.DiscoveryPgpKeyItem; import io.xeres.app.xrs.service.discovery.item.DiscoveryPgpListItem; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.Id; import io.xeres.common.id.ProfileFingerprint; import io.xeres.common.protocol.xrs.RsServiceType; import org.bouncycastle.openpgp.PGPPublicKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.info.BuildProperties; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.security.InvalidKeyException; import java.time.Instant; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; import static io.xeres.app.net.util.NetworkMode.getNetworkMode; import static io.xeres.common.protocol.xrs.RsServiceType.DISCOVERY; import static java.util.function.Predicate.not; import static java.util.stream.Collectors.toSet; import static org.apache.commons.lang3.StringUtils.isNotBlank; @Component public class DiscoveryRsService extends RsService { private static final Logger log = LoggerFactory.getLogger(DiscoveryRsService.class); private final ProfileService profileService; private final LocationService locationService; private final IdentityService identityService; private final BuildProperties buildProperties; private final DatabaseSessionManager databaseSessionManager; private final PeerConnectionManager peerConnectionManager; private final IdentityManager identityManager; private final StatusNotificationService statusNotificationService; public DiscoveryRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, ProfileService profileService, LocationService locationService, IdentityService identityService, BuildProperties buildProperties, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, StatusNotificationService statusNotificationService) { super(rsServiceRegistry); this.profileService = profileService; this.locationService = locationService; this.identityService = identityService; this.identityManager = identityManager; this.buildProperties = buildProperties; this.databaseSessionManager = databaseSessionManager; this.peerConnectionManager = peerConnectionManager; this.statusNotificationService = statusNotificationService; } @Override public RsServiceType getServiceType() { return DISCOVERY; } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.NORMAL; } @Override public void initialize(PeerConnection peerConnection) { peerConnection.schedule( () -> sendOwnContactAndIdentities(peerConnection) , 0, TimeUnit.SECONDS ); } private void sendOwnContactAndIdentities(PeerConnection peerConnection) { try (var ignored = new DatabaseSession(databaseSessionManager)) { var ownLocation = locationService.findOwnLocation().orElseThrow(); sendContact(peerConnection, ownLocation); sendIdentity(peerConnection, identityService.getOwnIdentity()); // XXX: in the future we will have several identities, just get the signed ones here // XXX: also send our own other locations, if any (ie. laptop, etc...). XXX: this should be already done in the current code but check. it is done when the peer sends us his list of friends and has us in it } } private void sendContact(Location toLocation, Location aboutLocation) { sendContact(toLocation, aboutLocation, null); } private void sendContact(PeerConnection peerConnection, Location aboutLocation) { sendContact(peerConnection.getLocation(), aboutLocation, peerConnection.getCtx().channel().remoteAddress()); } private void sendContact(Location toLocation, Location aboutLocation, SocketAddress toLocationAddress) { log.debug("Sending contact information of {} to {}", aboutLocation, toLocation); var builder = DiscoveryContactItem.builder(); builder.setPgpIdentifier(aboutLocation.getProfile().getPgpIdentifier()); builder.setLocationIdentifier(aboutLocation.getLocationIdentifier()); builder.setLocationName(aboutLocation.getSafeName()); if (aboutLocation.isOwn()) { builder.setVersion(buildProperties.getName() + " " + buildProperties.getVersion()); } builder.setNetMode(aboutLocation.getNetMode()); builder.setVsDisc(aboutLocation.isDiscoverable() ? 2 : 0); builder.setVsDht(aboutLocation.isDht() ? 2 : 0); builder.setLastContact((int) (aboutLocation.getLastConnected() != null ? aboutLocation.getLastConnected().getEpochSecond() : Instant.now().getEpochSecond())); // RS uses Instant.now() XXX: find out if there is any issue with that change. it tells since how long we've been connected aboutLocation.getConnections().stream() .filter(connection -> connection.getType() == PeerAddress.Type.IPV4) .filter(not(Connection::isExternal)) .findFirst() .ifPresent(connection -> builder.setLocalAddressV4(PeerAddress.fromAddress(connection.getAddress()))); aboutLocation.getConnections().stream() .filter(connection -> connection.getType() == PeerAddress.Type.IPV4) .filter(Connection::isExternal) .findFirst() .ifPresent(connection -> builder.setExternalAddressV4(PeerAddress.fromAddress(connection.getAddress()))); if (aboutLocation.equals(toLocation) && toLocationAddress != null) { // Tell the peer about how we see its IP address builder.setCurrentConnectAddress(PeerAddress.fromSocketAddress(toLocationAddress)); } aboutLocation.getConnections().stream() .filter(connection -> connection.getType() == PeerAddress.Type.HOSTNAME) .findFirst() .ifPresent(connection -> builder.setHostname(connection.getHostname())); peerConnectionManager.writeItem(toLocation, builder.build(), this); } private void sendIdentity(PeerConnection peerConnection, IdentityGroupItem identityGroupItem) { log.debug("Sending our own identity {} to {}", identityGroupItem, peerConnection); peerConnectionManager.writeItem(peerConnection, new DiscoveryIdentityListItem(List.of(identityGroupItem.getGxsId())), this); } private void askForPgpKeys(PeerConnection peerConnection, Set pgpIds) { var pgpListItem = new DiscoveryPgpListItem(DiscoveryPgpListItem.Mode.GET_CERT, pgpIds); peerConnectionManager.writeItem(peerConnection, pgpListItem, this); } private void sendOwnContacts(PeerConnection peerConnection) { if (!locationService.findOwnLocation().orElseThrow().isDiscoverable()) { return; } var pgpIds = profileService.getAllDiscoverableProfiles().stream() .map(Profile::getPgpIdentifier) .collect(toSet()); log.debug("Sending list of friends..."); assert !pgpIds.isEmpty(); peerConnectionManager.writeItem(peerConnection, new DiscoveryPgpListItem(DiscoveryPgpListItem.Mode.FRIENDS, pgpIds), this); } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { if (item instanceof DiscoveryContactItem discoveryContactItem) { handleContact(sender, discoveryContactItem); } else if (item instanceof DiscoveryIdentityListItem discoveryIdentityListItem) { handleIdentityList(sender, discoveryIdentityListItem); } else if (item instanceof DiscoveryPgpListItem discoveryPgpListItem) { handlePgpList(sender, discoveryPgpListItem); } else if (item instanceof DiscoveryPgpKeyItem discoveryPgpKeyItem) { handlePgpKey(sender, discoveryPgpKeyItem); } } private void handleContact(PeerConnection peerConnection, DiscoveryContactItem discoveryContactItem) { var peerLocation = peerConnection.getLocation(); var existingContactLocation = locationService.findLocationByLocationIdentifier(discoveryContactItem.getLocationIdentifier()); existingContactLocation.ifPresentOrElse(contactLocation -> { if (contactLocation.equals(peerLocation)) { // Contact information of the peer updateConnectedContact(peerConnection, discoveryContactItem, peerLocation, contactLocation); } else if (contactLocation.equals(locationService.findOwnLocation().orElseThrow())) { // Contact information about ourselves (this can be used to help us find our external IP address updateOwnContactLocation(discoveryContactItem); } else { // Contact information about our friends updateCommonContactLocation(peerConnection, discoveryContactItem, contactLocation); } }, () -> addNewContactLocation(discoveryContactItem)); } private void updateConnectedContact(PeerConnection peerConnection, DiscoveryContactItem discoveryContactItem, Location peerLocation, Location contactLocation) { log.debug("Peer is sending its own location: {}", discoveryContactItem); if (discoveryContactItem.getPgpIdentifier() != contactLocation.getProfile().getPgpIdentifier()) { log.error("PGP identifier or peer doesn't match the key we have about him. Ignoring."); return; } var updatedLocation = updateLocation(peerLocation, discoveryContactItem); peerConnection.updateLocation(updatedLocation); if (peerLocation.getProfile().isPartial()) { // Ask for its PGP public key log.debug("Asking for PGP public key of peer"); askForPgpKeys(peerConnection, Set.of(peerLocation.getProfile().getPgpIdentifier())); } else { // Send our friends sendOwnContacts(peerConnection); } } private static void updateOwnContactLocation(DiscoveryContactItem discoveryContactItem) { log.debug("Peer is sending our own location: {}", discoveryContactItem); // XXX: process the IP in case we don't find our external address and it could help // XXX: beware! RS seems to send ipv4 address in the ipv6 structure... // XXX: comments also seem to suggest this can be used to check if the connected IP is the same as our external IP (currentConnectedAddress is null/invalid, though (maybe ipv6? grmbl)) } private void updateCommonContactLocation(PeerConnection peerConnection, DiscoveryContactItem discoveryContactItem, Location contactLocation) { if (contactLocation.getProfile().isAccepted()) { log.debug("Would update friend here"); var updatedLocation = updateLocation(contactLocation, discoveryContactItem); peerConnection.updateLocation(updatedLocation); } } private void addNewContactLocation(DiscoveryContactItem discoveryContactItem) { log.debug("New location"); profileService.findProfileByPgpIdentifier(discoveryContactItem.getPgpIdentifier()) .ifPresentOrElse(profile -> { if (profile.isAccepted()) { // New location of a friend var newLocation = Location.createLocation(discoveryContactItem.getLocationName(), profile, discoveryContactItem.getLocationIdentifier()); newLocation = updateLocation(newLocation, discoveryContactItem); log.debug("New location of a friend, added: {}", newLocation); statusNotificationService.setTotalUsers((int) locationService.countLocations()); } else { // Friend of friend, but shouldn't happen because RS only sends common contacts. log.debug("New location for profile {} that we have but is not a friend, ignoring...", profile); } }, () -> { // Friend of friend, but shouldn't happen because RS only sends common contacts. // We don't have any use for those. RS uses them as potential proxies/relays for the DHT, but I have // yet to see this in the wild because it shouldn't happen. log.debug("New location for friend of friend {}, ignoring...", log.isDebugEnabled() ? Id.toString(discoveryContactItem.getPgpIdentifier()) : ""); }); } private Location updateLocation(Location location, DiscoveryContactItem discoveryContactItem) { var addresses = new ArrayList(); if (discoveryContactItem.getExternalAddressV4() != null) { addresses.add(discoveryContactItem.getExternalAddressV4()); // If we have a hostname, use the port from the external address if (isNotBlank(discoveryContactItem.getHostname())) { addresses.add(PeerAddress.fromHostname(discoveryContactItem.getHostname(), ((InetSocketAddress) discoveryContactItem.getExternalAddressV4().getSocketAddress()).getPort())); } } if (discoveryContactItem.getLocalAddressV4() != null) { addresses.add(discoveryContactItem.getLocalAddressV4()); } addresses.addAll(discoveryContactItem.getExternalAddressList()); return locationService.update( location, discoveryContactItem.getLocationName(), discoveryContactItem.getNetMode(), discoveryContactItem.getVersion(), getNetworkMode(discoveryContactItem.getVsDisc(), discoveryContactItem.getVsDht()), addresses); } private void handlePgpList(PeerConnection peerConnection, DiscoveryPgpListItem discoveryPgpListItem) { var ownLocation = locationService.findOwnLocation().orElseThrow(); if (!ownLocation.isDiscoverable()) { return; } if (discoveryPgpListItem.getMode() == DiscoveryPgpListItem.Mode.GET_CERT) { var friends = getMutualFriends(discoveryPgpListItem.getPgpIds()); friends.forEach(profile -> peerConnectionManager.writeItem(peerConnection, new DiscoveryPgpKeyItem(profile.getPgpIdentifier(), profile.getPgpPublicKeyData()), this)); // XXX: RS does that slowly it seems... about one key every few seconds } else if (discoveryPgpListItem.getMode() == DiscoveryPgpListItem.Mode.FRIENDS) { // The peer sent us his list of friends. log.debug("Received peer's list of friends: {}", discoveryPgpListItem); // Only ask for the ones we don't already have, including partial profiles var pgpIds = new HashSet<>(discoveryPgpListItem.getPgpIds()); profileService.findAllCompleteProfilesByPgpIdentifiers(pgpIds).stream() .map(Profile::getPgpIdentifier) .forEach(pgpIds::remove); if (!pgpIds.isEmpty()) { askForPgpKeys(peerConnection, pgpIds); } // Send contact info of all mutual friends with discovery enabled to peer, // including the peer itself if it wants to and also our other locations. var mutualFriends = getMutualFriends(discoveryPgpListItem.getPgpIds()); var locationsToSend = mutualFriends.stream() .map(Profile::getLocations) .flatMap(List::stream) .filter(location -> !location.equals(ownLocation)) // own location was sent at beginning .filter(location -> location.getName() != null) // Do not send locations that have no name (they have been automatically added using the profile) .toList(); locationsToSend.forEach(location -> sendContact(peerConnection, location)); // Inform all our online mutual friends about peer (except itself as we just sent it above). locationsToSend.stream() .filter(location -> !location.equals(peerConnection.getLocation()) && location.isConnected()) .forEach(location -> sendContact(location, peerConnection.getLocation())); } } private List getMutualFriends(Set pgpIds) { return profileService.findAllDiscoverableProfilesByPgpIdentifiers(pgpIds); } private void handlePgpKey(PeerConnection peerConnection, DiscoveryPgpKeyItem discoveryPgpKeyItem) { try { log.debug("Got PGP key for ID {}", log.isDebugEnabled() ? Id.toString(discoveryPgpKeyItem.getPgpIdentifier()) : ""); var pgpPublicKey = PGP.getPGPPublicKey(discoveryPgpKeyItem.getKeyData()); if (discoveryPgpKeyItem.getPgpIdentifier() != pgpPublicKey.getKeyID()) { log.warn("PGP key from {} has an ID ({}) which doesn't match the advertised ID {}", peerConnection.getLocation(), pgpPublicKey.getKeyID(), discoveryPgpKeyItem.getPgpIdentifier()); return; } var profileFingerprint = new ProfileFingerprint(pgpPublicKey.getFingerprint()); profileService.findProfileByPgpFingerprint(profileFingerprint) .ifPresentOrElse(profile -> { //noinspection StatementWithEmptyBody if (profile.isPartial()) { // The PGP key is about a partial profile, thoroughly check if the peer is the partial profile itself if (discoveryPgpKeyItem.getPgpIdentifier() == peerConnection.getLocation().getProfile().getPgpIdentifier() // Incoming key PGP id is the one of the remote peer && profile.getPgpIdentifier() == peerConnection.getLocation().getProfile().getPgpIdentifier() // ShortInvite PGP ID matches remote peer && profileFingerprint.equals(peerConnection.getLocation().getProfile().getProfileFingerprint())) // Incoming key fingerprint matches remote peer { // We can save its PGP key and promote it to full profile. profile.setPgpPublicKeyData(discoveryPgpKeyItem.getKeyData()); profile.setCreated(pgpPublicKey.getCreationTime().toInstant()); profileService.createOrUpdateProfile(profile); sendOwnContacts(peerConnection); } } else { // XXX: check the key and complain if it doesn't match } }, () -> { // Create a new profile and save the key log.debug("Creating new profile for id {}", log.isDebugEnabled() ? Id.toString(discoveryPgpKeyItem.getPgpIdentifier()) : ""); var newProfile = createNewProfile(pgpPublicKey); profileService.createOrUpdateProfile(newProfile); }); } catch (InvalidKeyException _) { log.warn("Invalid PGP public key for profile id {}", Id.toString(discoveryPgpKeyItem.getPgpIdentifier())); } } private static Profile createNewProfile(PGPPublicKey pgpPublicKey) { try { return Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded()); } catch (IOException e) { throw new IllegalArgumentException("Error while reading PGP public key for PGP id {}" + pgpPublicKey.getUserIDs().next() + ": {}" + e.getMessage()); } } private void handleIdentityList(PeerConnection peerConnection, DiscoveryIdentityListItem discoveryIdentityListItem) { log.debug("Got identities from friend: {}, requesting...", discoveryIdentityListItem); var friends = new HashSet<>(discoveryIdentityListItem.getIdentities()); identityManager.setAsFriend(friends); identityManager.fetchGxsGroups(peerConnection, friends); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryContactItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.discovery.item; import io.netty.buffer.ByteBuf; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.FieldSize; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.NetMode; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.*; import static io.xeres.app.xrs.serialization.TlvType.*; public class DiscoveryContactItem extends Item implements RsSerializable { private long pgpIdentifier; private LocationIdentifier locationIdentifier; private String locationName; private String version; private Set netMode; // 1: UDP, 2: UPNP, 3: EXT, 4: HIDDEN, 5: UNREACHABLE private short vsDisc; // 0: off, 1: minimal (never implemented I think), 2: full private short vsDht; // 0: off, 1: passive (never implemented too?!), 2: full private int lastContact; private String hiddenAddress; private short hiddenPort; private PeerAddress localAddressV4; private PeerAddress externalAddressV4; private PeerAddress localAddressV6; private PeerAddress externalAddressV6; private PeerAddress currentConnectAddress; private String hostname; private List localAddressList = new ArrayList<>(); private List externalAddressList = new ArrayList<>(); @SuppressWarnings("unused") public DiscoveryContactItem() { } private DiscoveryContactItem(Builder builder) { pgpIdentifier = builder.pgpIdentifier; locationIdentifier = builder.locationIdentifier; locationName = builder.location; version = builder.version; netMode = EnumSet.of(builder.netMode); vsDisc = builder.vsDisc; vsDht = builder.vsDht; lastContact = builder.lastContact; hiddenAddress = builder.hiddenAddress; hiddenPort = builder.hiddenPort; localAddressV4 = builder.localAddressV4; externalAddressV4 = builder.externalAddressV4; localAddressV6 = builder.localAddressV6; externalAddressV6 = builder.externalAddressV6; currentConnectAddress = builder.currentConnectAddress; hostname = builder.hostname; if (builder.localAddressList != null) { localAddressList = builder.localAddressList; } if (builder.externalAddressList != null) { externalAddressList = builder.externalAddressList; } } @Override public int getServiceType() { return RsServiceType.DISCOVERY.getType(); } @Override public int getSubType() { return 5; } @Override public int getPriority() { return ItemPriority.BACKGROUND.getPriority(); } public static Builder builder() { return new Builder(); } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, pgpIdentifier); size += serialize(buf, locationIdentifier, LocationIdentifier.class); size += serialize(buf, STR_LOCATION, locationName); size += serialize(buf, STR_VERSION, version); size += serialize(buf, netMode, FieldSize.INTEGER); size += serialize(buf, vsDisc); size += serialize(buf, vsDht); size += serialize(buf, lastContact); if (hiddenAddress != null) { size += serialize(buf, STR_DOM_ADDR, hiddenAddress); size += serialize(buf, hiddenPort); } else { size += serialize(buf, ADDRESS, localAddressV4); size += serialize(buf, ADDRESS, externalAddressV4); size += serialize(buf, ADDRESS, localAddressV6); size += serialize(buf, ADDRESS, externalAddressV6); size += serialize(buf, ADDRESS, currentConnectAddress); size += serialize(buf, STR_DYNDNS, hostname); size += serialize(buf, ADDRESS_SET, localAddressList); size += serialize(buf, ADDRESS_SET, externalAddressList); } return size; } @Override @SuppressWarnings("unchecked") public void readObject(ByteBuf buf) { pgpIdentifier = deserializeLong(buf); locationIdentifier = (LocationIdentifier) deserializeIdentifier(buf, LocationIdentifier.class); locationName = (String) deserialize(buf, STR_LOCATION); version = (String) deserialize(buf, STR_VERSION); netMode = deserializeEnumSet(buf, NetMode.class, FieldSize.INTEGER); vsDisc = deserializeShort(buf); vsDht = deserializeShort(buf); lastContact = deserializeInt(buf); if (buf.getUnsignedShort(buf.readerIndex()) == STR_DOM_ADDR.getValue()) // RS uses a hack to parse hidden addresses, so we do the same :/ { // is hidden address hiddenAddress = (String) deserialize(buf, STR_DOM_ADDR); hiddenPort = deserializeShort(buf); } else { // is normal address localAddressV4 = (PeerAddress) deserialize(buf, ADDRESS); externalAddressV4 = (PeerAddress) deserialize(buf, ADDRESS); localAddressV6 = (PeerAddress) deserialize(buf, ADDRESS); externalAddressV6 = (PeerAddress) deserialize(buf, ADDRESS); currentConnectAddress = (PeerAddress) deserialize(buf, ADDRESS); hostname = (String) deserialize(buf, STR_DYNDNS); localAddressList = (List) deserialize(buf, ADDRESS_SET); externalAddressList = (List) deserialize(buf, ADDRESS_SET); } } public long getPgpIdentifier() { return pgpIdentifier; } public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } public String getLocationName() { return locationName; } public String getVersion() { return version; } public NetMode getNetMode() { // TODO: find if there's a better way to handle that netmode... RS used a flag even though it really should be a value... if (netMode.contains(NetMode.HIDDEN)) { return NetMode.HIDDEN; } else if (netMode.contains(NetMode.EXT)) { return NetMode.EXT; } else if (netMode.contains(NetMode.UPNP)) { return NetMode.UPNP; } else if (netMode.contains(NetMode.UDP)) { return NetMode.UDP; } else if (netMode.contains(NetMode.UNREACHABLE)) { return NetMode.UNREACHABLE; } else { return NetMode.UNKNOWN; } } public short getVsDisc() { return vsDisc; } public short getVsDht() { return vsDht; } public int getLastContact() { return lastContact; } public String getHiddenAddress() { return hiddenAddress; } public short getHiddenPort() { return hiddenPort; } public PeerAddress getLocalAddressV4() { return localAddressV4; } public PeerAddress getExternalAddressV4() { return externalAddressV4; } public PeerAddress getLocalAddressV6() { return localAddressV6; } public PeerAddress getExternalAddressV6() { return externalAddressV6; } public PeerAddress getCurrentConnectAddress() { return currentConnectAddress; } public String getHostname() { return hostname; } public List getLocalAddressList() { return localAddressList; } public List getExternalAddressList() { return externalAddressList; } @Override public DiscoveryContactItem clone() { return (DiscoveryContactItem) super.clone(); } @Override public String toString() { return "DiscoveryContactItem{" + "pgpIdentifier=" + Id.toString(pgpIdentifier) + ", locationIdentifier=" + locationIdentifier + ", location='" + locationName + '\'' + ", version='" + version + '\'' + ", netMode=" + netMode + ", vsDisc=" + vsDisc + ", vsDht=" + vsDht + ", lastContact=" + lastContact + ", hiddenAddress='" + hiddenAddress + '\'' + ", hiddenPort=" + hiddenPort + ", localAddressV4=" + localAddressV4 + ", externalAddressV4=" + externalAddressV4 + ", localAddressV6=" + localAddressV6 + ", externalAddressV6=" + externalAddressV6 + ", currentConnectAddress=" + currentConnectAddress + ", hostname='" + hostname + '\'' + ", localAddressList=" + localAddressList + ", externalAddressList=" + externalAddressList + '}'; } public static final class Builder { private long pgpIdentifier; private LocationIdentifier locationIdentifier; private String location; private String version; private NetMode netMode; private short vsDisc; private short vsDht; private int lastContact; private String hiddenAddress; private short hiddenPort; private PeerAddress localAddressV4; private PeerAddress externalAddressV4; private PeerAddress localAddressV6; private PeerAddress externalAddressV6; private PeerAddress currentConnectAddress; private String hostname; private List localAddressList; private List externalAddressList; private Builder() { } public Builder setPgpIdentifier(long pgpIdentifier) { this.pgpIdentifier = pgpIdentifier; return this; } public Builder setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; return this; } public Builder setLocationName(String locationName) { location = locationName; return this; } public Builder setVersion(String version) { this.version = version; return this; } public Builder setNetMode(NetMode netMode) { this.netMode = netMode; return this; } public Builder setVsDisc(int vsDisc) { this.vsDisc = (short) vsDisc; return this; } public Builder setVsDht(int vsDht) { this.vsDht = (short) vsDht; return this; } public Builder setLastContact(int lastContact) { this.lastContact = lastContact; return this; } public Builder setHiddenAddress(String hiddenAddress) { this.hiddenAddress = hiddenAddress; return this; } public Builder setHiddenPort(short hiddenPort) { this.hiddenPort = hiddenPort; return this; } public Builder setLocalAddressV4(PeerAddress localAddressV4) { this.localAddressV4 = localAddressV4; return this; } public Builder setExternalAddressV4(PeerAddress externalAddressV4) { this.externalAddressV4 = externalAddressV4; return this; } public Builder setLocalAddressV6(PeerAddress localAddressV6) { this.localAddressV6 = localAddressV6; return this; } public Builder setExternalAddressV6(PeerAddress externalAddressV6) { this.externalAddressV6 = externalAddressV6; return this; } public Builder setCurrentConnectAddress(PeerAddress currentConnectAddress) { this.currentConnectAddress = currentConnectAddress; return this; } public Builder setHostname(String hostname) { this.hostname = hostname; return this; } public Builder setLocalAddressList(List localAddressList) { this.localAddressList = localAddressList; return this; } public Builder setExternalAddressList(List externalAddressList) { this.externalAddressList = externalAddressList; return this; } public DiscoveryContactItem build() { return new DiscoveryContactItem(this); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryIdentityListItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.discovery.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.GxsId; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.ArrayList; import java.util.List; import static java.util.stream.Collectors.joining; public class DiscoveryIdentityListItem extends Item { @RsSerialized private final List identities = new ArrayList<>(); @SuppressWarnings("unused") public DiscoveryIdentityListItem() { } public DiscoveryIdentityListItem(List identities) { this.identities.addAll(identities); } @Override public int getServiceType() { return RsServiceType.DISCOVERY.getType(); } @Override public int getSubType() { return 6; } @Override public int getPriority() { return ItemPriority.BACKGROUND.getPriority(); } public List getIdentities() { return identities; } @Override public DiscoveryIdentityListItem clone() { return (DiscoveryIdentityListItem) super.clone(); } @Override public String toString() { return "DiscoveryIdentityListItem{" + "identities=" + identities.stream().map(Object::toString).collect(joining(", ")) + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryPgpKeyItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.discovery.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Id; import io.xeres.common.protocol.xrs.RsServiceType; public class DiscoveryPgpKeyItem extends Item { @RsSerialized private long pgpIdentifier; @RsSerialized private byte[] keyData; @SuppressWarnings("unused") public DiscoveryPgpKeyItem() { } public DiscoveryPgpKeyItem(long pgpIdentifier, byte[] keyData) { this.pgpIdentifier = pgpIdentifier; this.keyData = keyData; } @Override public int getServiceType() { return RsServiceType.DISCOVERY.getType(); } @Override public int getSubType() { return 9; } @Override public int getPriority() { return ItemPriority.BACKGROUND.getPriority(); } public long getPgpIdentifier() { return pgpIdentifier; } public byte[] getKeyData() { return keyData; } @Override public DiscoveryPgpKeyItem clone() { return (DiscoveryPgpKeyItem) super.clone(); } @Override public String toString() { return "DiscoveryPgpKeyItem{" + "pgpIdentifier=" + Id.toString(pgpIdentifier) + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryPgpListItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.discovery.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.common.id.Id; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.Collections; import java.util.HashSet; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.*; import static io.xeres.app.xrs.serialization.TlvType.SET_PGP_ID; import static java.util.stream.Collectors.joining; public class DiscoveryPgpListItem extends Item implements RsSerializable { public enum Mode { NONE, FRIENDS, GET_CERT } private Mode mode; private Set pgpIds = new HashSet<>(); @SuppressWarnings("unused") public DiscoveryPgpListItem() { } public DiscoveryPgpListItem(Mode mode, Set pgpIds) { this.mode = mode; this.pgpIds = pgpIds; } @Override public int getServiceType() { return RsServiceType.DISCOVERY.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return ItemPriority.BACKGROUND.getPriority(); } public Mode getMode() { return mode; } public Set getPgpIds() { return Collections.unmodifiableSet(pgpIds); } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, mode); size += serialize(buf, SET_PGP_ID, pgpIds); return size; } @Override @SuppressWarnings("unchecked") public void readObject(ByteBuf buf) { mode = deserializeEnum(buf, Mode.class); pgpIds = (Set) deserialize(buf, SET_PGP_ID); } @Override public DiscoveryPgpListItem clone() { return (DiscoveryPgpListItem) super.clone(); } @Override public String toString() { return "DiscoveryPgpListItem{" + "mode=" + mode + ", pgpIds=" + pgpIds.stream().map(Id::toString).collect(joining(", ")) + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/Action.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; sealed interface Action permits ActionAddPeer, ActionDownload, ActionGetDownloadsProgress, ActionGetUploadsProgress, ActionReceiveChunkMap, ActionReceiveChunkMapRequest, ActionReceiveData, ActionReceiveDataRequest, ActionReceiveSingleChunkCrc, ActionReceiveSingleChunkCrcRequest, ActionRemoveDownload, ActionRemovePeer { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionAddPeer.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; record ActionAddPeer(Sha1Sum hash, Location location) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionDownload.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; import java.util.BitSet; record ActionDownload(long id, String name, Sha1Sum hash, long size, LocationIdentifier locationIdentifier, BitSet chunkMap) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionGetDownloadsProgress.java ================================================ package io.xeres.app.xrs.service.filetransfer; record ActionGetDownloadsProgress() implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionGetUploadsProgress.java ================================================ package io.xeres.app.xrs.service.filetransfer; record ActionGetUploadsProgress() implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveChunkMap.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; import java.util.List; record ActionReceiveChunkMap(Location location, Sha1Sum hash, List compressedChunkMap) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveChunkMapRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; record ActionReceiveChunkMapRequest(Location location, Sha1Sum hash, boolean isLeecher) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveData.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; record ActionReceiveData(Location location, Sha1Sum hash, long offset, byte[] data) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveDataRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; record ActionReceiveDataRequest(Location location, Sha1Sum hash, long offset, int chunkSize) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveSingleChunkCrc.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; record ActionReceiveSingleChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveSingleChunkCrcRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; record ActionReceiveSingleChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionRemoveDownload.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; record ActionRemoveDownload(long id) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionRemovePeer.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; record ActionRemovePeer(Sha1Sum hash, Location location) implements Action { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/Chunk.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE; /** * Represents a chunk. Is made up of several blocks of data. */ class Chunk { // hiBlocks and lowBlocks aren't necessary, but they could be used to re-ask only for the missing block instead of the whole chunk private long hiBlocks; private long lowBlocks; private final int totalBlocks; private int remainingBlocks; /** * Creates a chunk. * * @param size is at most {@link FileTransferRsService#CHUNK_SIZE} but can be less if the end of the file is within the last chunk */ public Chunk(long size) { if (size > CHUNK_SIZE) { throw new IllegalArgumentException("Chunk size is greater than " + CHUNK_SIZE); } totalBlocks = (int) (size / BLOCK_SIZE + (size % BLOCK_SIZE != 0 ? 1 : 0)); remainingBlocks = totalBlocks; } /** * Marks the block as written. * * @param offset the offset within the file * @param size the total written size */ public void setBlocksAsWritten(long offset, int size) { if (offset % BLOCK_SIZE != 0) { throw new IllegalArgumentException("Wrong block offset: " + offset); } while (size > 0) { var blockOffset = offset % CHUNK_SIZE; var blockIndex = blockOffset / BLOCK_SIZE; if (blockIndex < 64) { if ((lowBlocks & 1L << blockIndex) > 0) { return; // Already set } lowBlocks |= 1L << blockIndex; } else { if ((hiBlocks & 1L << blockIndex - 64) > 0) { return; // Already set } hiBlocks |= 1L << blockIndex - 64; } remainingBlocks--; size -= BLOCK_SIZE; offset += BLOCK_SIZE; } } /** * Checks if the chunk has all data written to it. * * @return true if complete */ public boolean isComplete() { return remainingBlocks == 0; } @Override public String toString() { return "Chunk{" + "hiBlocks=" + hiBlocks + ", lowBlocks=" + lowBlocks + ", totalBlocks=" + totalBlocks + ", remainingBlocks=" + remainingBlocks + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ChunkDistributor.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.ThreadLocalRandom; import static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.LINEAR; /** * Used to track which chunks are still remaining for a file to be complete. */ class ChunkDistributor { private static final int MAX_RANDOM_TRY = 10; /** * Time to consider a given chunk as "lost". * XXX: add a way to update that value when a write for that chunk is received. if possible, make the timeout shorter then */ private static final Duration GIVEN_CHUNK_TIMEOUT = Duration.ofMinutes(10); private final BitSet chunkMap; // This is updated externally private final Map givenChunks = new HashMap<>(); private final int totalChunks; private final FileTransferStrategy fileTransferStrategy; private int minChunk; private int maxChunk; public ChunkDistributor(BitSet chunkMap, int totalChunks, FileTransferStrategy fileTransferStrategy) { Objects.requireNonNull(chunkMap); if (totalChunks < 1) { throw new IllegalArgumentException("totalChunks must be greater than 0"); } this.chunkMap = chunkMap; this.totalChunks = totalChunks; this.fileTransferStrategy = fileTransferStrategy; } private void updateChunksInfo() { minChunk = chunkMap.nextClearBit(Math.max(minChunk, 0)); maxChunk = chunkMap.previousClearBit(totalChunks - 1); // The given chunks that were downloaded should be // removed to consolidate the set. var beforeSize = givenChunks.size(); givenChunks.entrySet().removeIf(entry -> chunkMap.get(entry.getKey()) || givenChunkIsTooOld(entry.getValue())); if (fileTransferStrategy == LINEAR && beforeSize != givenChunks.size()) { minChunk = findMinChunk(); } } private boolean givenChunkIsTooOld(Instant given) { return given.isBefore(Instant.now().minus(GIVEN_CHUNK_TIMEOUT)); } private int findMinChunk() { minChunk = chunkMap.nextClearBit(0); while (givenChunks.containsKey(minChunk) || chunkMap.get(minChunk)) { minChunk++; } if (minChunk > maxChunk) { minChunk = -1; } return minChunk; } /** * Gets a next available chunk to fill in. * * @return an empty chunk which needs to be filled in */ public Optional getNextChunk(BitSet availableChunks) { updateChunksInfo(); // When maxChunk is -1, there's no free chunk left. // minChunk has a wrong value in that case because BitSet has no // concept of maximum bits, so it will always find a "free" bit. if (maxChunk == -1 || minChunk == -1 || chunkMap.cardinality() + givenChunks.size() == totalChunks) { return Optional.empty(); } var chunk = fileTransferStrategy == LINEAR ? getLinearChunk() : getRandomChunk(); if (!availableChunks.get(chunk)) { return Optional.empty(); } givenChunks.put(chunk, Instant.now()); return Optional.of(chunk); } private int getLinearChunk() { if (givenChunks.containsKey(minChunk) || chunkMap.get(minChunk)) { minChunk++; } return minChunk; } private int getRandomChunk() { int chunk; var attempt = 0; do { chunk = ThreadLocalRandom.current().nextInt(minChunk, maxChunk + 1); } while (givenChunks.containsKey(chunk) && attempt++ < MAX_RANDOM_TRY); if (givenChunks.containsKey(chunk)) { for (int i = minChunk; i <= maxChunk; i++) { if (!givenChunks.containsKey(i)) { return i; } } throw new IllegalStateException("Couldn't return random chunk. Shouldn't happen"); } return chunk; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ChunkMapUtils.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; import java.util.BitSet; import java.util.List; final class ChunkMapUtils { private ChunkMapUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Converts the chunkMap to the format used by RS. Note that there might * be spurious unset chunks at the end. This is normal and RS also does that * because the file size is taken into account when searching chunks. * * @param chunkMap the chunk map * @return a compressed chunk map */ static List toCompressedChunkMap(BitSet chunkMap) { var intBuf = ByteBuffer.wrap(alignArray(chunkMap.toByteArray())) .order(ByteOrder.LITTLE_ENDIAN) .asIntBuffer(); var ints = new int[intBuf.remaining()]; intBuf.get(ints); return Arrays.stream(ints).boxed().toList(); } static BitSet toBitSet(List chunkMap) { var bitSet = new BitSet(chunkMap.size() * 32); for (var i = 0; i < chunkMap.size(); i++) { var value = chunkMap.get(i); for (var j = 0; j < 32; j++) { bitSet.set(i * 32 + j, (value & (1 << j)) != 0); } } return bitSet; } /** * Aligns the array to an integer (32-bits) boundary. * * @param src the source array * @return the array aligned to an integer boundary */ private static byte[] alignArray(byte[] src) { if (src.length % 4 != 0) { var dst = new byte[src.length + (4 - src.length % 4)]; System.arraycopy(src, 0, dst, 0, src.length); return dst; } return src; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/ChunkReceiver.java ================================================ package io.xeres.app.xrs.service.filetransfer; import java.util.BitSet; class ChunkReceiver { private boolean receiving; private int chunkNumber; private BitSet chunkMap; public boolean isReceiving() { return receiving; } public void setReceiving(boolean receiving) { this.receiving = receiving; } public int getChunkNumber() { return chunkNumber; } public void setChunkNumber(int chunkNumber) { this.chunkNumber = chunkNumber; } public boolean hasChunkMap() { return chunkMap != null; } public BitSet getChunkMap() { return chunkMap; } public void setChunkMap(BitSet chunkMap) { this.chunkMap = chunkMap; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileDownload.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.common.id.Sha1Sum; import io.xeres.common.util.OsUtils; import org.apache.commons.lang3.SystemUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.file.Files; import java.util.BitSet; import java.util.HashMap; import java.util.Map; import java.util.Optional; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE; import static java.nio.file.StandardOpenOption.*; /** * This implementation of {@link FileProvider} is for downloading a file. */ class FileDownload extends FileUpload { private static final Logger log = LoggerFactory.getLogger(FileDownload.class); private RandomAccessFile randomAccessFile; private final long id; private final BitSet chunkMap; private final int nBits; private final ChunkDistributor chunkDistributor; private final Map chunks = new HashMap<>(); private long bytesWritten; public FileDownload(long id, File file, long size, BitSet chunkMap, FileTransferStrategy fileTransferStrategy) { super(file); this.id = id; fileSize = size; nBits = (int) (size / CHUNK_SIZE + (size % CHUNK_SIZE != 0 ? 1 : 0)); this.chunkMap = chunkMap != null ? chunkMap : new BitSet(nBits); bytesWritten = (long) this.chunkMap.cardinality() * CHUNK_SIZE; chunkDistributor = new ChunkDistributor(this.chunkMap, nBits, fileTransferStrategy); } @Override public boolean open() { try { createSparseFile(); randomAccessFile = new RandomAccessFile(file, "rw"); OsUtils.setFileVisible(file.toPath(), false); ensureSparseFile(); channel = randomAccessFile.getChannel(); lock = channel.lock(); // Exclusive lock return true; } catch (IOException e) { log.error("Couldn't open file {} for writing", file, e); return false; } } /** * This creates a sparse file on Windows. *

* The file must not exist and is then marked as such. * (Write once, run anywhere, my ass...). * * @throws IOException if some I/O error happens */ private void createSparseFile() throws IOException { if (SystemUtils.IS_OS_WINDOWS && !file.exists()) { try (var seekableByteChannel = Files.newByteChannel(file.toPath(), CREATE_NEW, WRITE, SPARSE)) { seekableByteChannel.position(fileSize - 1); seekableByteChannel.write(ByteBuffer.wrap(new byte[]{(byte) 0})); } } } /** * This ensures the file is sparse. Basically on Linux and MacOS, we just have to * set the length, and it's sparse by default. * * @throws IOException if some I/O error happens */ private void ensureSparseFile() throws IOException { if (!SystemUtils.IS_OS_WINDOWS) { randomAccessFile.setLength(fileSize); } } @Override public byte[] read(long offset, int size) throws IOException { if (isChunkAvailable(offset, size)) { return super.read(offset, size); } throw new IOException("File at offset " + offset + " with size " + size + " is not available yet."); } @Override public void write(long offset, byte[] data) throws IOException { var buf = ByteBuffer.wrap(data); var size = channel.write(buf, offset); bytesWritten += size; if (size != data.length) { throw new IOException("Failed to write data, requested size: " + data.length + ", actually written: " + size); } markBlocksAsWritten(offset, size); } @Override public void close() { try { lock.close(); channel.close(); randomAccessFile.close(); } catch (IOException e) { log.error("Failed to close file {} properly", file, e); } } @Override public void closeAndDelete() { close(); try { Files.delete(file.toPath()); } catch (IOException e) { log.error("Couldn't delete file {} properly: {}", file, e.getMessage()); } } @Override public BitSet getChunkMap() { return (BitSet) chunkMap.clone(); } @Override public boolean isComplete() { return chunkMap.cardinality() == nBits; } @Override public Optional getNeededChunk(BitSet chunkMap) { return chunkDistributor.getNextChunk(chunkMap); } @Override public boolean hasChunk(int index) { return chunkMap.get(index); } private boolean isChunkAvailable(long offset, int chunkSize) { int chunkStart = (int) (offset / chunkSize); int chunkEnd = (int) ((offset + chunkSize) / chunkSize); if ((offset + chunkSize) % chunkSize != 0) { chunkEnd++; } for (var i = chunkStart; i < chunkEnd; i++) { if (!chunkMap.get(i)) { return false; } } return true; } private void markBlocksAsWritten(long offset, int size) { int chunkKey = (int) (offset / CHUNK_SIZE); var chunk = chunks.computeIfAbsent(chunkKey, _ -> new Chunk(Math.min(CHUNK_SIZE, fileSize - offset))); chunk.setBlocksAsWritten(offset, size); if (chunk.isComplete()) { chunkMap.set(chunkKey); chunks.remove(chunkKey); } } @Override public Sha1Sum computeHash(long offset) { throw new IllegalStateException("Cannot compute hashes of files being downloaded"); } @Override public long getBytesWritten() { return bytesWritten; } @Override public long getId() { return id; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileLeecher.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import java.util.ArrayList; import java.util.List; class FileLeecher extends FilePeer { private final List sliceSenders = new ArrayList<>(2); FileLeecher(Location location) { super(location); } public void addSliceSender(SliceSender sender) { sliceSenders.add(sender); } public SliceSender getSliceSender() { return sliceSenders.getFirst(); } public void removeSliceSender(SliceSender sender) { sliceSenders.remove(sender); } public boolean hasNoMoreSlices() { return sliceSenders.isEmpty(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FilePeer.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import java.time.Duration; import java.time.Instant; /** * Note: this class has a natural ordering that is inconsistent with equals. */ abstract class FilePeer implements Comparable { private final Location location; private Instant nextScheduling = Instant.EPOCH; FilePeer(Location location) { this.location = location; } public Location getLocation() { return location; } public Instant getNextScheduling() { return nextScheduling; } public void addNextScheduling(Duration duration) { nextScheduling = Instant.now().plus(duration); } @Override public int compareTo(FilePeer o) { return nextScheduling.compareTo(o.getNextScheduling()); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileProvider.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.common.id.Sha1Sum; import java.io.IOException; import java.nio.file.Path; import java.util.BitSet; import java.util.Optional; /** * Represents a local file. Can be complete or being completed. */ interface FileProvider { long getFileSize(); boolean open(); byte[] read(long offset, int size) throws IOException; void write(long offset, byte[] data) throws IOException; void close(); void closeAndDelete(); BitSet getChunkMap(); Optional getNeededChunk(BitSet chunkMap); boolean hasChunk(int index); boolean isComplete(); Path getPath(); long getBytesWritten(); long getId(); Sha1Sum computeHash(long offset); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileSeeder.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import java.util.BitSet; public class FileSeeder extends FilePeer { private final ChunkReceiver chunkReceiver = new ChunkReceiver(); FileSeeder(Location location) { super(location); } public void updateChunkMap(BitSet chunkMap) { chunkReceiver.setChunkMap(chunkMap); } public void setReceiving(boolean receiving) { chunkReceiver.setReceiving(receiving); } public boolean isReceiving() { return chunkReceiver.isReceiving(); } public int getChunkNumber() { return chunkReceiver.getChunkNumber(); } public boolean hasChunkMap() { return chunkReceiver.hasChunkMap(); } public BitSet getChunkMap() { return chunkReceiver.getChunkMap(); } public void setChunkNumber(int chunkNumber) { chunkReceiver.setChunkNumber(chunkNumber); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferAgent.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; import io.xeres.common.util.FileNameUtils; import io.xeres.common.util.OsUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.*; /** * Responsible for sending/receiving one file. * There can be several leechers or seeders per file. */ class FileTransferAgent { private static final Logger log = LoggerFactory.getLogger(FileTransferAgent.class); /** * Time after which a download or upload is considered stale. */ private static final long IDLE_TIME = Duration.ofMinutes(5).toNanos(); private final FileTransferRsService fileTransferRsService; private final FileProvider fileProvider; private final Sha1Sum hash; private final String fileName; private boolean done; private long lastActivity; private boolean trusted; private final Map leechers = new LinkedHashMap<>(); private final Map seeders = new LinkedHashMap<>(); private final PriorityQueue queue = new PriorityQueue<>(); public FileTransferAgent(FileTransferRsService fileTransferRsService, String fileName, Sha1Sum hash, FileProvider fileProvider) { this.fileTransferRsService = fileTransferRsService; this.hash = hash; this.fileProvider = fileProvider; this.fileName = fileName; lastActivity = System.nanoTime(); } public void setTrusted(boolean trusted) { this.trusted = trusted; } public FileProvider getFileProvider() { return fileProvider; } public String getFileName() { return fileName; } public void addSeeder(Location peer) { seeders.computeIfAbsent(peer, _ -> { var fileSeeder = new FileSeeder(peer); queue.add(fileSeeder); return fileSeeder; }); fileTransferRsService.sendChunkMapRequest(peer, hash, false); } public void addLeecher(Location peer, long offset, int size) { leechers.computeIfAbsent(peer, _ -> { var fileLeecher = new FileLeecher(peer); queue.add(fileLeecher); return fileLeecher; }).addSliceSender(new SliceSender(fileTransferRsService, peer, fileProvider, hash, fileProvider.getFileSize(), offset, size)); } public void removePeer(Location peer) { FilePeer removed = seeders.remove(peer); if (removed == null) { removed = leechers.remove(peer); } if (removed == null) { log.warn("Removal of peer {} failed because it's not in the list. This shouldn't happen.", peer); } queue.remove(removed); } /** * Processes file transfers. * * @return true if processing, false if there's nothing to process */ public boolean process() { processPeers(); return queue.isEmpty(); } public void cancel() { if (!fileProvider.isComplete()) { fileProvider.closeAndDelete(); } } public void stop() { fileProvider.close(); } public void addChunkMap(Location peer, BitSet chunkMap) { var seeder = seeders.get(peer); if (seeder == null) { log.error("Seeder not found for adding chunkmap"); return; } seeder.updateChunkMap(chunkMap); } /** * Tells if an agent idle. That is, nothing has been sent or received * for more than 5 minutes. * * @return true if idle */ public boolean isIdle() { return System.nanoTime() - lastActivity > IDLE_TIME; } public boolean isDone() // XXX: isDone what? it's only when it's done loading, should be clearer { return done; } /** * Returns the next desired processing. * * @return when the next processing happens, null if there's no processing needed */ public Instant getNextProcessing() { var filePeer = queue.peek(); if (filePeer != null) { return filePeer.getNextScheduling(); } return null; } private void processPeers() { var filePeer = queue.poll(); switch (filePeer) { case FileSeeder fileSeeder -> processSeeder(fileSeeder); case FileLeecher fileLeecher -> processLeecher(fileLeecher); case null -> { // Empty queue } default -> throw new IllegalStateException("Unhandled peer class"); } } private void processSeeder(FileSeeder fileSeeder) { if (fileSeeder.isReceiving()) { lastActivity = System.nanoTime(); if (fileProvider.hasChunk(fileSeeder.getChunkNumber())) { log.debug("Chunk {} is complete", fileSeeder.getChunkNumber()); fileSeeder.setReceiving(false); } } else { if (fileProvider.isComplete() && !done) { log.debug("File is complete, size: {}, renaming to {}", fileProvider.getFileSize(), fileName); stop(); fileTransferRsService.markDownloadAsCompleted(hash); fileTransferRsService.deactivateTunnels(hash); var newPath = renameFile(fileProvider.getPath(), fileName); setFileSecurity(newPath); removePeer(fileSeeder.getLocation()); done = true; // Prevents closing the file several times (we might have several seeders) return; // Don't reinsert in the queue } else { if (fileSeeder.hasChunkMap()) { getNextChunk(fileSeeder.getChunkMap()).ifPresent(chunkNumber -> { log.debug("Requesting chunk number {} to peer {}", chunkNumber, fileSeeder.getLocation()); fileTransferRsService.sendDataRequest(fileSeeder.getLocation(), hash, fileProvider.getFileSize(), (long) chunkNumber * FileTransferRsService.CHUNK_SIZE, FileTransferRsService.CHUNK_SIZE); fileSeeder.setChunkNumber(chunkNumber); fileSeeder.setReceiving(true); }); } } } // Calculating the next computation would require guessing when we need to ask for the // next chunk. Right now we ask for 1 MB, but we should ask for smaller and progressively bigger (up to 1 MB). addNextScheduling(fileSeeder, Duration.ofMillis(250)); // XXX: use a real computation... not sure it needs to be done in each process*()... maybe in the processPeer() only? check... // XXX: also to know the bandwidth, we have to know to which tunnelId the virtual location maps to, then to which peer the tunnelId maps to and we finally got a bandwidth. // then we also need to take into account the number of tunnels that are shared through that peer... what a mess. maybe we should push that info when creating the FileSeeder/Leecher? } private void setFileSecurity(Path path) { if (path != null) { OsUtils.setFileSecurity(path, trusted); } } private void processLeecher(FileLeecher fileLeecher) { var sliceSender = fileLeecher.getSliceSender(); var remaining = sliceSender.send(); lastActivity = System.nanoTime(); if (!remaining) { // We just remove the leecher here and nothing else. The fileTransferManager will close the file // when it's idle for some time, otherwise it would need to be reopened immediately for the // next slice. fileLeecher.removeSliceSender(sliceSender); if (fileLeecher.hasNoMoreSlices()) { removePeer(fileLeecher.getLocation()); return; } } // Here we could calculate the best time to send the next slice (8 KB) without overflowing our bandwidth addNextScheduling(fileLeecher, Duration.ofMillis(50)); // XXX: see above. this is 160 KB/s... } private void addNextScheduling(FilePeer filePeer, Duration duration) { filePeer.addNextScheduling(duration); queue.offer(filePeer); } private static Path renameFile(Path filePath, String fileName) { var success = false; Path path = null; while (!success) { try { var newPath = filePath.resolveSibling(fileName); Files.move(filePath, newPath); OsUtils.setFileVisible(newPath, true); success = true; path = newPath; } catch (FileAlreadyExistsException _) { log.warn("File name {} already exists, renaming...", fileName); fileName = FileNameUtils.rename(fileName); } catch (InvalidPathException _) { log.warn("File name {} is invalid, trying to fix the characters...", fileName); var newFileName = OsUtils.sanitizeFileName(fileName); if (newFileName.equals(fileName)) { fileName = "InvalidFileName_RenameMe"; log.error("Couldn't find a proper name for file {}, using: {}. Rename by hand and report", filePath, fileName); } else { fileName = newFileName; } } catch (IOException e) { log.error("Couldn't rename the file {} to {}", filePath, fileName, e); success = true; // This is really a failure, but there's nothing else we can do } } return path; } /** * Gets the next available chunk. * * @return the chunk number */ private Optional getNextChunk(BitSet chunkMap) { return fileProvider.getNeededChunk(chunkMap); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferEncryptionKey.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.crypto.hash.sha256.Sha256MessageDigest; import io.xeres.common.id.Sha1Sum; import javax.crypto.SecretKey; import java.io.Serial; class FileTransferEncryptionKey implements SecretKey { @Serial private static final long serialVersionUID = 6540345707970134182L; private final byte[] encoded; public FileTransferEncryptionKey(Sha1Sum hash) { var digest = new Sha256MessageDigest(); digest.update(hash.getBytes()); encoded = digest.getBytes(); } @Override public String getAlgorithm() { return "ChaCha20"; } @Override public String getFormat() { return "RAW"; } @Override public byte[] getEncoded() { return encoded.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferManager.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.location.Location; import io.xeres.app.service.LocationService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.file.FileService; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; import io.xeres.common.rest.file.FileProgress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import static io.xeres.app.service.file.FileService.DOWNLOAD_EXTENSION; import static io.xeres.app.service.file.FileService.DOWNLOAD_PREFIX; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE; /** * File transfer management class. *

* File transfer diagram * The FileTransferManager manages several uploads and downloads. Each of them is represented by one {@link FileTransferAgent}. *

* A FileTransferAgent is paired with a {@link FileProvider} that is either a {@link FileDownload} or a {@link FileUpload} depending on the role of * that agent (respectively, download or upload a file). *

* Each FileTransferAgent has a list of seeders and leechers for itself. *

* Leechers ask for a slice between 1 byte and 1 MB. The result is always sent in packets of 8 KB max. * The goal is to send at the optimum speed depending on our bandwidth, the peer's bandwidth and the peer's RTT. *

* For requesting, ask for a chunk size of some small size, then monitor the speed and RTT while asking for more. We shouldn't * overflow our bandwidth nor the peer's one. We should also ask ahead of time for optimum speed including between chunks. */ class FileTransferManager implements Runnable { private static final Logger log = LoggerFactory.getLogger(FileTransferManager.class); private static final int DEFAULT_TICK = 1000; private final FileTransferRsService fileTransferRsService; private final FileService fileService; private final SettingsService settingsService; private final LocationService locationService; private final DatabaseSessionManager databaseSessionManager; private final Location ownLocation; private final BlockingQueue queue; private final FileTransferStrategy fileTransferStrategy; private final Map downloads = new HashMap<>(); // files that we are downloading (client) private final Map uploads = new HashMap<>(); // files that we are uploading (serving) private final List downloadsProgress = new ArrayList<>(); private final List uploadsProgress = new ArrayList<>(); public FileTransferManager(FileTransferRsService fileTransferRsService, FileService fileService, SettingsService settingsService, LocationService locationService, DatabaseSessionManager databaseSessionManager, Location ownLocation, BlockingQueue queue, FileTransferStrategy fileTransferStrategy) { this.fileTransferRsService = fileTransferRsService; this.fileService = fileService; this.settingsService = settingsService; this.locationService = locationService; this.databaseSessionManager = databaseSessionManager; this.ownLocation = ownLocation; this.queue = queue; this.fileTransferStrategy = fileTransferStrategy; } @Override public void run() { var done = false; while (!done) { try { var action = getNextAction(); processAction(action); processDownloads(); processUploads(); } catch (InterruptedException _) { log.debug("FileTransferManager thread interrupted"); cleanup(); done = true; Thread.currentThread().interrupt(); } } } private void cleanup() { downloads.forEach((hash, download) -> fileService.suspendDownload(hash, download.getFileProvider().getChunkMap())); } private Action getNextAction() throws InterruptedException { if (downloads.isEmpty() && uploads.isEmpty()) { return queue.take(); } else { return queue.poll(computeOptimalWaitingTime(), TimeUnit.MILLISECONDS); } } private long computeOptimalWaitingTime() { var now = Instant.now(); int minWaitingTime = DEFAULT_TICK; var agents = Stream.concat(downloads.values().stream(), uploads.values().stream()) .toList(); for (var agent : agents) { minWaitingTime = Math.min(minWaitingTime, durationBetween(now, agent.getNextProcessing())); if (minWaitingTime == 0) { break; } } log.debug("Calculated optimal time: {}", minWaitingTime); return minWaitingTime; } private static int durationBetween(Instant now, Instant nextDelay) { if (nextDelay == null) { return DEFAULT_TICK; } var duration = Duration.between(now, nextDelay); if (duration.isNegative()) { return 0; } return safeLongToInt(duration.toMillis()); } private static int safeLongToInt(long value) { if (value > Integer.MAX_VALUE) { return Integer.MAX_VALUE; } return (int) value; } public List getDownloadsProgress() { synchronized (downloadsProgress) { //noinspection unchecked return (List) ((ArrayList) downloadsProgress).clone(); } } public List getUploadsProgress() { synchronized (uploadsProgress) { //noinspection unchecked return (List) ((ArrayList) uploadsProgress).clone(); } } private void processDownloads() { downloads.forEach((_, download) -> download.process()); } private void processUploads() { uploads.entrySet().removeIf(upload -> stopStalledUpload(upload.getValue())); uploads.forEach((_, upload) -> upload.process()); } private boolean stopStalledUpload(FileTransferAgent upload) { if (upload.isIdle()) { upload.stop(); return true; } return false; } private void processAction(Action action) { switch (action) { case ActionAddPeer(Sha1Sum hash, Location location) -> actionAddPeer(hash, location); case ActionRemovePeer(Sha1Sum hash, Location location) -> actionRemovePeer(hash, location); case ActionReceiveDataRequest(Location location, Sha1Sum hash, long offset, int chunkSize) -> actionReceiveDataRequest(location, hash, offset, chunkSize); case ActionReceiveData(Location location, Sha1Sum hash, long offset, byte[] data) -> actionReceiveData(location, hash, offset, data); case ActionDownload(long id, String name, Sha1Sum hash, long size, LocationIdentifier from, BitSet chunkMap) -> actionDownload(id, name, hash, size, from, chunkMap); case ActionRemoveDownload(long id) -> actionRemoveDownload(id); case ActionGetDownloadsProgress() -> actionComputeDownloadsProgress(); case ActionGetUploadsProgress() -> actionComputeUploadsProgress(); case ActionReceiveChunkMapRequest(Location location, Sha1Sum hash, boolean isLeecher) -> actionReceiveChunkMapRequest(location, hash, isLeecher); case ActionReceiveChunkMap(Location location, Sha1Sum hash, List compressedChunkMap) -> actionReceiveChunkMap(location, hash, compressedChunkMap); case ActionReceiveSingleChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber) -> actionReceiveChunkCrcRequest(location, hash, chunkNumber); case ActionReceiveSingleChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum) -> actionReceiveChunkCrc(location, hash, chunkNumber, checkSum); case null -> { // This is the return from a timeout. Nothing to do. } } } public void actionDownload(long id, String name, Sha1Sum hash, long size, LocationIdentifier from, BitSet chunkMap) { try (var ignored = new DatabaseSession(databaseSessionManager)) { downloads.computeIfAbsent(hash, sha1Sum -> { var file = Paths.get(settingsService.getIncomingDirectory(), DOWNLOAD_PREFIX + sha1Sum + DOWNLOAD_EXTENSION).toFile(); log.debug("Downloading file {}, size: {}, from: {}", file, size, from); var fileDownload = new FileDownload(id, file, size, chunkMap, from != null ? FileTransferStrategy.LINEAR : fileTransferStrategy); if (fileDownload.open()) { var download = new FileTransferAgent(fileTransferRsService, name, sha1Sum, fileDownload); if (from != null) { download.setTrusted(true); locationService.findLocationByLocationIdentifier(from).ifPresent(download::addSeeder); } else { fileTransferRsService.activateTunnels(sha1Sum); } return download; } else { log.error("Couldn't create file {} for download", file); return null; } }); } } public void actionRemoveDownload(long id) { try (var ignored = new DatabaseSession(databaseSessionManager)) { fileService.findById(id).ifPresent(fileDownload -> { fileTransferRsService.deactivateTunnels(fileDownload.getHash()); var download = downloads.get(fileDownload.getHash()); if (download != null) { download.cancel(); downloads.remove(fileDownload.getHash()); } fileService.removeDownload(id); }); } } private void actionComputeDownloadsProgress() { List newDownloadList = new ArrayList<>(downloads.size()); downloads.forEach((sha1Sum, download) -> newDownloadList.add( new FileProgress(download.getFileProvider().getId(), download.getFileName(), download.getFileProvider().getBytesWritten(), download.getFileProvider().getFileSize(), sha1Sum.toString(), download.isDone()))); synchronized (downloadsProgress) { downloadsProgress.clear(); downloadsProgress.addAll(newDownloadList); } } private void actionComputeUploadsProgress() { List newUploadList = new ArrayList<>(uploads.size()); uploads.forEach((sha1Sum, upload) -> newUploadList.add( new FileProgress(0L, upload.getFileName(), 0L, upload.getFileProvider().getFileSize(), sha1Sum.toString(), upload.isDone()))); synchronized (uploadsProgress) { uploadsProgress.clear(); uploadsProgress.addAll(newUploadList); } } /** * Adds a peer to one of our downloads. * * @param hash the hash of the file being downloaded * @param location the source location to add */ private void actionAddPeer(Sha1Sum hash, Location location) { var download = downloads.get(hash); if (download != null) { download.addSeeder(location); } } /** * Removes a peer from one of our downloads. * * @param hash the hash of the file being downloaded * @param location the source location to remove */ private void actionRemovePeer(Sha1Sum hash, Location location) { var download = downloads.get(hash); if (download != null) { download.removePeer(location); } } private void actionReceiveDataRequest(Location location, Sha1Sum hash, long offset, int chunkSize) { log.debug("Received data request from {}, hash: {}, offset: {}, chunkSize: {}", location, hash, offset, chunkSize); FileTransferAgent upload; //noinspection StatementWithEmptyBody if (location.equals(ownLocation)) { // Own requests must be passed to seeders } else { upload = uploads.get(hash); if (upload == null) { upload = localSearch(hash); } if (upload != null) { handleLeecherRequest(location, upload, hash, offset, chunkSize); } } } private FileTransferAgent localSearch(Sha1Sum hash) { try (var ignored = new DatabaseSession(databaseSessionManager)) { return uploads.computeIfAbsent(hash, h -> fileService.findFilePathByHash(h) .map(Path::toFile) .map(file -> { log.debug("Serving file {} for hash {}", file, hash); var upload = new FileUpload(file); if (!upload.open()) { log.debug("Failed to open file {} for serving", file); return null; } return new FileTransferAgent(fileTransferRsService, file.getName(), h, upload); }) .orElse(null)); } } private void actionReceiveData(Location location, Sha1Sum hash, long offset, byte[] data) { var download = downloads.get(hash); if (download == null) { log.error("No matching download agent for hash {}", hash); return; } try { log.trace("Writing file {}, offset: {}, length: {}", download.getFileName(), offset, data.length); // XXX: update location stats for writing (see how RS does it) var fileProvider = download.getFileProvider(); fileProvider.write(offset, data); } catch (IOException e) { log.error("Failed to write to file", e); } } private void actionReceiveChunkMapRequest(Location location, Sha1Sum hash, boolean isLeecher) { log.debug("Received {} chunk map request from {}, hash: {}", isLeecher ? "leecher (client)" : "seeder (server)", location, hash); if (isLeecher) { actionReceiveLeecherChunkMapRequest(location, hash); } else { actionReceiveSeederChunkMapRequest(location, hash); } } private void actionReceiveChunkMap(Location location, Sha1Sum hash, List compressedChunkMap) { log.debug("Received chunk map from {}", location); var download = downloads.get(hash); if (download == null) { log.error("No matching download agent for hash {} for chunk map", hash); return; } var chunkMap = ChunkMapUtils.toBitSet(compressedChunkMap); download.addChunkMap(location, chunkMap); } private void actionReceiveLeecherChunkMapRequest(Location location, Sha1Sum hash) { var download = downloads.get(hash); if (download == null) { log.error("No matching download agent for hash {} for chunk map request", hash); return; } var compressedChunkMap = ChunkMapUtils.toCompressedChunkMap(download.getFileProvider().getChunkMap()); fileTransferRsService.sendChunkMap(location, hash, false, compressedChunkMap); } private void actionReceiveSeederChunkMapRequest(Location location, Sha1Sum hash) { var upload = uploads.get(hash); if (upload == null) { upload = localSearch(hash); } if (upload == null) { log.error("Chunk map request succeeded but no seeder available"); return; } var compressedChunkMap = ChunkMapUtils.toCompressedChunkMap(upload.getFileProvider().getChunkMap()); fileTransferRsService.sendChunkMap(location, hash, true, compressedChunkMap); } private void actionReceiveChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber) { log.debug("Received chunk crc request from {}", location); var upload = uploads.get(hash); if (upload == null) { upload = localSearch(hash); } if (upload == null) { log.error("No matching upload agent for hash {} for chunk number {}", hash, chunkNumber); return; } // XXX: add a cache, queue for serving them later, etc... var checkSum = upload.getFileProvider().computeHash((long) chunkNumber * CHUNK_SIZE); if (checkSum != null) { fileTransferRsService.sendSingleChunkCrc(location, hash, chunkNumber, checkSum); } } private void actionReceiveChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum) { log.debug("Received chunk crc from {}", location); // XXX: handle! need to check leecher... } private static void handleLeecherRequest(Location location, FileTransferAgent upload, Sha1Sum hash, long offset, int chunkSize) { if (chunkSize > CHUNK_SIZE) { log.warn("Peer {} is requesting a too large chunk ({}) for hash {}, ignoring", location, chunkSize, hash); return; } // XXX: update location stats for reading, see how RS does it upload.addLeecher(location, offset, chunkSize); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferRsService.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.crypto.rscrypto.RsCrypto; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.repository.FileDownloadRepository; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.properties.NetworkProperties; import io.xeres.app.service.LocationService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.file.FileService; import io.xeres.app.service.notification.file.FileSearchNotificationService; import io.xeres.app.service.notification.file.FileTrendNotificationService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemUtils; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.filetransfer.item.*; import io.xeres.app.xrs.service.turtle.TurtleRouter; import io.xeres.app.xrs.service.turtle.TurtleRsClient; import io.xeres.app.xrs.service.turtle.item.*; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.rest.file.FileProgress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.nio.file.Files; import java.util.List; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; import static io.xeres.app.properties.NetworkProperties.*; import static io.xeres.common.protocol.xrs.RsServiceType.FILE_TRANSFER; import static io.xeres.common.protocol.xrs.RsServiceType.TURTLE_ROUTER; @Component public class FileTransferRsService extends RsService implements TurtleRsClient { private static final Logger log = LoggerFactory.getLogger(FileTransferRsService.class); private TurtleRouter turtleRouter; static final int CHUNK_SIZE = 1024 * 1024; // 1 MB static final int BLOCK_SIZE = 1024 * 8; // 8 KB (warning: this got changed to 240 KB (!?) in recent RS) private final FileService fileService; private final PeerConnectionManager peerConnectionManager; private final FileSearchNotificationService fileSearchNotificationService; private final FileTrendNotificationService fileTrendNotificationService; private final RsServiceRegistry rsServiceRegistry; private final DatabaseSessionManager databaseSessionManager; private final LocationService locationService; private final SettingsService settingsService; private final RsCrypto.EncryptionFormat encryptionFormat; private final FileTransferStrategy fileTransferStrategy; private final FileDownloadRepository fileDownloadRepository; private FileTransferManager fileTransferManager; private Thread fileTransferManagerThread; private final BlockingQueue fileCommandQueue = new LinkedBlockingQueue<>(); private final Map encryptedHashes = new ConcurrentHashMap<>(); public FileTransferRsService(RsServiceRegistry rsServiceRegistry, FileService fileService, PeerConnectionManager peerConnectionManager, FileSearchNotificationService fileSearchNotificationService, FileTrendNotificationService fileTrendNotificationService, DatabaseSessionManager databaseSessionManager, LocationService locationService, SettingsService settingsService, NetworkProperties networkProperties, FileDownloadRepository fileDownloadRepository) { super(rsServiceRegistry); this.fileService = fileService; this.peerConnectionManager = peerConnectionManager; this.fileSearchNotificationService = fileSearchNotificationService; this.rsServiceRegistry = rsServiceRegistry; this.fileTrendNotificationService = fileTrendNotificationService; this.databaseSessionManager = databaseSessionManager; this.locationService = locationService; this.settingsService = settingsService; encryptionFormat = getEncryptionFormat(networkProperties); fileTransferStrategy = getFileTransferStrategy(networkProperties); this.fileDownloadRepository = fileDownloadRepository; } private static RsCrypto.EncryptionFormat getEncryptionFormat(NetworkProperties networkProperties) { if (networkProperties.getTunnelEncryption().equals(TUNNEL_ENCRYPTION_CHACHA20_SHA256)) { return RsCrypto.EncryptionFormat.CHACHA20_SHA256; } else if (networkProperties.getTunnelEncryption().equals(TUNNEL_ENCRYPTION_CHACHA20_POLY1305)) { return RsCrypto.EncryptionFormat.CHACHA20_POLY1305; } else { throw new IllegalArgumentException("Unsupported encryption format: " + networkProperties.getTunnelEncryption()); } } private static FileTransferStrategy getFileTransferStrategy(NetworkProperties networkProperties) { if (networkProperties.getFileTransferStrategy().equals(FILE_TRANSFER_STRATEGY_LINEAR)) { return FileTransferStrategy.LINEAR; } else if (networkProperties.getFileTransferStrategy().equals(FILE_TRANSFER_STRATEGY_RANDOM)) { return FileTransferStrategy.RANDOM; } else { throw new IllegalArgumentException("Unsupported file transfer strategy: " + networkProperties.getFileTransferStrategy()); } } @Override public void initialize() { Location ownLocation; try (var ignored = new DatabaseSession(databaseSessionManager)) { ownLocation = locationService.findOwnLocation().orElseThrow(); fileDownloadRepository.deleteAllByCompletedTrue(); fileDownloadRepository.findAllByLocationIsNull() .forEach(file -> fileCommandQueue.add(new ActionDownload(file.getId(), file.getName(), file.getHash(), file.getSize(), null, file.getChunkMap()))); } fileTransferManager = new FileTransferManager(this, fileService, settingsService, locationService, databaseSessionManager, ownLocation, fileCommandQueue, fileTransferStrategy); fileTransferManagerThread = Thread.ofVirtual() .name("File Transfer Manager") .start(fileTransferManager); } @Override public RsServiceType getServiceType() { return FILE_TRANSFER; } @Override public RsServiceType getMasterServiceType() { return TURTLE_ROUTER; } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.NORMAL; } @Override public void initializeTurtle(TurtleRouter turtleRouter) { this.turtleRouter = turtleRouter; } @Override public void initialize(PeerConnection peerConnection) { try (var ignored = new DatabaseSession(databaseSessionManager)) { fileDownloadRepository.findAllByLocation(peerConnection.getLocation()) .forEach(file -> fileCommandQueue.add(new ActionDownload(file.getId(), file.getName(), file.getHash(), file.getSize(), file.getLocation().getLocationIdentifier(), file.getChunkMap()))); } } @Override public void handleItem(PeerConnection sender, Item item) { switch (item) { case FileTransferDataRequestItem ftItem -> // XXX: check for upload limit for this peer and drop it if exceeded! fileCommandQueue.add(new ActionReceiveDataRequest(sender.getLocation(), ftItem.getFileItem().hash(), ftItem.getFileOffset(), ftItem.getChunkSize())); case FileTransferDataItem ftItem -> fileCommandQueue.add(new ActionReceiveData(sender.getLocation(), ftItem.getFileData().fileItem().hash(), ftItem.getFileData().offset(), ftItem.getFileData().data())); case FileTransferChunkMapRequestItem ftItem -> fileCommandQueue.add(new ActionReceiveChunkMapRequest(sender.getLocation(), ftItem.getHash(), ftItem.isLeecher())); case FileTransferChunkMapItem ftItem -> fileCommandQueue.add(new ActionReceiveChunkMap(sender.getLocation(), ftItem.getHash(), ftItem.getCompressedChunks())); case FileTransferSingleChunkCrcRequestItem ftItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrcRequest(sender.getLocation(), ftItem.getHash(), ftItem.getChunkNumber())); case FileTransferSingleChunkCrcItem ftItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrc(sender.getLocation(), ftItem.getHash(), ftItem.getChunkNumber(), ftItem.getCheckSum())); default -> log.debug("Unhandled item {}", item); } } @Override public boolean handleTunnelRequest(PeerConnection sender, Sha1Sum hash) { // - find file by encrypted hash (and get its real hash) // - the correspondence can be put in the encryptedHashes, because the tunnel will likely be established var file = fileService.findFileByEncryptedHash(hash); if (file.isPresent()) { log.debug("Found file {}", file.get()); var path = fileService.getFilePath(file.get()); if (!Files.isRegularFile(path)) { log.debug("File {} doesn't exist on disk, not serving it and removing", file.get()); fileService.deleteFile(file.get()); return false; } // Add it to the encrypted hashes because it's going to be used soon // to establish the tunnels encryptedHashes.put(hash, file.get().getHash()); // XXX: don't forget to handle files currently being swarmed and tons of other things // XXX: sender might not necessarily be needed (it's for the permissions) return true; } return false; } @Override public void receiveTurtleData(TurtleGenericTunnelItem item, Sha1Sum hash, Location virtualLocation, TunnelDirection tunnelDirection) { switch (item) { case TurtleGenericDataItem turtleGenericDataItem -> { var realHash = encryptedHashes.get(hash); if (realHash == null) { log.error("Cannot find the real hash of hash {}", hash); return; } var decryptedItem = decryptItem(turtleGenericDataItem, realHash); if (decryptedItem instanceof TurtleGenericDataItem) { log.error("Decrypted item is a recursive bomb, dropping"); } else { receiveTurtleData(decryptedItem, realHash, virtualLocation, tunnelDirection); } // No need to dispose decryptedItem as it doesn't come from netty } case TurtleFileRequestItem turtleFileRequestItem -> fileCommandQueue.add(new ActionReceiveDataRequest(virtualLocation, hash, turtleFileRequestItem.getChunkOffset(), turtleFileRequestItem.getChunkSize())); case TurtleFileDataItem turtleFileDataItem -> fileCommandQueue.add(new ActionReceiveData(virtualLocation, hash, turtleFileDataItem.getChunkOffset(), turtleFileDataItem.getChunkData())); case TurtleFileMapRequestItem turtleFileMapRequestItem -> fileCommandQueue.add(new ActionReceiveChunkMapRequest(virtualLocation, hash, turtleFileMapRequestItem.getDirection() == TunnelDirection.CLIENT)); case TurtleFileMapItem turtleFileMapItem -> fileCommandQueue.add(new ActionReceiveChunkMap(virtualLocation, hash, turtleFileMapItem.getCompressedChunks())); case TurtleChunkCrcRequestItem turtleChunkCrcRequestItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrcRequest(virtualLocation, hash, turtleChunkCrcRequestItem.getChunkNumber())); case TurtleChunkCrcItem turtleChunkCrcItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrc(virtualLocation, hash, turtleChunkCrcItem.getChunkNumber(), turtleChunkCrcItem.getChecksum())); case null -> throw new IllegalStateException("Null item"); default -> log.warn("Unknown packet type received: {}", item.getSubType()); } } @Override public List receiveSearchRequest(byte[] query, int maxHits) { return List.of(); } @Override public void receiveSearchRequestString(PeerConnection sender, String keywords) { fileTrendNotificationService.receivedSearch(sender.getLocation().getProfile().getName(), keywords); } @Override public void receiveSearchResult(int requestId, TurtleSearchResultItem item) { if (item instanceof TurtleFileSearchResultItem fileItem) { log.debug("Forwarding search result id {} as notification", requestId); fileItem.getResults().forEach(fileInfo -> fileSearchNotificationService.foundFile(requestId, fileInfo.getFileName(), fileInfo.getFileSize(), fileInfo.getFileHash())); } } @Override public void addVirtualPeer(Sha1Sum encryptedHash, Location virtualLocation, TunnelDirection direction) { var hash = encryptedHashes.get(encryptedHash); if (hash == null) { log.warn("Couldn't add virtual peer, not an encrypted hash"); return; } if (direction == TunnelDirection.SERVER) { fileCommandQueue.add(new ActionAddPeer(hash, virtualLocation)); } } @Override public void removeVirtualPeer(Sha1Sum encryptedHash, Location virtualLocation) { var hash = encryptedHashes.get(encryptedHash); if (hash == null) { log.warn("Couldn't remove virtual peer, not an encrypted hash"); return; } fileCommandQueue.add(new ActionRemovePeer(hash, virtualLocation)); } public int turtleSearch(String search) // XXX: maybe make a generic version or so... { if (turtleRouter != null) // Happens if the service is not enabled { return turtleRouter.turtleSearch(search, this); } return 0; } @Transactional public long download(String name, Sha1Sum hash, long size, LocationIdentifier locationIdentifier) { var id = fileService.addDownload(name, hash, size, locationService.findLocationByLocationIdentifier(locationIdentifier).orElse(null)); if (id != 0L) { fileCommandQueue.add(new ActionDownload(id, name, hash, size, locationIdentifier, null)); } return id; } public void markDownloadAsCompleted(Sha1Sum hash) { fileService.markDownloadAsCompleted(hash); } public List getDownloadStatistics() { fileCommandQueue.add(new ActionGetDownloadsProgress()); return fileTransferManager.getDownloadsProgress(); } public List getUploadStatistics() { fileCommandQueue.add(new ActionGetUploadsProgress()); return fileTransferManager.getUploadsProgress(); } public void removeDownload(long id) { fileCommandQueue.add(new ActionRemoveDownload(id)); } @Override public void shutdown() { fileSearchNotificationService.shutdown(); fileTrendNotificationService.shutdown(); if (fileTransferManagerThread != null) { log.info("Stopping FileTransferManager..."); fileTransferManagerThread.interrupt(); try { log.info("Waiting for FileTransferManager to terminate..."); fileTransferManagerThread.join(); log.debug("FileTransferManager terminated"); } catch (InterruptedException e) { log.error("Failed to wait for termination: {}", e.getMessage(), e); Thread.currentThread().interrupt(); } } } private void sendTurtleItem(Location virtualLocation, Sha1Sum hash, TurtleGenericTunnelItem item) { // We only send encrypted tunnels. They're available since Retroshare 0.6.2 turtleRouter.sendTurtleData(virtualLocation, encryptItem(item, hash)); } private TurtleGenericDataItem encryptItem(TurtleGenericTunnelItem item, Sha1Sum hash) { var key = new FileTransferEncryptionKey(hash); var serializedItem = ItemUtils.serializeItem(item, this); return new TurtleGenericDataItem(RsCrypto.encryptAuthenticateData(key, serializedItem, encryptionFormat)); } private TurtleGenericTunnelItem decryptItem(TurtleGenericDataItem item, Sha1Sum hash) { var key = new FileTransferEncryptionKey(hash); return (TurtleGenericTunnelItem) ItemUtils.deserializeItem(RsCrypto.decryptAuthenticateData(key, item.getTunnelData()), rsServiceRegistry); } public void activateTunnels(Sha1Sum hash) { var encryptedHash = FileService.encryptHash(hash); encryptedHashes.put(encryptedHash, hash); turtleRouter.startMonitoringTunnels(encryptedHash, this, true); } public void deactivateTunnels(Sha1Sum hash) { var encryptedHash = FileService.encryptHash(hash); encryptedHashes.put(encryptedHash, hash); turtleRouter.stopMonitoringTunnels(encryptedHash); } /** * Sends request as a client. * * @param location the location to send to (can be virtual) * @param hash the hash related to * @param size the size * @param offset the offset * @param chunkSize the chunk size (usually 1 MB) */ public void sendDataRequest(Location location, Sha1Sum hash, long size, long offset, int chunkSize) { if (turtleRouter.isVirtualPeer(location)) { var item = new TurtleFileRequestItem(offset, chunkSize); sendTurtleItem(location, hash, item); } else { var item = new FileTransferDataRequestItem(size, hash, offset, chunkSize); peerConnectionManager.writeItem(location, item, this); } } /** * Sends a chunk map request. * * @param location the location to send to (can be virtual) * @param hash the hash related to * @param isClient if true, means that the message is for a client (that is, one that is currently downloading the file) instead of a server */ public void sendChunkMapRequest(Location location, Sha1Sum hash, boolean isClient) { if (turtleRouter.isVirtualPeer(location)) { var item = new TurtleFileMapRequestItem(); sendTurtleItem(location, hash, item); } else { var item = new FileTransferChunkMapRequestItem(hash, isClient); peerConnectionManager.writeItem(location, item, this); } } void sendChunkMap(Location location, Sha1Sum hash, boolean isClient, List compressedChunkMap) { if (turtleRouter.isVirtualPeer(location)) { var item = new TurtleFileMapItem(compressedChunkMap); sendTurtleItem(location, hash, item); } else { var item = new FileTransferChunkMapItem(hash, compressedChunkMap, isClient); peerConnectionManager.writeItem(location, item, this); } } public void sendSingleChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber) { if (turtleRouter.isVirtualPeer(location)) { var item = new TurtleChunkCrcRequestItem(chunkNumber); sendTurtleItem(location, hash, item); } else { var item = new FileTransferSingleChunkCrcRequestItem(hash, chunkNumber); peerConnectionManager.writeItem(location, item, this); } } public void sendSingleChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum) { if (turtleRouter.isVirtualPeer(location)) { var item = new TurtleChunkCrcItem(chunkNumber, checkSum); sendTurtleItem(location, hash, item); } else { var item = new FileTransferSingleChunkCrcItem(hash, chunkNumber, checkSum); peerConnectionManager.writeItem(location, item, this); } } /** * Sends data as a server. * * @param location the location to send to (can be virtual too) * @param hash the hash related to it * @param totalSize the total size of the file * @param offset the offset within the file * @param data the data to send */ void sendData(Location location, Sha1Sum hash, long totalSize, long offset, byte[] data) { if (data.length > 0) { if (data.length > BLOCK_SIZE) { throw new IllegalArgumentException("Maximum send totalSize must be " + BLOCK_SIZE + ", not " + data.length); } if (turtleRouter.isVirtualPeer(location)) { var item = new TurtleFileDataItem(offset, data); sendTurtleItem(location, hash, item); } else { var item = new FileTransferDataItem(offset, totalSize, hash, data); peerConnectionManager.writeItem(location, item, this); } } else { log.debug("Empty data, nothing to send. Bug?!"); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferStrategy.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; public enum FileTransferStrategy { LINEAR, RANDOM } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileUpload.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.common.id.Sha1Sum; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.BitSet; import java.util.Optional; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE; /** * This implementation of {@link FileProvider} is for uploading a file. */ class FileUpload implements FileProvider { private static final Logger log = LoggerFactory.getLogger(FileUpload.class); protected final File file; protected FileChannel channel; protected FileLock lock; protected long fileSize; private BitSet chunkMap; private ByteBuffer buf; public FileUpload(File file) { this.file = file; } @Override public long getFileSize() { if (channel == null) { throw new IllegalStateException("FileSeeder has not been initialized yet so the file size is not known"); } return fileSize; } @Override public boolean open() { try { channel = FileChannel.open(file.toPath(), StandardOpenOption.READ); lock = channel.lock(0, Long.MAX_VALUE, true); if (!lock.isShared()) { log.warn("Lock for file {} is not shared", file); } fileSize = channel.size(); return true; } catch (IOException e) { log.error("Couldn't open file {} for reading", file, e); return false; } } @Override public byte[] read(long offset, int size) throws IOException // XXX: RS has an option to return unchecked chunks. not sure when it's used { if (size > BLOCK_SIZE) { throw new IllegalArgumentException("size must be smaller than " + BLOCK_SIZE + " bytes"); } allocateBufferIfNeeded(); buf.clear(); buf.limit(size); channel.read(buf, offset); var a = new byte[buf.position()]; buf.flip(); buf.get(a); return a; } @Override public Sha1Sum computeHash(long offset) { var hashBuf = ByteBuffer.allocate(CHUNK_SIZE); try { channel.read(hashBuf, offset); } catch (IOException e) { log.error("Failed to compute hash: {}", e.getMessage()); return null; } var a = new byte[hashBuf.position()]; hashBuf.flip(); hashBuf.get(a); var digest = new Sha1MessageDigest(); digest.update(a); return digest.getSum(); } private void allocateBufferIfNeeded() { if (buf == null) { buf = ByteBuffer.allocate(BLOCK_SIZE); } } @Override public void write(long offset, byte[] data) throws IOException { throw new IllegalArgumentException("Cannot write data to a file provider"); } @Override public BitSet getChunkMap() { if (chunkMap == null) { var numberOfChunks = getNumberOfChunks(); chunkMap = new BitSet(numberOfChunks); chunkMap.set(0, numberOfChunks); } return (BitSet) chunkMap.clone(); } protected int getNumberOfChunks() { var numberOfChunks = fileSize / CHUNK_SIZE; if (fileSize % CHUNK_SIZE != 0) { numberOfChunks++; } if (numberOfChunks > Integer.MAX_VALUE) // RS has a higher value because of unsigned ints. 4 TB instead of 2 TB { log.error("Maximum chunk value exceeded. File size: {}. File won't be transferred properly", fileSize); return 0; } return (int) numberOfChunks; } @Override public void close() { try { lock.close(); channel.close(); } catch (IOException e) { log.error("Failed to close file {} properly", file, e); } } @Override public void closeAndDelete() { throw new IllegalStateException("Cannot delete a seeder"); } @Override public boolean isComplete() { return true; } @Override public boolean hasChunk(int index) { return true; } @Override public Optional getNeededChunk(BitSet chunkMap) { return Optional.empty(); } @Override public Path getPath() { return file.toPath(); } @Override public long getBytesWritten() { throw new IllegalStateException("FileSeeder doesn't write bytes"); } @Override public long getId() { throw new IllegalStateException("FileSeeders don't have an id"); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/SliceSender.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE; /** * Responsible for sending a slice (1 MB or less) to a remote location. * It is sent by blocks of 8 KB (possibly less for the last one). */ class SliceSender { private static final Logger log = LoggerFactory.getLogger(SliceSender.class); private final FileTransferRsService fileTransferRsService; private final Location location; private final FileProvider provider; private final Sha1Sum hash; private final long totalSize; private long offset; private int size; public SliceSender(FileTransferRsService fileTransferRsService, Location location, FileProvider provider, Sha1Sum hash, long totalSize, long offset, int size) { this.fileTransferRsService = fileTransferRsService; this.location = location; this.provider = provider; this.hash = hash; this.totalSize = totalSize; this.offset = offset; this.size = size; } /** * Sends data. * * @return false in case of an error or when it's done sending. Basically keep calling it when it's true */ public boolean send() { var length = Math.min(BLOCK_SIZE, size); byte[] data; try { data = provider.read(offset, length); } catch (IOException e) { log.error("Failed to read file", e); return false; } if (data.length > 0) { fileTransferRsService.sendData(location, hash, totalSize, offset, data); } size -= data.length; offset += data.length; return size > 0 && data.length == length; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/doc-files/filetransfer.puml ================================================ @startuml 'https://plantuml.com/component-diagram skinparam componentStyle rectangle package "File Transfer" { [FileTransferManager] --> [FileTransferRsService] } [FileTransferManager] <-> FileTransferAgents package "FileTransferAgents" { [Leechers] <> [Seeders] <> } [Leechers] <--> [FileSystem] [Seeders] <--> [FileSystem] [FileSystem] package "Turtle" { [TurtleRsService] <-> Tunnels } package "Peers" { [Peer #1] [Peer #2] } database "H2 Database" { folder "Metadata" { [Files] } } [FileTransferRsService] <--> [TurtleRsService] [FileTransferRsService] <--> [Metadata] [FileTransferRsService] <--> [Peers] [Tunnels] <-> [Peers] @enduml ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferChunkMapItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.List; public class FileTransferChunkMapItem extends Item { @RsSerialized private boolean isClient; @RsSerialized private Sha1Sum hash; @RsSerialized private List compressedChunks; @SuppressWarnings("unused") public FileTransferChunkMapItem() { } public FileTransferChunkMapItem(Sha1Sum hash, List compressedChunks, boolean isClient) { this.hash = hash; this.compressedChunks = compressedChunks; this.isClient = isClient; } @Override public int getServiceType() { return RsServiceType.FILE_TRANSFER.getType(); } @Override public int getSubType() { return 5; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); } public boolean isClient() { return isClient; } public Sha1Sum getHash() { return hash; } public List getCompressedChunks() { return compressedChunks; } @Override public FileTransferChunkMapItem clone() { return (FileTransferChunkMapItem) super.clone(); } @Override public String toString() { return "FileTransferChunkMapItem{" + "isClient=" + isClient + ", hash=" + hash + ", compressedChunks=" + compressedChunks + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferChunkMapRequestItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; public class FileTransferChunkMapRequestItem extends Item { @RsSerialized private boolean isLeecher; @RsSerialized private Sha1Sum hash; @SuppressWarnings("unused") public FileTransferChunkMapRequestItem() { } public FileTransferChunkMapRequestItem(Sha1Sum hash, boolean isLeecher) { this.hash = hash; this.isLeecher = isLeecher; } @Override public int getServiceType() { return RsServiceType.FILE_TRANSFER.getType(); } @Override public int getSubType() { return 4; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); } public boolean isLeecher() { return isLeecher; } public Sha1Sum getHash() { return hash; } @Override public FileTransferChunkMapRequestItem clone() { return (FileTransferChunkMapRequestItem) super.clone(); } @Override public String toString() { return "FileTransferChunkMapRequestItem{" + "isLeecher=" + isLeecher + ", hash=" + hash + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferDataItem.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.common.FileData; import io.xeres.app.xrs.common.FileItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; import static io.xeres.app.xrs.serialization.TlvType.FILE_DATA; public class FileTransferDataItem extends Item { @RsSerialized(tlvType = FILE_DATA) private FileData fileData; @SuppressWarnings("unused") public FileTransferDataItem() { } public FileTransferDataItem(long offset, long size, Sha1Sum hash, byte[] data) { var fileItem = new FileItem(size, hash, "", "", 0); fileData = new FileData(fileItem, offset, data); } @Override public int getServiceType() { return RsServiceType.FILE_TRANSFER.getType(); } @Override public int getSubType() { return 2; } @Override public int getPriority() { return ItemPriority.NORMAL.getPriority(); } public FileData getFileData() { return fileData; } @Override public FileTransferDataItem clone() { return (FileTransferDataItem) super.clone(); } @Override public String toString() { return "FileTransferDataItem{" + "fileData=" + fileData + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferDataRequestItem.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.common.FileItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; import static io.xeres.app.xrs.serialization.TlvType.FILE_ITEM; public class FileTransferDataRequestItem extends Item { @RsSerialized private long fileOffset; @RsSerialized private int chunkSize; @RsSerialized(tlvType = FILE_ITEM) private FileItem fileItem; @SuppressWarnings("unused") public FileTransferDataRequestItem() { } public FileTransferDataRequestItem(long fileSize, Sha1Sum hash, long fileOffset, int chunkSize) { fileItem = new FileItem(fileSize, hash, null, null, 0); this.fileOffset = fileOffset; this.chunkSize = chunkSize; } @Override public int getServiceType() { return RsServiceType.FILE_TRANSFER.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); } public long getFileOffset() { return fileOffset; } public int getChunkSize() { return chunkSize; } public FileItem getFileItem() { return fileItem; } @Override public FileTransferDataRequestItem clone() { return (FileTransferDataRequestItem) super.clone(); } @Override public String toString() { return "FileTransferDataRequestItem{" + "fileOffset=" + fileOffset + ", chunkSize=" + chunkSize + ", fileItem=" + fileItem + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferSingleChunkCrcItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; public class FileTransferSingleChunkCrcItem extends Item { @RsSerialized private Sha1Sum hash; @RsSerialized private int chunkNumber; @RsSerialized private Sha1Sum checkSum; @SuppressWarnings("unused") public FileTransferSingleChunkCrcItem() { } public FileTransferSingleChunkCrcItem(Sha1Sum hash, int chunkNumber, Sha1Sum checkSum) { this.hash = hash; this.chunkNumber = chunkNumber; this.checkSum = checkSum; } @Override public int getServiceType() { return RsServiceType.FILE_TRANSFER.getType(); } @Override public int getSubType() { return 9; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); } public Sha1Sum getHash() { return hash; } public int getChunkNumber() { return chunkNumber; } public Sha1Sum getCheckSum() { return checkSum; } @Override public FileTransferSingleChunkCrcItem clone() { return (FileTransferSingleChunkCrcItem) super.clone(); } @Override public String toString() { return "FileTransferSingleChunkCrcItem{" + "hash=" + hash + ", chunkNumber=" + chunkNumber + ", checkSum=" + checkSum + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferSingleChunkCrcRequestItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; public class FileTransferSingleChunkCrcRequestItem extends Item { @RsSerialized private Sha1Sum hash; @RsSerialized private int chunkNumber; @SuppressWarnings("unused") public FileTransferSingleChunkCrcRequestItem() { } public FileTransferSingleChunkCrcRequestItem(Sha1Sum hash, int chunkNumber) { this.hash = hash; this.chunkNumber = chunkNumber; } @Override public int getServiceType() { return RsServiceType.FILE_TRANSFER.getType(); } @Override public int getSubType() { return 8; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); } public int getChunkNumber() { return chunkNumber; } public Sha1Sum getHash() { return hash; } @Override public FileTransferSingleChunkCrcRequestItem clone() { return (FileTransferSingleChunkCrcRequestItem) super.clone(); } @Override public String toString() { return "FileTransferSingleChunkCrcRequestItem{" + "hash=" + hash + ", chunkNumber=" + chunkNumber + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleChunkCrcItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import io.xeres.common.id.Sha1Sum; import static io.xeres.app.xrs.service.turtle.item.TunnelDirection.CLIENT; public class TurtleChunkCrcItem extends TurtleGenericTunnelItem { @RsSerialized private int chunkNumber; @RsSerialized private Sha1Sum checksum; public TurtleChunkCrcItem() { setDirection(CLIENT); } public TurtleChunkCrcItem(int chunkNumber, Sha1Sum checksum) { super(); this.chunkNumber = chunkNumber; this.checksum = checksum; } @Override public boolean shouldStampTunnel() { return true; } @Override public int getSubType() { return 20; } public int getChunkNumber() { return chunkNumber; } public Sha1Sum getChecksum() { return checksum; } @Override public TurtleChunkCrcItem clone() { return (TurtleChunkCrcItem) super.clone(); } @Override public String toString() { return "TurtleChunkCrcItem{" + "chunkNumber=" + chunkNumber + ", checksum=" + checksum + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleChunkCrcRequestItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import static io.xeres.app.xrs.service.turtle.item.TunnelDirection.SERVER; public class TurtleChunkCrcRequestItem extends TurtleGenericTunnelItem { @RsSerialized private int chunkNumber; @SuppressWarnings("unused") public TurtleChunkCrcRequestItem() { setDirection(SERVER); } public TurtleChunkCrcRequestItem(int chunkNumber) { super(); this.chunkNumber = chunkNumber; } @Override public boolean shouldStampTunnel() { return false; } @Override public int getSubType() { return 21; } public int getChunkNumber() { return chunkNumber; } @Override public TurtleChunkCrcRequestItem clone() { return (TurtleChunkCrcRequestItem) super.clone(); } @Override public String toString() { return "TurtleChunkCrcRequestItem{" + "chunkNumber=" + chunkNumber + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileDataItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import java.util.Arrays; import static io.xeres.app.xrs.service.turtle.item.TunnelDirection.CLIENT; public class TurtleFileDataItem extends TurtleGenericTunnelItem { @RsSerialized private long chunkOffset; @RsSerialized private byte[] chunkData; public TurtleFileDataItem() { setDirection(CLIENT); } public TurtleFileDataItem(long chunkOffset, byte[] chunkData) { this(); this.chunkOffset = chunkOffset; this.chunkData = chunkData; } @Override public boolean shouldStampTunnel() { return true; } @Override public int getSubType() { return 8; } public long getChunkOffset() { return chunkOffset; } public byte[] getChunkData() { return chunkData; } @Override public TurtleFileDataItem clone() { return (TurtleFileDataItem) super.clone(); } @Override public String toString() { return "TurtleFileDataItem{" + "chunkOffset=" + chunkOffset + ", chunkData=" + Arrays.toString(chunkData) + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileMapItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.service.turtle.item.TunnelDirection; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.List; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.*; public class TurtleFileMapItem extends TurtleGenericTunnelItem implements RsSerializable { private List compressedChunks; @SuppressWarnings("unused") public TurtleFileMapItem() { } public TurtleFileMapItem(List compressedChunks) { this.compressedChunks = compressedChunks; } @Override public boolean shouldStampTunnel() { return false; } @Override public int getSubType() { return 16; } @Override public TurtleFileMapItem clone() { return (TurtleFileMapItem) super.clone(); } public List getCompressedChunks() { return compressedChunks; } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, getTunnelId()); size += serialize(buf, getDirection() == TunnelDirection.CLIENT ? 1 : 2); //noinspection unchecked size += serialize(buf, (List) (List) compressedChunks); return size; } @Override public void readObject(ByteBuf buf) { setTunnelId(deserializeInt(buf)); var tunnelDirection = deserializeInt(buf); setDirection(tunnelDirection == 1 ? TunnelDirection.CLIENT : TunnelDirection.SERVER); //noinspection unchecked compressedChunks = (List) (List) deserializeList(buf, new ParameterizedType() { @Override public Type[] getActualTypeArguments() { return new Type[]{Integer.class}; } @Override public Type getRawType() { return List.class; } @Override public Type getOwnerType() { return null; } }); } @Override public String toString() { return "TurtleFileMapItem{" + "compressedChunks=" + compressedChunks + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileMapRequestItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.service.turtle.item.TunnelDirection; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.deserializeInt; import static io.xeres.app.xrs.serialization.Serializer.serialize; public class TurtleFileMapRequestItem extends TurtleGenericTunnelItem implements RsSerializable { @Override public boolean shouldStampTunnel() { return false; } @Override public int getSubType() { return 17; } @Override public TurtleFileMapRequestItem clone() { return (TurtleFileMapRequestItem) super.clone(); } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, getTunnelId()); size += serialize(buf, getDirection() == TunnelDirection.CLIENT ? 1 : 2); return size; } @Override public void readObject(ByteBuf buf) { setTunnelId(deserializeInt(buf)); setDirection(deserializeInt(buf) == 1 ? TunnelDirection.CLIENT : TunnelDirection.SERVER); } @Override public String toString() { return "TurtleFileMapRequestItem{}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileRequestItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer.item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import static io.xeres.app.xrs.service.turtle.item.TunnelDirection.SERVER; public class TurtleFileRequestItem extends TurtleGenericTunnelItem { @RsSerialized private long chunkOffset; @RsSerialized private int chunkSize; @SuppressWarnings("unused") public TurtleFileRequestItem() { setDirection(SERVER); } public TurtleFileRequestItem(long chunkOffset, int chunkSize) { super(); this.chunkOffset = chunkOffset; this.chunkSize = chunkSize; } @Override public boolean shouldStampTunnel() { return false; } @Override public int getSubType() { return 7; } public long getChunkOffset() { return chunkOffset; } public int getChunkSize() { return chunkSize; } @Override public TurtleFileRequestItem clone() { return (TurtleFileRequestItem) super.clone(); } @Override public String toString() { return "TurtleFileRequestItem{" + "chunkOffset=" + chunkOffset + ", chunkSize=" + chunkSize + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/forum/ForumRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.forum; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.forum.ForumMessageItemSummary; import io.xeres.app.database.model.gxs.*; import io.xeres.app.database.repository.GxsForumGroupRepository; import io.xeres.app.database.repository.GxsForumMessageRepository; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.notification.forum.ForumNotificationService; import io.xeres.app.xrs.common.CommentMessageItem; import io.xeres.app.xrs.common.VoteMessageItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.forum.item.ForumGroupItem; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.app.xrs.service.gxs.GxsAuthentication; import io.xeres.app.xrs.service.gxs.GxsHelperService; import io.xeres.app.xrs.service.gxs.GxsRsService; import io.xeres.app.xrs.service.gxs.GxsTransactionManager; import io.xeres.app.xrs.service.gxs.item.GxsSyncMessageRequestItem; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.protocol.xrs.RsServiceType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_AUTHOR; import static io.xeres.common.protocol.xrs.RsServiceType.GXS_FORUMS; @Component public class ForumRsService extends GxsRsService { private static final Duration SYNCHRONIZATION_INITIAL_DELAY = Duration.ofSeconds(30); private static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1); private final GxsForumGroupRepository gxsForumGroupRepository; private final GxsForumMessageRepository gxsForumMessageRepository; private final GxsHelperService gxsHelperService; private final DatabaseSessionManager databaseSessionManager; private final ForumNotificationService forumNotificationService; public ForumRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsForumGroupRepository gxsForumGroupRepository, GxsForumMessageRepository gxsForumMessageRepository, GxsHelperService gxsHelperService, ForumNotificationService forumNotificationService) { super(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService); this.gxsForumGroupRepository = gxsForumGroupRepository; this.gxsForumMessageRepository = gxsForumMessageRepository; this.gxsHelperService = gxsHelperService; this.databaseSessionManager = databaseSessionManager; this.forumNotificationService = forumNotificationService; } @Override public RsServiceType getServiceType() { return GXS_FORUMS; } @Override protected GxsAuthentication getAuthentication() { // Anybody can write messages on forums return new GxsAuthentication.Builder() .withRequirements(EnumSet.of(ROOT_NEEDS_AUTHOR, CHILD_NEEDS_AUTHOR)) .build(); } @Override public void initialize(PeerConnection peerConnection) { super.initialize(peerConnection); peerConnection.scheduleWithFixedDelay( () -> syncMessages(peerConnection), SYNCHRONIZATION_INITIAL_DELAY.toSeconds(), SYNCHRONIZATION_DELAY.toSeconds(), TimeUnit.SECONDS ); } @Override public void syncMessages(PeerConnection peerConnection) { try (var ignored = new DatabaseSession(databaseSessionManager)) { // Request new messages for all subscribed groups findAllSubscribedGroups().forEach(forumGroupItem -> { var request = new GxsSyncMessageRequestItem(forumGroupItem.getGxsId(), gxsHelperService.getLastPeerMessagesUpdate(peerConnection.getLocation(), forumGroupItem.getGxsId(), getServiceType()), ChronoUnit.YEARS.getDuration()); log.debug("Asking {} for new messages in {} ({}) since {}, last updated: {}", peerConnection, forumGroupItem.getName(), request.getGxsId(), log.isDebugEnabled() ? Instant.ofEpochSecond(request.getLimit()) : null, log.isDebugEnabled() ? Instant.ofEpochSecond(request.getLastUpdated()) : null); peerConnectionManager.writeItem(peerConnection, request, this); }); } } public void fixDuplicates() { findAllSubscribedGroups().forEach(forumGroupItem -> { gxsHelperService.fixHiddenMessages(forumGroupItem.getGxsId(), Instant.now().minus(Duration.ofDays(360))); // XXX: make the date range smaller... and move it somewhere else, perhaps }); } @Override protected List onAvailableGroupListRequest(PeerConnection recipient) { return findAllSubscribedGroups(); } @Override protected List onGroupListRequest(Set ids) { return findAllGroups(ids); } @Override protected Set onAvailableGroupListResponse(Map ids) { // We want new forums as well as updated ones var existingMap = findAllGroups(ids.keySet()).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished)); ids.entrySet().removeIf(gxsIdInstantEntry -> { var existing = existingMap.get(gxsIdInstantEntry.getKey()); return existing != null && !gxsIdInstantEntry.getValue().isAfter(existing); }); return ids.keySet(); } @Override protected boolean onGroupReceived(ForumGroupItem item) { log.debug("Received {}, saving/updating...", item); return true; } @Override protected void onGroupsSaved(List items) { forumNotificationService.addOrUpdateGroups(items); } @Override protected List onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since) { return findAllMessagesInGroupSince(gxsId, since); // Don't return old messages, they're unimportant } @Override protected List onMessageListRequest(GxsId gxsId, Set msgIds) { return findAllMessagesIncludingOlds(gxsId, msgIds); } @Override protected List onMessageListResponse(GxsId gxsId, Set msgIds) { var existing = findAllMessagesIncludingOlds(gxsId, msgIds).stream() .map(GxsMessageItem::getMsgId) .collect(Collectors.toSet()); msgIds.removeAll(existing); return msgIds.stream().toList(); } @Override protected boolean onMessageReceived(ForumMessageItem item) { log.debug("Received message {}, saving...", item); return true; } @Override protected void onMessagesSaved(List items) { forumNotificationService.addOrUpdateMessages(items); } @Override protected boolean onCommentReceived(CommentMessageItem item) { return false; } @Override protected void onCommentsSaved(List items) { // Nothing to do } @Override protected boolean onVoteReceived(VoteMessageItem item) { return false; } @Override protected void onVotesSaved(List items) { // Nothing to do } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { super.handleItem(sender, item); // This is required for the @Transactional to work } @Transactional public void subscribeToForumGroup(long id) { var forumGroupItem = findById(id).orElseThrow(); forumGroupItem.setSubscribed(true); gxsHelperService.setLastServiceGroupsUpdateNow(GXS_FORUMS); // We don't need to send a sync notification here because it's not urgent. // The peers will poll normally to show if there's a new group available. } @Transactional public void unsubscribeFromForumGroup(long id) { var forumGroupItem = findById(id).orElseThrow(); forumGroupItem.setSubscribed(false); } public Optional findById(long id) { return gxsForumGroupRepository.findById(id); } public List findAllGroups() { return gxsForumGroupRepository.findAll(); } public List findAllSubscribedGroups() { return gxsForumGroupRepository.findAllBySubscribedIsTrue(); } public List findAllGroups(Set gxsIds) { return gxsForumGroupRepository.findAllByGxsIdIn(gxsIds); } public List findAllMessagesInGroupSince(GxsId gxsId, Instant since) { return gxsForumMessageRepository.findAllByGxsIdAndPublishedAfterAndHiddenFalse(gxsId, since); } public List findAllMessages(GxsId gxsId, Set msgIds) { return gxsForumMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(gxsId, msgIds); } public List findAllMessagesIncludingOlds(GxsId gxsId, Set msgIds) { return gxsForumMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds); } public List findAllMessages(long groupId, Set msgIds) { var forumGroup = gxsForumGroupRepository.findById(groupId).orElseThrow(); return gxsForumMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(forumGroup.getGxsId(), msgIds); } /** * Finds all messages. Prefer the other variants as this one is slower. * * @param msgIds the list of message ids * @return the messages */ public List findAllMessages(Set msgIds) { return gxsForumMessageRepository.findAllByMsgIdInAndHiddenFalse(msgIds); } public List findAllOldMessages(Set msgIds) { return gxsForumMessageRepository.findAllByMsgIdInAndHiddenTrue(msgIds); } public int getUnreadCount(long groupId) { var forumGroupItem = gxsForumGroupRepository.findById(groupId).orElseThrow(); return gxsForumMessageRepository.countUnreadMessages(forumGroupItem.getGxsId()); } @Transactional public Page findAllMessagesSummary(long groupId, Pageable pageable) { var forumGroup = gxsForumGroupRepository.findById(groupId).orElseThrow(); return gxsForumMessageRepository.findSummaryAllByGxsIdAndHiddenFalse(forumGroup.getGxsId(), pageable); } public ForumMessageItem findMessageById(long id) { return gxsForumMessageRepository.findById(id).orElseThrow(); } private ForumMessageItem saveMessage(MessageBuilder messageBuilder) { var forumMessageItem = messageBuilder.build(); forumMessageItem.setId(gxsForumMessageRepository.findByGxsIdAndMsgId(forumMessageItem.getGxsId(), forumMessageItem.getMsgId()).orElse(forumMessageItem).getId()); // XXX: not sure we should be able to overwrite a message. in which case is it correct? maybe throw? var savedMessage = gxsForumMessageRepository.save(forumMessageItem); markOriginalMessageAsHidden(List.of(savedMessage)); var forumGroupItem = gxsForumGroupRepository.findByGxsId(forumMessageItem.getGxsId()).orElseThrow(); forumGroupItem.setLastUpdated(Instant.now()); gxsForumGroupRepository.save(forumGroupItem); return savedMessage; } @Transactional public long createForumGroup(GxsId identity, String name, String description) { var forumGroupItem = createGroup(name, false); forumGroupItem.setDescription(description); if (identity != null) { forumGroupItem.setAuthorGxsId(identity); } forumGroupItem.setCircleType(GxsCircleType.PUBLIC); // XXX: I think... forumGroupItem.setSignatureFlags(Set.of(GxsSignatureFlags.NONE_REQUIRED, GxsSignatureFlags.AUTHENTICATION_REQUIRED)); forumGroupItem.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC)); // XXX: set list of moderators forumGroupItem.setSubscribed(true); forumGroupItem = saveForum(forumGroupItem); forumNotificationService.addOrUpdateGroups(List.of(forumGroupItem)); return forumGroupItem.getId(); } @Transactional public void updateForumGroup(long groupId, String name, String description) { var forumGroupItem = gxsForumGroupRepository.findById(groupId).orElseThrow(); forumGroupItem.setName(name); forumGroupItem.setDescription(description); forumGroupItem = saveForum(forumGroupItem); forumNotificationService.addOrUpdateGroups(List.of(forumGroupItem)); } private ForumGroupItem saveForum(ForumGroupItem forumGroupItem) { signGroupIfNeeded(forumGroupItem); var savedForum = gxsForumGroupRepository.save(forumGroupItem); gxsHelperService.setLastServiceGroupsUpdateNow(GXS_FORUMS); peerConnectionManager.doForAllPeers(this::sendSyncNotification, this); return savedForum; } @Transactional public long createForumMessage(IdentityGroupItem author, long forumId, String title, String content, long parentId, long originalId) { // XXX: check the size, like createBoardMessage() var group = gxsForumGroupRepository.findById(forumId).orElseThrow(); var builder = new MessageBuilder(group, author, title); if (parentId != 0L) { builder.parentMsgId(gxsForumMessageRepository.findById(parentId).orElseThrow().getMsgId()); } if (originalId != 0L) { builder.originalMsgId(gxsForumMessageRepository.findById(originalId).orElseThrow().getMsgId()); } builder.getMessageItem().setContent(content); var forumMessageItem = saveMessage(builder); forumNotificationService.addOrUpdateMessages(List.of(forumMessageItem)); peerConnectionManager.doForAllPeers(this::sendSyncNotification, this); return forumMessageItem.getId(); } @Transactional public void setMessageReadState(long messageId, boolean read) { var message = gxsForumMessageRepository.findById(messageId).orElseThrow(); message.setRead(read); var group = gxsForumGroupRepository.findByGxsId(message.getGxsId()).orElseThrow(); forumNotificationService.setMessageReadState(group.getId(), message.getId(), read); } @Transactional public void setAllGroupMessagesReadState(long groupId, boolean read) { var group = gxsForumGroupRepository.findById(groupId).orElseThrow(); gxsForumMessageRepository.setAllGroupMessagesReadState(group.getGxsId(), read); forumNotificationService.setGroupMessagesReadState(groupId, read); } @Override public void shutdown() { forumNotificationService.shutdown(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/forum/item/ForumGroupItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.forum.item; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import jakarta.persistence.*; import java.util.HashSet; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.serialize; import static io.xeres.app.xrs.serialization.TlvType.*; @Entity(name = "forum_group") public class ForumGroupItem extends GxsGroupItem { private String description; @ElementCollection @AttributeOverride(name = "identifier", column = @Column(name = "admin")) private Set admins = new HashSet<>(); @ElementCollection @AttributeOverride(name = "identifier", column = @Column(name = "pinned_post")) private Set pinnedPosts = new HashSet<>(); @Transient private boolean oldVersion; // Needed because RS added admins and pinnedPosts later, and it would break signature verification otherwise public ForumGroupItem() { // Needed for JPA } public ForumGroupItem(GxsId gxsId, String name) { setGxsId(gxsId); setName(name); updatePublished(); } @Override public int getSubType() { return 2; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, STR_DESCR, description); if (!oldVersion) { size += serialize(buf, SET_GXS_ID, admins); size += serialize(buf, SET_GXS_MSG_ID, pinnedPosts); } return size; } @Override public void readDataObject(ByteBuf buf) { description = (String) Serializer.deserialize(buf, STR_DESCR); if (buf.isReadable()) { //noinspection unchecked admins = (Set) Serializer.deserialize(buf, SET_GXS_ID); //noinspection unchecked pinnedPosts = (Set) Serializer.deserialize(buf, SET_GXS_MSG_ID); } else { oldVersion = true; } } @Override public ForumGroupItem clone() { return (ForumGroupItem) super.clone(); } @Override public String toString() { return "ForumGroupItem{" + super.toString() + ", admins=" + admins + ", pinnedPosts=" + pinnedPosts + ", oldVersion=" + oldVersion + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/forum/item/ForumMessageItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.forum.item; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import jakarta.persistence.Entity; import jakarta.persistence.Transient; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.serialize; import static io.xeres.app.xrs.serialization.TlvType.STR_MSG; @Entity(name = "forum_message") public class ForumMessageItem extends GxsMessageItem { @Transient public static final ForumMessageItem EMPTY = new ForumMessageItem(); private String content; private boolean read; public ForumMessageItem() { // Needed for JPA } public ForumMessageItem(GxsId gxsId, MsgId msgId, String name) { setGxsId(gxsId); setMsgId(msgId); setName(name); updatePublished(); } @Override public int getSubType() { return 3; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public boolean isRead() { return read; } public void setRead(boolean read) { this.read = read; } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { return serialize(buf, STR_MSG, content); } @Override public void readDataObject(ByteBuf buf) { content = (String) Serializer.deserialize(buf, STR_MSG); } @Override public ForumMessageItem clone() { return (ForumMessageItem) super.clone(); } @Override public String toString() { return "ForumMessageItem{" + super.toString() + ", read=" + read + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/GxsAuthentication.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import java.util.Set; public final class GxsAuthentication { public enum Flags { /** * New threads need to be signed by the author of the message. Typical use: forums, since posts are signed. */ ROOT_NEEDS_AUTHOR, /** * All child messages/votes/comments need to be signed by the author of the message. Typical use: forums since response to posts are signed, and signed comments in channels. */ CHILD_NEEDS_AUTHOR, /** * New threads need to be signed by the publish key of the group. Typical use: posts in channels. Only the creator of the group can post. */ ROOT_NEEDS_PUBLISH, /** * All messages/votes/comments need to be signed by the publish key of the group. */ CHILD_NEEDS_PUBLISH } private final Set requirements; private final boolean authorSigningGroups; private GxsAuthentication(Builder builder) { requirements = builder.requirements; authorSigningGroups = builder.authorSigningGroups; } public Set getRequirements() { return requirements; } public boolean isAuthorSigningGroups() { return authorSigningGroups; } public static final class Builder { private Set requirements; private boolean authorSigningGroups; public Builder() { // Default constructor } public Builder withRequirements(Set val) { requirements = val; return this; } public Builder withAuthorSigningGroups(boolean val) { authorSigningGroups = val; return this; } public GxsAuthentication build() { return new GxsAuthentication(this); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/GxsHelperService.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import io.xeres.app.database.model.gxs.GxsClientUpdate; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.database.model.gxs.GxsServiceSetting; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.repository.GxsClientUpdateRepository; import io.xeres.app.database.repository.GxsGroupItemRepository; import io.xeres.app.database.repository.GxsMessageItemRepository; import io.xeres.app.database.repository.GxsServiceSettingRepository; import io.xeres.app.xrs.common.CommentMessageItem; import io.xeres.app.xrs.common.VoteMessageItem; import io.xeres.app.xrs.service.gxs.item.GxsSyncGroupStatsItem; import io.xeres.app.xrs.service.gxs.item.RequestType; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.protocol.xrs.RsServiceType; import org.springframework.data.domain.Limit; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; /** * Helper service to manage various GXS group and message functions. */ @Service public class GxsHelperService { private final GxsClientUpdateRepository gxsClientUpdateRepository; private final GxsServiceSettingRepository gxsServiceSettingRepository; private final GxsGroupItemRepository gxsGroupItemRepository; private final GxsMessageItemRepository gxsMessageItemRepository; public GxsHelperService(GxsClientUpdateRepository gxsClientUpdateRepository, GxsServiceSettingRepository gxsServiceSettingRepository, GxsGroupItemRepository gxsGroupItemRepository, GxsMessageItemRepository gxsMessageItemRepository) { this.gxsClientUpdateRepository = gxsClientUpdateRepository; this.gxsServiceSettingRepository = gxsServiceSettingRepository; this.gxsGroupItemRepository = gxsGroupItemRepository; this.gxsMessageItemRepository = gxsMessageItemRepository; } /** * Gets the last update time of the peer's groups. The peer's time is always used, not our local time. * * @param location the peer's location * @param serviceType the service type * @return the time when the peer last updated its groups, in peer's time */ public Instant getLastPeerGroupsUpdate(Location location, RsServiceType serviceType) { return gxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType()) .map(GxsClientUpdate::getLastSynced) .orElse(Instant.EPOCH).truncatedTo(ChronoUnit.SECONDS); } /** * Gets the last update of the peer's group messages. The peer's time is always used, not our local time. * * @param location the peer's location. * @param gxsId the group's gxs id. * @param serviceType the service type. * @return the time when the peer last updated its group messages, in peer's time */ public Instant getLastPeerMessagesUpdate(Location location, GxsId gxsId, RsServiceType serviceType) { return gxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType()) .map(gxsClientUpdate -> gxsClientUpdate.getMessageUpdate(gxsId)) .orElse(Instant.EPOCH).truncatedTo(ChronoUnit.SECONDS); } /** * Sets the last update time of the peer's groups. The peer's time is always used, not our local time. * * @param location the peer's location * @param update the peer's last update time, in peer's time (so given by the peer itself). Never supply a time computed locally * @param serviceType the service type */ @Transactional public void setLastPeerGroupsUpdate(Location location, Instant update, RsServiceType serviceType) { gxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType()) .ifPresentOrElse(gxsClientUpdate -> gxsClientUpdate.setLastSynced(update), () -> gxsClientUpdateRepository.save(new GxsClientUpdate(location, serviceType.getType(), update))); } /** * Sets the last update time of a peer's messages. The peer's time is always used, not our local time. * * @param location the peer's location * @param gxsId the group * @param update the peer's last update time, in peer's time (so given by the peer itself). Never supply a time computed locally. * @param serviceType the service type */ @Transactional public void setLastPeerMessageUpdate(Location location, GxsId gxsId, Instant update, RsServiceType serviceType) { gxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType()) .ifPresentOrElse(gxsClientUpdate -> gxsClientUpdate.putMessageUpdate(gxsId, update), () -> { var clientUpdate = new GxsClientUpdate(location, serviceType.getType(), Instant.EPOCH); clientUpdate.putMessageUpdate(gxsId, update); gxsClientUpdateRepository.save(clientUpdate); }); } /** * Gets the last time our service's groups were updated. This uses the local time. * * @param serviceType the service type * @return the last time */ public Instant getLastServiceGroupsUpdate(RsServiceType serviceType) { return gxsServiceSettingRepository.findById(serviceType.getType()) .map(GxsServiceSetting::getLastUpdated) .orElse(Instant.EPOCH).truncatedTo(ChronoUnit.SECONDS); } /** * Sets the service group's last update to now. * * @param serviceType the service type */ @Transactional public void setLastServiceGroupsUpdateNow(RsServiceType serviceType) { var now = Instant.now(); // we always use local time gxsServiceSettingRepository.findById(serviceType.getType()) .ifPresentOrElse(gxsServiceSetting -> gxsServiceSetting.setLastUpdated(now), () -> gxsServiceSettingRepository.save(new GxsServiceSetting(serviceType.getType(), now))); } /** * Saves an external group. * * @param gxsGroupItem the group * @param confirmation the confirmation predicate * @return the group */ @Transactional public Optional saveGroup(G gxsGroupItem, Predicate confirmation) { gxsGroupItem.setId(gxsGroupItemRepository.findByGxsId(gxsGroupItem.getGxsId()).orElse(gxsGroupItem).getId()); if (confirmation.test(gxsGroupItem) && gxsGroupItem.isExternal()) // Don't overwrite our own groups { return Optional.of(gxsGroupItemRepository.save(gxsGroupItem)); } return Optional.empty(); } /** * Gets a group. * * @param gxsId the gxsId of the group * @return the group, null if it doesn't exist */ public GxsGroupItem getGroup(GxsId gxsId) { return gxsGroupItemRepository.findByGxsId(gxsId).orElse(null); } @Transactional(readOnly = true) public Optional findGroupStatsByGxsId(GxsId gxsId) { return gxsGroupItemRepository.findByGxsIdAndSubscribedIsTrue(gxsId) .map(group -> { var numberOfPosts = gxsMessageItemRepository.countByGxsId(group.getGxsId()); return new GxsSyncGroupStatsItem(RequestType.RESPONSE, group.getGxsId(), group.getLastUpdated() != null ? (int) group.getLastUpdated().getEpochSecond() : 0, numberOfPosts); }); } @Transactional public void updateGroupStats(GxsSyncGroupStatsItem item) { gxsGroupItemRepository.findByGxsId(item.getGxsId()).ifPresent(group -> { group.setVisibleMessageCount(Math.max(group.getVisibleMessageCount(), item.getNumberOfPosts())); if (item.getLastPostTimestamp() > group.getLastActivity().getEpochSecond()) { group.setLastActivity(Instant.ofEpochSecond(item.getLastPostTimestamp())); } // XXX: how to set popularity? }); } @Transactional public Set findGroupsToRequestStats(Instant now, Duration delay) { return gxsGroupItemRepository.findByOrderByLastStatistics(Limit.of(2)).stream() .filter(gxsGroupItem -> Duration.between(gxsGroupItem.getLastStatistics(), now).compareTo(delay) > 0) .map(gxsGroupItem -> { gxsGroupItem.setLastStatistics(now); return gxsGroupItem.getGxsId(); }) .collect(Collectors.toSet()); } @Transactional public Optional saveMessage(M gxsMessageItem, Predicate confirmation) { gxsMessageItem.setId(gxsMessageItemRepository.findByGxsIdAndMsgId(gxsMessageItem.getGxsId(), gxsMessageItem.getMsgId()).orElse(gxsMessageItem).getId()); if (confirmation.test(gxsMessageItem) /*&& gxsMessageItem.isExternal()*/) // Don't overwrite our own messages (XXX: find a way to do the check) { return Optional.of(gxsMessageItemRepository.save(gxsMessageItem)); } return Optional.empty(); } public void fixHiddenMessages(GxsId gxsId, Instant since) { gxsMessageItemRepository.fixIntervalDuplicates(gxsId, since); gxsMessageItemRepository.hideOldDuplicates(gxsId, since); } @Transactional public Optional saveComment(CommentMessageItem commentMessageItem, Predicate confirmation) { commentMessageItem.setId(gxsMessageItemRepository.findByGxsIdAndMsgId(commentMessageItem.getGxsId(), commentMessageItem.getMsgId()).orElse(commentMessageItem).getId()); if (confirmation.test(commentMessageItem) /*&& gxsMessageItem.isExternal()*/) // Don't overwrite our own messages (XXX: find a way to do the check) { return Optional.of(gxsMessageItemRepository.save(commentMessageItem)); } return Optional.empty(); } @Transactional public Optional saveVote(VoteMessageItem voteMessageItem, Predicate confirmation) { voteMessageItem.setId(gxsMessageItemRepository.findByGxsIdAndMsgId(voteMessageItem.getGxsId(), voteMessageItem.getMsgId()).orElse(voteMessageItem).getId()); if (confirmation.test(voteMessageItem) /*&& gxsMessageItem.isExternal()*/) // Don't overwrite our own messages (XXX: find a way to do the check) { return Optional.of(gxsMessageItemRepository.save(voteMessageItem)); } return Optional.empty(); } /** * Overrides a message. This allows to "edit" a message. If the message is found, it's marked as hidden. * * @param gxsId the group of the message * @param msgId the message id * @param authorGxsId the author id */ @Transactional public void overrideMessage(GxsId gxsId, MsgId msgId, GxsId authorGxsId) { gxsMessageItemRepository.findByGxsIdAndMsgId(gxsId, msgId).ifPresent(gxsMessageItem -> { if (Objects.equals(authorGxsId, gxsMessageItem.getAuthorGxsId())) { gxsMessageItem.setHidden(true); } }); } /** * Updates the last posted field of the group. This allows knowing when the last time a message was added in a group was. * * @param gxsId the group * @param lastPosted the last posted value */ @Transactional public void updateLastPosted(GxsId gxsId, Instant lastPosted) { gxsGroupItemRepository.findByGxsId(gxsId).ifPresent(gxsGroupItem -> { if (gxsGroupItem.getLastUpdated() == null || gxsGroupItem.getLastUpdated().isBefore(lastPosted)) { gxsGroupItem.setLastUpdated(lastPosted); } }); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/GxsRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.gxs.GxsCircleType; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.common.CommentMessageItem; import io.xeres.app.xrs.common.VoteMessageItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemUtils; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.gxs.item.*; import io.xeres.app.xrs.service.identity.IdentityManager; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.ExecutorUtils; import io.xeres.common.util.NoSuppressedRunnable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.ParameterizedType; import java.security.KeyPair; import java.security.PublicKey; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.*; import static io.xeres.app.net.peer.PeerConnection.KEY_GXS_TRANSACTION_ID; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.*; import static io.xeres.app.xrs.service.gxs.item.GxsSyncGroupItem.REQUEST; import static io.xeres.app.xrs.service.gxs.item.GxsSyncGroupItem.RESPONSE; import static java.util.stream.Collectors.toMap; import static java.util.stream.Collectors.toSet; import static org.apache.commons.collections4.CollectionUtils.isEmpty; /** * This abstract class is used by all Gxs services. The transfer system goes the following way, for example * if Juergen asks Heike every minute if she has new groups for him: *

* Transfer diagram * * @param the GxsGroupItem subclass * @param the GxsMessageItem subclass */ public abstract class GxsRsService extends RsService { protected final Logger log = LoggerFactory.getLogger(getClass().getName()); private static final int GXS_KEY_SIZE = 2048; // The RSA size of Gxs keys. Do not change unless you want everything to break. private static final int KEY_LAST_SYNC_REQUEST = 1; /** * When to perform synchronization run with a peer. */ private static final Duration SYNCHRONIZATION_DELAY_INITIAL_MIN = Duration.ofSeconds(10); private static final Duration SYNCHRONIZATION_DELAY_INITIAL_MAX = Duration.ofSeconds(15); private static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1); private static final int MESSAGES_PER_TRANSACTIONS = 20; private static final Duration PENDING_VERIFICATION_MAX = Duration.ofMinutes(1); private static final Duration PENDING_VERIFICATION_DELAY = Duration.ofSeconds(10); private static final Duration GROUP_STATISTICS_DELAY = Duration.ofMinutes(2); private Instant lastGroupStatistics = Instant.EPOCH; protected final GxsTransactionManager gxsTransactionManager; protected final PeerConnectionManager peerConnectionManager; private final IdentityManager identityManager; private final GxsHelperService gxsHelperService; private final DatabaseSessionManager databaseSessionManager; private final Class itemGroupClass; private final Class itemMessageClass; private ScheduledExecutorService executorService; private final Map pendingGxsGroups = new ConcurrentHashMap<>(); private final Map pendingGxsMessages = new ConcurrentHashMap<>(); private final Set ongoingGxsMessageTransfers = ConcurrentHashMap.newKeySet(); private final GxsAuthentication gxsAuthentication; @SuppressWarnings("unchecked") protected GxsRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsHelperService gxsHelperService) { super(rsServiceRegistry); this.gxsTransactionManager = gxsTransactionManager; this.peerConnectionManager = peerConnectionManager; this.databaseSessionManager = databaseSessionManager; this.identityManager = identityManager; this.gxsHelperService = gxsHelperService; // Type information is available when subclassing a class using a generic type, which means itemClass is the class of G itemGroupClass = (Class) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0]; itemMessageClass = (Class) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[1]; // and M gxsAuthentication = Objects.requireNonNull(getAuthentication(), "Authentication cannot be null"); } protected enum VerificationStatus { OK, FAILED, DELAYED } /** * Called when the peer wants a list of our subscribed groups. * * @param recipient the recipient of the result * @return the available groups that we have */ protected abstract List onAvailableGroupListRequest(PeerConnection recipient); /** * Called when a peer sends the list of new or updated groups that might interest us. * * @param ids the ids of updated groups and their update time that the peer has for us * @return the subset of those groups that we actually want */ protected abstract Set onAvailableGroupListResponse(Map ids); /** * Called when the peer wants specific groups to be transferred to him. * * @param ids the groups that the peer wants * @return the groups that we have available within the requested set */ protected abstract List onGroupListRequest(Set ids); /** * Called when a group has been received. * * @param item the received group * @return true if the group must be saved to disk */ protected abstract boolean onGroupReceived(G item); /** * Called when the groups have been saved. * * @param items the list of groups that have been successfully saved to disk */ protected abstract void onGroupsSaved(List items); /** * Called when the peer wants a list of new messages within a group that we have for him. * * @param recipient the recipient of the result * @param gxsId the group gxs ID * @param since the time after which the messages are relevant. Everything before is ignored * @return the available messages that we have */ protected abstract List onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since); /** * Called when the peer wants specific messages to be transferred to him, within a group. * * @param gxsId the group gxs ID * @param msgIds the ids of messages that the peer wants * @return the messages that we have available within the requested set */ protected abstract List onMessageListRequest(GxsId gxsId, Set msgIds); /** * Called when a peer sends the list of new messages that might interest us, within a group. * * @param gxsId the group gxs ID * @param msgIds the ids of new messages * @return the subset of those messages that we actually want */ protected abstract List onMessageListResponse(GxsId gxsId, Set msgIds); /** * Called when a message has been received. * * @param item the received message * @return true if we want to save it */ protected abstract boolean onMessageReceived(M item); /** * Called when the messages have been saved. * * @param items the list of saved messages */ protected abstract void onMessagesSaved(List items); /** * Called when a comment has been received. * * @param item the received comment * @return true if we want to save it */ protected abstract boolean onCommentReceived(CommentMessageItem item); /** * Called when the comments have been saved. * * @param items the list of saved comments */ protected abstract void onCommentsSaved(List items); /** * Called when a vote has been received. * * @param item the received vote * @return true if we want to save it */ protected abstract boolean onVoteReceived(VoteMessageItem item); /** * Called when the votes have been saved. * * @param items the list of saved votes */ protected abstract void onVotesSaved(List items); /** * Called to gather the authentication requirements for the service. * * @return the authentication requirements */ protected abstract GxsAuthentication getAuthentication(); /** * Called periodically (normally each minute, or when receiving a {@link GxsSyncNotifyItem}) to sync messages. * * @param recipient the peer to sync messages with */ protected abstract void syncMessages(PeerConnection recipient); @Override public RsServiceType getServiceType() { throw new IllegalStateException("Must override getServiceType()"); } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.LOW; } @Override public void initialize() { executorService = ExecutorUtils.createFixedRateExecutor(this::manageAll, getInitPriority().getMaxTime() + PENDING_VERIFICATION_DELAY.toSeconds() / 2, PENDING_VERIFICATION_DELAY.toSeconds()); } @Override public void initialize(PeerConnection peerConnection) { peerConnection.scheduleWithFixedDelay( () -> autoSync(peerConnection), ThreadLocalRandom.current().nextLong(SYNCHRONIZATION_DELAY_INITIAL_MIN.toSeconds(), SYNCHRONIZATION_DELAY_INITIAL_MAX.toSeconds() + 1), SYNCHRONIZATION_DELAY.toSeconds(), TimeUnit.SECONDS ); } @Override public void handleItem(PeerConnection sender, Item item) { if (item instanceof GxsExchange gxsExchangeItem) { if (gxsExchangeItem.getTransactionId() != 0) { handleTransaction(sender, gxsExchangeItem); } else if (item instanceof GxsSyncGroupRequestItem gxsSyncGroupRequestItem) { handleGxsSyncGroupRequestItem(sender, gxsSyncGroupRequestItem); } else if (item instanceof GxsSyncMessageRequestItem gxsSyncMessageRequestItem) { handleGxsSyncMessageRequestItem(sender, gxsSyncMessageRequestItem); } } else { switch (item) { case GxsSyncGroupStatsItem gxsSyncGroupStatsItem -> handleGxsSyncGroupStats(sender, gxsSyncGroupStatsItem); case GxsSyncNotifyItem gxsSyncNotifyItem -> handleGxsSyncNotifyItem(sender, gxsSyncNotifyItem); case null, default -> log.error("Not a GxsExchange item: {}, ignoring", item); } } } @Override public void cleanup() { ExecutorUtils.cleanupExecutor(executorService); } /** * Syncs automatically each SYNCHRONIZATION_DELAY, unless a syncNow() was performed in between, in that case * skip until the next one. * * @param peerConnection the peer connection */ private void autoSync(PeerConnection peerConnection) { var lastSync = (Instant) peerConnection.getServiceData(this, KEY_LAST_SYNC_REQUEST).orElse(Instant.EPOCH); if (Duration.between(lastSync, Instant.now()).compareTo(SYNCHRONIZATION_DELAY.minusSeconds(1)) > 0) { syncNow(peerConnection); } } private void syncNow(PeerConnection peerConnection) { var gxsSyncGroupRequestItem = new GxsSyncGroupRequestItem(gxsHelperService.getLastPeerGroupsUpdate(peerConnection.getLocation(), getServiceType())); log.debug("Asking {} for last local sync {}", peerConnection, log.isDebugEnabled() ? Instant.ofEpochSecond(gxsSyncGroupRequestItem.getLastUpdated()) : null); peerConnectionManager.writeItem(peerConnection, gxsSyncGroupRequestItem, this); peerConnection.putServiceData(this, KEY_LAST_SYNC_REQUEST, Instant.now()); } private void manageAll() { checkPendingGroupsAndMessages(); askGroupStatisticsIfNeeded(); } private void checkPendingGroupsAndMessages() { try (var ignored = new DatabaseSession(databaseSessionManager)) { var randomPeer = peerConnectionManager.getRandomPeer(); if (randomPeer != null) { verifyAndStoreGroups(randomPeer, pendingGxsGroups.keySet()); verifyAndStoreMessages(randomPeer, pendingGxsMessages.keySet()); } pendingGxsGroups.entrySet().removeIf(gxsGroupItemLongEntry -> gxsGroupItemLongEntry.getValue() < 0); pendingGxsMessages.entrySet().removeIf(gxsMessageItemLongEntry -> gxsMessageItemLongEntry.getValue() < 0); } } private void askGroupStatisticsIfNeeded() { var now = Instant.now(); if (Duration.between(lastGroupStatistics, now).compareTo(GROUP_STATISTICS_DELAY) <= 0) { return; } lastGroupStatistics = now; try (var ignored = new DatabaseSession(databaseSessionManager)) { var randomPeer = peerConnectionManager.getRandomPeer(); if (randomPeer != null) { var ids = gxsHelperService.findGroupsToRequestStats(now, GROUP_STATISTICS_DELAY); ids.forEach(gxsId -> peerConnectionManager.writeItem(randomPeer, new GxsSyncGroupStatsItem(RequestType.REQUEST, gxsId), this)); } } } private void handleGxsSyncNotifyItem(PeerConnection peerConnection, GxsSyncNotifyItem item) { log.debug("Got sync notify {} from {}", item, peerConnection); syncNow(peerConnection); syncMessages(peerConnection); } private void handleGxsSyncGroupStats(PeerConnection peerConnection, GxsSyncGroupStatsItem item) { log.debug("Got group stat {} from {}", item, peerConnection); if (item.getRequestType() == RequestType.REQUEST) { gxsHelperService.findGroupStatsByGxsId(item.getGxsId()) .ifPresent(gxsSyncGroupStatsItem -> peerConnectionManager.writeItem(peerConnection, gxsSyncGroupStatsItem, this)); } else if (item.getRequestType() == RequestType.RESPONSE) { gxsHelperService.updateGroupStats(item); } } protected void sendSyncNotification(PeerConnection peerConnection) { CompletableFuture.runAsync((NoSuppressedRunnable) () -> { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException _) { Thread.currentThread().interrupt(); return; } var gxsSyncNotifyItem = new GxsSyncNotifyItem(); log.debug("Sending sync notification to {}", peerConnection); peerConnectionManager.writeItem(peerConnection, gxsSyncNotifyItem, this); }); } private void handleGxsSyncGroupRequestItem(PeerConnection peerConnection, GxsSyncGroupRequestItem item) { log.debug("{} sent group {}", peerConnection, item); var transactionId = getNextTransactionId(peerConnection); var since = Instant.ofEpochSecond(item.getLastUpdated()); var latestGroup = areGroupUpdatesAvailableForPeer(since); if (latestGroup != null) { log.debug("Group updates available, sending ids..."); List items = new ArrayList<>(); onAvailableGroupListRequest(peerConnection).forEach(gxsGroupItem -> { if (isGxsAllowedForPeer(peerConnection, gxsGroupItem)) { log.debug("Adding group id of item: {}", gxsGroupItem); var gxsSyncGroupItem = new GxsSyncGroupItem( RESPONSE, gxsGroupItem, transactionId); items.add(gxsSyncGroupItem); } }); // the items are included in a transaction (they all have the same transaction number) log.debug("Calling transaction for group, number of items: {}", items.size()); gxsTransactionManager.startOutgoingTransactionForGroupListResponse( peerConnection, items, latestGroup, transactionId, this ); } else { log.debug("No group updates available"); } // XXX: check if the peer is subscribed, encrypt or not the group, etc... it's rsgxsnetservice.cc/handleRecvSyncGroup we might not need that for gxsid transferts // XXX: to handle the synchronization we must know which tables to use, then it's generic } private void handleGxsSyncMessageRequestItem(PeerConnection peerConnection, GxsSyncMessageRequestItem item) { log.debug("{} sent message {}", peerConnection, item); var transactionId = getNextTransactionId(peerConnection); var lastUpdated = Instant.ofEpochSecond(item.getLastUpdated()); var since = Instant.ofEpochSecond(item.getLimit()); var latestMessage = areMessageUpdatesAvailableForPeer(item.getGxsId(), lastUpdated, since); if (latestMessage != null) { log.debug("New messages available, sending ids..."); List items = new ArrayList<>(); onPendingMessageListRequest(peerConnection, item.getGxsId(), since).forEach(gxsMessageItem -> { log.debug("Adding message id of item {}", gxsMessageItem); var gxsSyncMessageItem = new GxsSyncMessageItem( GxsSyncMessageItem.RESPONSE, gxsMessageItem, transactionId); items.add(gxsSyncMessageItem); }); log.debug("Calling transaction for message, number of items: {}", items.size()); gxsTransactionManager.startOutgoingTransactionForMessageListResponse( peerConnection, items, latestMessage, transactionId, this ); } // XXX: maybe some more to do, check rsgxsnetservice.cc/handleRecvSyncMsg } private void handleTransaction(PeerConnection peerConnection, GxsExchange item) { if (item instanceof GxsTransactionItem gxsTransactionItem) { gxsTransactionManager.processIncomingTransaction(peerConnection, gxsTransactionItem, this); } else { gxsTransactionManager.addIncomingItemToTransaction(peerConnection, item, this); } } protected synchronized int getNextTransactionId(PeerConnection peerConnection) { // The transaction id needs to be stored globally on the peer connection as multiple services can use them var transactionId = (int) peerConnection.getPeerData(KEY_GXS_TRANSACTION_ID).orElse(0) + 1; peerConnection.putPeerData(KEY_GXS_TRANSACTION_ID, transactionId); return transactionId; } private Instant areGroupUpdatesAvailableForPeer(Instant lastPeerUpdate) { var lastServiceUpdate = gxsHelperService.getLastServiceGroupsUpdate(getServiceType()); // XXX: there should be a way to detect if the peer is sending a lastPeerUpdate several times (means the transaction isn't complete yet) if (lastPeerUpdate.isBefore(lastServiceUpdate)) { return lastServiceUpdate; } return null; } private Instant areMessageUpdatesAvailableForPeer(GxsId gxsId, Instant lastPeerUpdate, Instant since) { var groupList = onGroupListRequest(Set.of(gxsId)); if (groupList.isEmpty()) { log.debug("Peer requested unavailable group {}", gxsId); // Switched severity do debug instead of warn because RS seems to request without checking return null; } Instant latestMessage = Instant.EPOCH; groupList = groupList.stream() .filter(g -> g.isSubscribed() && g.getLastUpdated() != null && lastPeerUpdate.isBefore(g.getLastUpdated()) && g.getLastUpdated().isAfter(since)) .toList(); if (groupList.isEmpty()) { return null; } for (var group : groupList) { if (group.getLastUpdated().isAfter(latestMessage)) { latestMessage = group.getLastUpdated(); } } return latestMessage; } private boolean isGxsAllowedForPeer(PeerConnection peerConnection, G item) { return switch (item.getCircleType()) { case LOCAL, EXTERNAL_SELF, YOUR_EYES_ONLY -> false; case PUBLIC -> true; case UNKNOWN -> true; // Identities don't have the circle type initialized case EXTERNAL -> false; // XXX: should be true and the data should be encrypted then case YOUR_FRIENDS_ONLY -> isGxsAllowedForFriendGroup(peerConnection, item); }; } private boolean isGxsAllowedForFriendGroup(PeerConnection peerConnection, G item) { // XXX: implement rsgxsnetservice/checkPermissionsForFriendGroup() return false; } /** * Processes the transaction. * * @param peerConnection the peer connection who sent the items * @param transaction the transaction to process */ public void processItems(PeerConnection peerConnection, Transaction transaction) { if (isEmpty(transaction.getItems())) { log.debug("{} has no items in the transaction", peerConnection); return; // nothing to do } if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_GROUP_LIST_RESPONSE)) { @SuppressWarnings("unchecked") var gxsIdsMap = ((List) transaction.getItems()).stream() .collect(toMap(GxsSyncGroupItem::getGxsId, gxsSyncGroupItem -> Instant.ofEpochSecond(gxsSyncGroupItem.getPublishTimestamp()))); log.debug("{} has the following group ids (new or updates) for us (total: {}): {} ...", peerConnection, gxsIdsMap.size(), gxsIdsMap.keySet().stream().limit(10).toList()); requestGxsGroups(peerConnection, onAvailableGroupListResponse(gxsIdsMap)); } else if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_GROUP_LIST_REQUEST)) { @SuppressWarnings("unchecked") var gxsIds = ((List) transaction.getItems()).stream() .map(GxsSyncGroupItem::getGxsId).collect(toSet()); log.debug("{} wants the following group ids (total: {}): {} ...", peerConnection, gxsIds.size(), gxsIds.stream().limit(10).toList()); sendGxsGroups(peerConnection, onGroupListRequest(gxsIds)); } else if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_GROUPS)) { @SuppressWarnings("unchecked") var gxsGroupItems = ((List) transaction.getItems()).stream() .map(this::convertTransferGroupToGxsGroup) .toList(); verifyAndStoreGroups(peerConnection, gxsGroupItems); if (!gxsGroupItems.isEmpty()) { log.debug("{} sent groups", peerConnection); gxsHelperService.setLastPeerGroupsUpdate(peerConnection.getLocation(), transaction.getUpdated(), getServiceType()); gxsHelperService.setLastServiceGroupsUpdateNow(getServiceType()); peerConnectionManager.doForAllPeersExceptSender(this::sendSyncNotification, peerConnection, this); } } else if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_MESSAGE_LIST_RESPONSE)) { @SuppressWarnings("unchecked") var gxsId = ((List) transaction.getItems()).stream() .map(GxsSyncMessageItem::getGxsId).findFirst().orElse(null); @SuppressWarnings("unchecked") var msgIds = ((List) transaction.getItems()).stream() .map(GxsSyncMessageItem::getMsgId).collect(toSet()); log.debug("{} has the following msg ids for group {} (new) for us (total: {}): {} ...", peerConnection, gxsId, msgIds.size(), msgIds.stream().limit(10).toList()); var messagesWanted = onMessageListResponse(gxsId, msgIds); requestGxsMessages(peerConnection, gxsId, messagesWanted); if (messagesWanted.isEmpty()) { // If there was no message, it means we got them all already (from another peer or multiple transactions). We can set the timestamp. updateLastMessageUpdateAndBroadcastToOthers(peerConnection, gxsId, transaction.getUpdated()); } } else if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_MESSAGE_LIST_REQUEST)) { @SuppressWarnings("unchecked") var gxsId = ((List) transaction.getItems()).stream() .map(GxsSyncMessageItem::getGxsId).findFirst().orElse(null); @SuppressWarnings("unchecked") var msgIds = ((List) transaction.getItems()).stream() .map(GxsSyncMessageItem::getMsgId).collect(toSet()); log.debug("{} wants the following msg ids for group {} (total: {}): {} ...", peerConnection, gxsId, msgIds.size(), msgIds.stream().limit(10).toList()); sendGxsMessages(peerConnection, onMessageListRequest(gxsId, msgIds)); } else if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_MESSAGES)) { // This contains the message items, the votes and the comments @SuppressWarnings("unchecked") var gxsMessageItems = ((List) transaction.getItems()).stream() .map(this::convertTransferGroupToGxsMessage) .sorted(Comparator.comparing(GxsMessageItem::getPublished)) // Get older message first to facilitate marking messages as edited .toList(); verifyAndStoreMessages(peerConnection, gxsMessageItems); if (!gxsMessageItems.isEmpty()) { var gxsId = gxsMessageItems.getFirst().getGxsId(); log.debug("{} sent messages for group {}", peerConnection, gxsId); if (!ongoingGxsMessageTransfers.contains(gxsId)) { // If there's no more ongoing transfer for those messages, we can mark them as finished. updateLastMessageUpdateAndBroadcastToOthers(peerConnection, gxsId, transaction.getUpdated()); } } } else { log.debug("Unknown transaction {}", transaction); } } private void updateLastMessageUpdateAndBroadcastToOthers(PeerConnection peerConnection, GxsId group, Instant when) { gxsHelperService.setLastPeerMessageUpdate(peerConnection.getLocation(), group, when, getServiceType()); peerConnectionManager.doForAllPeersExceptSender(this::sendSyncNotification, peerConnection, this); } private void verifyAndStoreGroups(PeerConnection peerConnection, Collection groups) { List savedGroups = new ArrayList<>(groups.size()); for (var group : groups) { var data = ItemUtils.serializeItemForSignature(group, this); var validation = verifyGroupAdmin(group, data); // Validate author signature, if needed if (validation == VerificationStatus.OK && (group.getAuthorGxsId() != null || gxsAuthentication.isAuthorSigningGroups())) { if (group.getAuthorGxsId() == null) { log.warn("Failed to validate group {}: missing author id", group); continue; } validation = verifyGroupAuthor(peerConnection, group, data); if (validation == VerificationStatus.DELAYED) { continue; } } // If this is a group update, validate its admin signature using the public key we already have if (validation == VerificationStatus.OK) { validation = verifyGroupForUpdate(peerConnection, group, data); } // Save the group if everything is OK if (validation == VerificationStatus.OK) { gxsHelperService.saveGroup(group, this::onGroupReceived).ifPresent(savedGroups::add); } // If the group verification was delayed, remove it pendingGxsGroups.computeIfPresent(group, (_, _) -> -1L); } if (!savedGroups.isEmpty()) { onGroupsSaved(savedGroups); } } private VerificationStatus verifyGroupAdmin(G group, byte[] data) { var adminPublicKey = group.getAdminPublicKey(); if (adminPublicKey == null) { log.warn("Failed to validate group {}: missing admin key", group); return VerificationStatus.FAILED; } var adminSignature = group.getAdminSignature(); if (adminSignature == null) { log.warn("Failed to validate group {}: missing admin signature", group); return VerificationStatus.FAILED; } if (!RSA.verify(adminPublicKey, adminSignature, data)) { log.warn("Failed to validate group {}: wrong admin signature", group); return VerificationStatus.FAILED; } return VerificationStatus.OK; } private VerificationStatus verifyGroupAuthor(PeerConnection peerConnection, G gxsGroupItem, byte[] data) { if (gxsGroupItem.getAuthorSignature() == null) { log.warn("Missing author signature for group {}", gxsGroupItem); return VerificationStatus.FAILED; } var authorIdentity = identityManager.getGxsGroup(peerConnection, gxsGroupItem.getAuthorGxsId()); if (authorIdentity == null) { log.warn("Delaying verification of group {} (author: {})", gxsGroupItem, gxsGroupItem.getAuthorGxsId()); var existingDelay = pendingGxsGroups.putIfAbsent(gxsGroupItem, PENDING_VERIFICATION_MAX.toSeconds()); if (existingDelay != null) { var newDelay = existingDelay - PENDING_VERIFICATION_DELAY.toSeconds(); pendingGxsGroups.put(gxsGroupItem, newDelay); if (newDelay < 0) { log.warn("Failed to validate group {}: timeout exceeded", gxsGroupItem); } } return VerificationStatus.DELAYED; } else { var authorAdminPublicKey = authorIdentity.getAdminPublicKey(); if (authorAdminPublicKey == null) { log.warn("Failed to validate group {}: missing author admin key", gxsGroupItem); return VerificationStatus.FAILED; } var authorSignature = gxsGroupItem.getAuthorSignature(); if (authorSignature == null) { log.warn("Missing author signature for group {}", gxsGroupItem); return VerificationStatus.FAILED; } if (RSA.verify(authorAdminPublicKey, authorSignature, data)) { return VerificationStatus.OK; } else { log.warn("Failed to validate group {}: wrong author signature", gxsGroupItem); return VerificationStatus.FAILED; } } } private VerificationStatus verifyGroupForUpdate(PeerConnection peerConnection, G group, byte[] data) { var existingGroup = gxsHelperService.getGroup(group.getGxsId()); if (existingGroup != null) { // Validate the new group using the old key to certify this is an upgrade var existingAdminPublicKey = existingGroup.getAdminPublicKey(); if (!group.getPublished().isAfter(existingGroup.getPublished())) { log.warn("Failed to validate group {} for update: new group timestamp {} <= old group timestamp {}", group.getPublished(), existingGroup.getPublished(), group.getPublished()); return VerificationStatus.FAILED; } if (RSA.verify(existingAdminPublicKey, group.getAdminSignature(), data)) { // Copy the fields we want to retain. group.retainValues(existingGroup); // XXX: private keys? do we have groups with private keys? update should not replace them but keep the old ones if (group.getCircleType() == GxsCircleType.YOUR_FRIENDS_ONLY) { group.setOriginator(peerConnection.getLocation().getLocationIdentifier()); } return VerificationStatus.OK; } else { if (isSameKey(existingAdminPublicKey, group.getAdminPublicKey())) { log.warn("Failed to validate group {} for update: wrong admin signature", group); } else { log.warn("Failed to validate group {} for update: new public key doesn't match the old one", group); } return VerificationStatus.FAILED; } } else { group.setOriginator(peerConnection.getLocation().getLocationIdentifier()); return VerificationStatus.OK; } } private static boolean isSameKey(PublicKey a, PublicKey b) { return Arrays.equals(a.getEncoded(), b.getEncoded()); } private G createGxsGroupItem() { G gxsGroupItem; try { gxsGroupItem = itemGroupClass.getDeclaredConstructor().newInstance(); } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException _) { throw new IllegalArgumentException("Failed to instantiate " + itemGroupClass.getSimpleName() + " missing empty constructor?"); } return gxsGroupItem; } private G convertTransferGroupToGxsGroup(GxsTransferGroupItem fromItem) { var toItem = createGxsGroupItem(); fromItem.toGxsGroupItem(toItem); return toItem; } private void sendGxsGroups(PeerConnection peerConnection, List gxsGroupItems) { var transactionId = getNextTransactionId(peerConnection); List items = new ArrayList<>(); gxsGroupItems.forEach(gxsGroupItem -> items.add(new GxsTransferGroupItem(gxsGroupItem, transactionId, getServiceType()))); gxsTransactionManager.startOutgoingTransactionForGroupTransfer( peerConnection, items, gxsHelperService.getLastServiceGroupsUpdate(getServiceType()), transactionId, this ); } public void requestGxsGroups(PeerConnection peerConnection, Collection ids) // XXX: maybe use a future to know when the group arrived? it's possible by keeping a list of transactionIds then answering once the answer comes back { if (isEmpty(ids)) { return; } var transactionId = getNextTransactionId(peerConnection); List items = new ArrayList<>(); ids.forEach(gxsId -> items.add(new GxsSyncGroupItem(REQUEST, gxsId, transactionId))); gxsTransactionManager.startOutgoingTransactionForGroupListRequest(peerConnection, items, transactionId, this); } private void verifyAndStoreMessages(PeerConnection peerConnection, Collection messages) { List savedMessages = new ArrayList<>(); List savedComments = new ArrayList<>(); List savedVotes = new ArrayList<>(); Map lastPostedMap = new HashMap<>(); for (var message : messages) { var data = ItemUtils.serializeItemForSignature(message, this); var validation = VerificationStatus.OK; if (gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_PUBLISH : ROOT_NEEDS_PUBLISH)) { validation = verifyMessagePublish(message, data); } // Check requirements, but if the message has been signed anyway, we still need to validate it if (validation == VerificationStatus.OK && (message.hasAuthor() || gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_AUTHOR : ROOT_NEEDS_AUTHOR))) { validation = verifyMessageAuthor(peerConnection, message, data); if (validation == VerificationStatus.DELAYED) { continue; } } // Save the message if everything is OK if (validation == VerificationStatus.OK) { switch (message) { case CommentMessageItem commentMessageItem -> gxsHelperService.saveComment(commentMessageItem, this::onCommentReceived).ifPresent(savedComments::add); case VoteMessageItem voteMessageItem -> gxsHelperService.saveVote(voteMessageItem, this::onVoteReceived).ifPresent(savedVotes::add); default -> //noinspection unchecked gxsHelperService.saveMessage((M) message, this::onMessageReceived).ifPresent(savedMessages::add); } var lastPosted = lastPostedMap.computeIfAbsent(message.getGxsId(), _ -> message.getPublished()); if (message.getPublished().isAfter(lastPosted)) { lastPostedMap.put(message.getGxsId(), message.getPublished()); } } // If the message verification was delayed, remove it pendingGxsMessages.computeIfPresent(message, (_, _) -> -1L); } if (!savedMessages.isEmpty()) { markOriginalMessageAsHidden(savedMessages); onMessagesSaved(savedMessages); } if (!savedComments.isEmpty()) { markOriginalMessageAsHidden(savedComments); onCommentsSaved(savedComments); } if (!savedVotes.isEmpty()) { markOriginalMessageAsHidden(savedVotes); onVotesSaved(savedVotes); } lastPostedMap.forEach(gxsHelperService::updateLastPosted); } protected void markOriginalMessageAsHidden(Collection gxsMessageItems) { gxsMessageItems.forEach(gxsMessageItem -> { if (gxsMessageItem.getOriginalMsgId() != null && !gxsMessageItem.getOriginalMsgId().equals(gxsMessageItem.getMsgId())) { gxsHelperService.overrideMessage(gxsMessageItem.getGxsId(), gxsMessageItem.getOriginalMsgId(), gxsMessageItem.getAuthorGxsId()); } }); } private VerificationStatus verifyMessagePublish(GxsMessageItem message, byte[] data) { var group = gxsHelperService.getGroup(message.getGxsId()); if (group == null) { log.warn("Failed to find group for message: {}, dropping", message); return VerificationStatus.FAILED; } var publicKey = group.getPublishPublicKey(); if (publicKey == null) { log.warn("Failed to find group publish public key for message: {}, dropping", message); return VerificationStatus.FAILED; } var signature = message.getPublishSignature(); if (signature == null) { log.warn("Missing publish signature for message: {}, dropping", message); return VerificationStatus.FAILED; } if (RSA.verify(publicKey, signature, data)) { return VerificationStatus.OK; } else { log.warn("Failed to validate message {}: wrong publish signature", message); return VerificationStatus.FAILED; } } private VerificationStatus verifyMessageAuthor(PeerConnection peerConnection, GxsMessageItem message, byte[] data) { var signature = message.getAuthorSignature(); if (signature == null) { log.warn("Missing author signature for message {}", message); return VerificationStatus.FAILED; } var authorIdentity = identityManager.getGxsGroup(peerConnection, message.getAuthorGxsId()); if (authorIdentity == null) { log.warn("Delaying verification of message {}", message); var existingDelay = pendingGxsMessages.putIfAbsent(message, PENDING_VERIFICATION_MAX.toSeconds()); if (existingDelay != null) { var newDelay = existingDelay - PENDING_VERIFICATION_DELAY.toSeconds(); pendingGxsMessages.put(message, newDelay); if (newDelay < 0) { log.warn("Failed to validate message {}: timeout exceeded", message); } } return VerificationStatus.DELAYED; } else { var publicKey = authorIdentity.getAdminPublicKey(); if (publicKey == null) { log.warn("Failed to find author admin public key for message {}", message); return VerificationStatus.FAILED; } if (RSA.verify(publicKey, signature, data)) { // XXX: check for reputation here, if reputation is too low, remove return VerificationStatus.OK; } else { log.warn("Failed to validate message {}: wrong author signature", message); return VerificationStatus.FAILED; } } } public void sendGxsMessages(PeerConnection peerConnection, List gxsMessageItems) { var transactionId = getNextTransactionId(peerConnection); List items = new ArrayList<>(); Instant latestMessage = Instant.EPOCH; for (var gxsMessageItem : gxsMessageItems) { items.add(new GxsTransferMessageItem(gxsMessageItem, transactionId, getServiceType())); if (gxsMessageItem.getPublished().isAfter(latestMessage)) { latestMessage = gxsMessageItem.getPublished(); } } gxsTransactionManager.startOutgoingTransactionForMessageTransfer( peerConnection, items, latestMessage, transactionId, this ); } public void requestGxsMessages(PeerConnection peerConnection, GxsId gxsId, Collection msgIds) { if (isEmpty(msgIds)) { return; } var transactionId = getNextTransactionId(peerConnection); List items = new ArrayList<>(); // Ask for MESSAGES_PER_TRANSACTIONS messages at a time. This is done to avoid // overflowing the peer's queue. var count = 0; for (var msgId : msgIds) { items.add(new GxsSyncMessageItem(GxsSyncMessageItem.REQUEST, gxsId, msgId, transactionId)); if (++count == MESSAGES_PER_TRANSACTIONS) { break; } } // Mark/unmark as ongoing transaction to make sure // we update the peer timestamp when needed. if (count == MESSAGES_PER_TRANSACTIONS && msgIds.size() > MESSAGES_PER_TRANSACTIONS) { ongoingGxsMessageTransfers.add(gxsId); } else { ongoingGxsMessageTransfers.remove(gxsId); } gxsTransactionManager.startOutgoingTransactionForMessageListRequest(peerConnection, items, transactionId, this); } private M createGxsMessageItem() { M gxsMessageItem; try { gxsMessageItem = itemMessageClass.getDeclaredConstructor().newInstance(); } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException _) { throw new IllegalArgumentException("Failed to instantiate " + itemMessageClass.getSimpleName() + " missing empty constructor?"); } return gxsMessageItem; } private GxsMessageItem convertTransferGroupToGxsMessage(GxsTransferMessageItem fromItem) { var subType = fromItem.getMessageType(); var toItem = switch (subType) { case CommentMessageItem.SUBTYPE -> new CommentMessageItem(); case VoteMessageItem.SUBTYPE -> new VoteMessageItem(); default -> createGxsMessageItem(); }; return fromItem.toGxsMessageItem(toItem); } protected G createGroup(String name, boolean needsPublish) { KeyPair adminKeyPair = RSA.generateKeys(GXS_KEY_SIZE); KeyPair publishKeyPair = null; if (needsPublish) { publishKeyPair = RSA.generateKeys(GXS_KEY_SIZE); } return createGroup(name, adminKeyPair, publishKeyPair); } protected G createGroup(String name, KeyPair adminKeyPair, KeyPair publishKeyPair) { var adminPrivateKey = (RSAPrivateKey) adminKeyPair.getPrivate(); var adminPublicKey = (RSAPublicKey) adminKeyPair.getPublic(); // The GxsId is from the public admin key (n and e) var gxsId = RSA.getGxsId(adminPublicKey); var gxsGroupItem = createGxsGroupItem(); gxsGroupItem.setGxsId(gxsId); gxsGroupItem.setName(name); gxsGroupItem.updatePublished(); // Needs to be called before we set any key (validFrom is computed from it) gxsGroupItem.setAdminKeys(adminPrivateKey, adminPublicKey, gxsGroupItem.getPublished(), null); if (publishKeyPair != null) { var publishPrivateKey = (RSAPrivateKey) publishKeyPair.getPrivate(); var publishPublicKey = (RSAPublicKey) publishKeyPair.getPublic(); gxsGroupItem.setPublishKeys(RSA.getGxsId(publishPublicKey), publishPrivateKey, publishPublicKey, gxsGroupItem.getPublished(), null); } return gxsGroupItem; } protected void signGroupIfNeeded(GxsGroupItem group) { if (group.isExternal()) { return; // Only sign our own groups } // Sign as admin var data = ItemUtils.serializeItemForSignature(group, this); var signature = RSA.sign(group.getAdminPrivateKey(), data); group.setAdminSignature(signature); if (group.getAuthorGxsId() != null || gxsAuthentication.isAuthorSigningGroups()) { if (group.getAuthorGxsId() == null) { throw new IllegalArgumentException("Missing author id for signing group " + group); } var author = identityManager.getGxsGroup(group.getAuthorGxsId()); Objects.requireNonNull(author, "Couldn't get own identity. Shouldn't happen (tm)"); var authorSignature = RSA.sign(author.getAdminPrivateKey(), data); group.setAuthorSignature(authorSignature); } } protected final class MessageBuilder { private final M gxsMessageItem; private final GxsGroupItem group; private final IdentityGroupItem author; public MessageBuilder(GxsGroupItem group, IdentityGroupItem author, String name) { this.group = group; this.author = author; gxsMessageItem = createGxsMessageItem(); gxsMessageItem.setGxsId(group.getGxsId()); gxsMessageItem.setName(name); if (author != null) { gxsMessageItem.setAuthorGxsId(author.getGxsId()); } } public MessageBuilder originalMsgId(MsgId originalMsgId) { Objects.requireNonNull(originalMsgId, "originalMsgId must not be null"); gxsMessageItem.setOriginalMsgId(originalMsgId); return this; } public MessageBuilder parentMsgId(MsgId parentMsgId) { // XXX: if parentId != 0L, then threadId must be set gxsMessageItem.setParentMsgId(parentMsgId); return this; } public M getMessageItem() { return gxsMessageItem; } public M build() { gxsMessageItem.updatePublished(); // XXX: serviceType? how? how does group do it? // The identifier is the sha1 hash of the data and meta (note: do not set any serialized fields after that call!) var data = ItemUtils.serializeItemForSignature(gxsMessageItem, GxsRsService.this); var md = new Sha1MessageDigest(); md.update(data); gxsMessageItem.setMsgId(new MsgId(md.getBytes())); // The signature is performed afterwards signMessage(gxsMessageItem, data); return gxsMessageItem; } private void signMessage(GxsMessageItem message, byte[] data) { if (gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_PUBLISH : ROOT_NEEDS_PUBLISH)) { var publishPrivateKey = group.getPublishPrivateKey(); if (publishPrivateKey == null) { throw new IllegalArgumentException("Message " + message + " requires a publish key but there's none"); } var signature = RSA.sign(publishPrivateKey, data); message.setPublishSignature(signature); } if (author != null || gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_AUTHOR : ROOT_NEEDS_AUTHOR)) { if (author == null) { throw new IllegalArgumentException("Message " + message + " requires an author but there's none"); } var signature = RSA.sign(author.getAdminPrivateKey(), data); message.setAuthorSignature(signature); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/GxsTransactionManager.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import io.xeres.app.application.events.PeerDisconnectedEvent; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.service.gxs.Transaction.Direction; import io.xeres.app.xrs.service.gxs.item.*; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.util.ExecutorUtils; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import java.time.Instant; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import static io.xeres.app.xrs.service.gxs.Transaction.Direction.INCOMING; import static io.xeres.app.xrs.service.gxs.Transaction.Direction.OUTGOING; import static io.xeres.app.xrs.service.gxs.Transaction.State; import static io.xeres.app.xrs.service.gxs.item.TransactionFlags.*; /** * Manages incoming and outgoing transactions. * Transactions work this way: *

* Incoming transactions: *

    *
  • we receive a GxsTransactionItem with flag START which contains the expected number of items
  • *
  • we send back a GxsTransactionItem with flag START_ACKNOWLEDGE
  • *
  • the peer sends GxsExchange items
  • *
  • once we have received all items, we send a GxsTransactionItem with flag END_SUCCESS
  • *
*

* Outgoing transactions: *

    *
  • we send a GxsTransactionItem with flag START which contains the expected number of items
  • *
  • the peer sends back a GxsTransactionItem with flag START_ACKNOWLEDGE
  • *
  • we send GxsExchange items to the peer
  • *
  • once the peer has received all the items, it sends back a GxsTransactionItem with flag END_SUCCESS
  • *
*

* Transaction diagram * @see Transaction */ @Service public class GxsTransactionManager { private static final Logger log = LoggerFactory.getLogger(GxsTransactionManager.class); private final PeerConnectionManager peerConnectionManager; private final Map>> incomingTransactions = new ConcurrentHashMap<>(); private final Map>> outgoingTransactions = new ConcurrentHashMap<>(); private ScheduledExecutorService executorService; public GxsTransactionManager(PeerConnectionManager peerConnectionManager) { this.peerConnectionManager = peerConnectionManager; } @PostConstruct private void init() { executorService = ExecutorUtils.createFixedRateExecutor(this::cleanupTransactions, Transaction.TRANSACTION_TIMEOUT.toSeconds() + 30, Transaction.TRANSACTION_TIMEOUT.toSeconds()); } @PreDestroy private void cleanup() { ExecutorUtils.cleanupExecutor(executorService); } /** * Removes all transactions that have a timeout. */ private void cleanupTransactions() { incomingTransactions.forEach((_, transactionMap) -> transactionMap.entrySet().removeIf(transaction -> transaction.getValue().hasTimedOut())); outgoingTransactions.forEach((_, transactionMap) -> transactionMap.entrySet().removeIf(transaction -> transaction.getValue().hasTimedOut())); } /** * Starts an outgoing transaction to request a list of gxs group IDs that we want the peer to transfer to us. * * @param peerConnection the peer * @param items gxs group IDs * @param transactionId the transaction ID * @param gxsRsService the service the transaction is bound to */ public void startOutgoingTransactionForGroupListRequest(PeerConnection peerConnection, List items, int transactionId, GxsRsService gxsRsService) { var transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_GROUP_LIST_REQUEST), items, items.size(), gxsRsService, OUTGOING); startOutgoingTransaction(peerConnection, transaction, Instant.EPOCH); } /** * Starts an outgoing transaction to request a list of gxs message IDs that we want the peer to transfer to us. * * @param peerConnection the peer * @param items gxs message IDs * @param transactionId the transaction ID * @param gxsRsService the service the transaction is bound to */ public void startOutgoingTransactionForMessageListRequest(PeerConnection peerConnection, List items, int transactionId, GxsRsService gxsRsService) { var transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_MESSAGE_LIST_REQUEST), items, items.size(), gxsRsService, OUTGOING); startOutgoingTransaction(peerConnection, transaction, Instant.EPOCH); } /** * Starts an outgoing transaction to respond with a list of gxs group IDs that we have and their update time. * * @param peerConnection the peer * @param items gxs group IDs * @param update the last update of the list * @param transactionId the transaction ID * @param gxsRsService the service the transaction is bound to */ public void startOutgoingTransactionForGroupListResponse(PeerConnection peerConnection, List items, Instant update, int transactionId, GxsRsService gxsRsService) { var transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_GROUP_LIST_RESPONSE), items, items.size(), gxsRsService, OUTGOING); startOutgoingTransaction(peerConnection, transaction, update); } /** * Starts an outgoing transaction to respond with a list of gxs message IDs that we have and their update time. * * @param peerConnection the peer * @param items gxs message IDs * @param update the last update of the list * @param transactionId the transaction ID * @param gxsRsService the service the transaction is bound to */ public void startOutgoingTransactionForMessageListResponse(PeerConnection peerConnection, List items, Instant update, int transactionId, GxsRsService gxsRsService) { var transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_MESSAGE_LIST_RESPONSE), items, items.size(), gxsRsService, OUTGOING); startOutgoingTransaction(peerConnection, transaction, update); } /** * Starts an outgoing transaction to transfer gxs groups. * * @param peerConnection the peer * @param items gxs groups * @param update the last update of the groups * @param transactionId the transaction ID * @param gxsRsService the service the transaction is bound to */ public void startOutgoingTransactionForGroupTransfer(PeerConnection peerConnection, List items, Instant update, int transactionId, GxsRsService gxsRsService) { var transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_GROUPS), items, items.size(), gxsRsService, OUTGOING); startOutgoingTransaction(peerConnection, transaction, update); } /** * Starts an outgoing transaction to transfer gxs messages. * * @param peerConnection the peer * @param items gxs messages * @param update the last update of the groups * @param transactionId the transaction ID * @param gxsRsService the service the transaction is bound to */ public void startOutgoingTransactionForMessageTransfer(PeerConnection peerConnection, List items, Instant update, int transactionId, GxsRsService gxsRsService) { var transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_MESSAGES), items, items.size(), gxsRsService, OUTGOING); startOutgoingTransaction(peerConnection, transaction, update); } /** * Processes an incoming transactions (incoming, confirmation of outgoing, success confirmation). * * @param peerConnection the peer * @param item a transaction item (contains transaction type, timestamp and total number of items) * @param gxsRsService the service the transaction is bound to */ public void processIncomingTransaction(PeerConnection peerConnection, GxsTransactionItem item, GxsRsService gxsRsService) { if (item.getFlags().contains(START)) { // This is an incoming connection log.debug("Received INCOMING transaction {} from peer {}, sending back ACK", item, peerConnection); Set transactionFlags = EnumSet.copyOf(item.getFlags()); transactionFlags.retainAll(TransactionFlags.ofTypes()); transactionFlags.add(START_ACKNOWLEDGE); var transaction = new Transaction<>(item.getTransactionId(), transactionFlags, new ArrayList<>(), item.getItemCount(), gxsRsService, INCOMING); transaction.setUpdated(Instant.ofEpochSecond(item.getUpdateTimestamp())); addTransaction(peerConnection, transaction, INCOMING); var readyTransactionItem = new GxsTransactionItem( transactionFlags, item.getTransactionId() ); peerConnectionManager.writeItem(peerConnection, readyTransactionItem, transaction.getService()); transaction.setState(State.RECEIVING); } else if (item.getFlags().contains(START_ACKNOWLEDGE)) { // This is the confirmation by the peer of our outgoing connection log.debug("Confirmation of OUTGOING transaction {} from peer {}, sending items...", item, peerConnection); var transaction = getTransaction(peerConnection, item.getTransactionId(), OUTGOING); transaction.setState(State.SENDING); log.debug("{} items to go", transaction.getItems().size()); transaction.getItems().forEach(gxsExchange -> peerConnectionManager.writeItem(peerConnection, gxsExchange, transaction.getService())); log.debug("done"); transaction.setState(State.WAITING_CONFIRMATION); } else if (item.getFlags().contains(END_SUCCESS)) { // The peer confirms success log.debug("Got END_SUCCESS transaction {} from peer {}, removing the transaction", item, peerConnection); var transaction = getTransaction(peerConnection, item.getTransactionId(), OUTGOING); transaction.setState(State.COMPLETED); removeTransaction(peerConnection, transaction); } } /** * Adds an incoming item to an existing transaction. * * @param peerConnection the peer * @param item the item to add to the transaction * @param gxsRsService the service the transaction is bound to */ public void addIncomingItemToTransaction(PeerConnection peerConnection, GxsExchange item, GxsRsService gxsRsService) { log.trace("Adding transaction item: {}", item); var transaction = getTransaction(peerConnection, item.getTransactionId(), INCOMING); transaction.addItem(item); if (transaction.hasAllItems()) { log.debug("Received all items of {}, sending COMPLETED", transaction); transaction.setState(State.COMPLETED); var successTransactionItem = new GxsTransactionItem( EnumSet.of(END_SUCCESS), transaction.getId() ); peerConnectionManager.writeItem(peerConnection, successTransactionItem, transaction.getService()); gxsRsService.processItems(peerConnection, transaction); // XXX: how will processItems() know what the items are? should the transaction have something to know that? yes, the flag... removeTransaction(peerConnection, transaction); // XXX: in the case that interest us, GxsIdService would call requestGxsGroups() } } @EventListener public void onPeerDisconnectedEvent(PeerDisconnectedEvent event) { incomingTransactions.remove(event.locationIdentifier()); outgoingTransactions.remove(event.locationIdentifier()); } private void addTransaction(PeerConnection peerConnection, Transaction transaction, Direction direction) { Map>> transactionList = switch (direction) { case OUTGOING -> outgoingTransactions; case INCOMING -> incomingTransactions; }; var transactionMap = transactionList.computeIfAbsent(peerConnection.getLocation().getLocationIdentifier(), _ -> new HashMap<>()); if (transactionMap.put(transaction.getId(), transaction) != null && direction == OUTGOING) { throw new IllegalStateException("Transaction " + transaction.getId() + " (OUTGOING) for peer " + peerConnection + " already exists. Should not happen (tm)"); } } private Transaction getTransaction(PeerConnection peerConnection, int id, Direction direction) { var locationIdentifier = peerConnection.getLocation().getLocationIdentifier(); var transactionMap = direction == INCOMING ? incomingTransactions.get(locationIdentifier) : outgoingTransactions.get(locationIdentifier); if (transactionMap == null) { throw new IllegalStateException("No existing transaction for peer " + peerConnection); } var transaction = transactionMap.get(id); if (transaction == null) { throw new IllegalStateException("No existing transaction for peer " + peerConnection); } if (transaction.hasTimedOut()) { throw new IllegalStateException("Transaction timed out for peer " + peerConnection); } return transaction; } private void removeTransaction(PeerConnection peerConnection, Transaction transaction) { var locationIdentifier = peerConnection.getLocation().getLocationIdentifier(); var transactionMap = transaction.getDirection() == INCOMING ? incomingTransactions.get(locationIdentifier) : outgoingTransactions.get(locationIdentifier); if (transactionMap == null) { throw new IllegalStateException("No existing transaction for removal for peer " + peerConnection); } if (!transactionMap.remove(transaction.getId(), transaction)) { throw new IllegalStateException("No existing transaction for removal for peer " + peerConnection); } } private void startOutgoingTransaction(PeerConnection peerConnection, Transaction transaction, Instant update) { log.debug("Starting outgoing transaction {} with peer {}", transaction, peerConnection); addTransaction(peerConnection, transaction, OUTGOING); var startTransactionItem = new GxsTransactionItem( transaction.getTransactionFlags(), transaction.getItems().size(), (int) update.getEpochSecond(), transaction.getId()); peerConnectionManager.writeItem(peerConnection, startTransactionItem, transaction.getService()); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/Transaction.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.gxs.item.GxsExchange; import io.xeres.app.xrs.service.gxs.item.TransactionFlags; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Set; /** * A Transaction is a way to group multiple items of the same type that have the same transaction id. Transactions can be outgoing or incoming and have * different states. Once a transaction is complete, its items can be accessed. * * @param the GxsExchange type. GxsExchange for incoming transactions and a subclass for outgoing transactions. * @see GxsTransactionManager */ public class Transaction { private static final Logger log = LoggerFactory.getLogger(Transaction.class); public enum State { STARTING, RECEIVING, SENDING, COMPLETED, FAILED, WAITING_CONFIRMATION } public enum Direction { INCOMING, OUTGOING } public static final Duration TRANSACTION_TIMEOUT = Duration.ofSeconds(2000); private final int id; private State state; private final Direction direction; private final Set transactionFlags; private final Instant start; private final Duration timeout; private final List items; private final int itemCount; private final GxsRsService service; private Instant updated; Transaction(int id, Set transactionFlags, List items, int itemCount, GxsRsService service, Direction direction) { this.id = id; this.transactionFlags = transactionFlags; this.items = items; this.itemCount = itemCount; timeout = TRANSACTION_TIMEOUT; this.service = service; state = direction == Direction.OUTGOING ? State.WAITING_CONFIRMATION : State.STARTING; this.direction = direction; start = Instant.now(); } public int getId() { return id; } public State getState() { return state; } public Direction getDirection() { return direction; } public void setState(State state) { this.state = state; } public List getItems() { return items; } @SuppressWarnings("unchecked") public void addItem(GxsExchange item) { items.add((T) item); } public RsService getService() { return service; } public boolean hasAllItems() { log.trace("expected number of items: {}, current number of items: {}", itemCount, items.size()); return itemCount == items.size(); } public boolean hasTimedOut() { return start.plus(timeout).isBefore(Instant.now()); } public Set getTransactionFlags() { return transactionFlags; } public Instant getUpdated() { return updated; } public void setUpdated(Instant updated) { this.updated = updated; } @Override public String toString() { return "Transaction{" + "id=" + id + ", flags=" + transactionFlags + ", state=" + state + ", type=" + direction + ", itemCount=" + itemCount + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/doc-files/transaction.puml ================================================ @startuml rnote over Juergen: STARTING Juergen -> Heike : START rnote over Heike : RECEIVING Juergen <- Heike : START_ACKNOWLEDGE rnote over Juergen: SENDING Juergen -> Heike: Sending data Juergen -> Heike: Sending data Juergen -> Heike: ... Juergen -> Heike: Sending data rnote over Juergen: WAITING_CONFIRMATION rnote over Heike: COMPLETED Heike -> Juergen: END_SUCCESS rnote over Juergen: COMPLETED @enduml ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/doc-files/transfer.puml ================================================ @startuml Juergen -> Heike : GxsSyncGroupRequestItem rnote over Heike : onAvailableGroupListRequest() Juergen <- Heike : GROUP_LIST_RESPONSE //List// rnote over Juergen: onAvailableGroupListResponse() Juergen -> Heike: GROUP_LIST_REQUEST //List// rnote over Heike: onGroupListRequest() Heike -> Juergen: TRANSFER //List// rnote over Juergen: onGroupReceived() rnote over Juergen: onGroupReceived() rnote over Juergen: onGroupReceived() rnote over Juergen: ... rnote over Juergen: onGroupsSaved() @enduml ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/DynamicServiceType.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; /** * This interface is used for items that don't have an intrinsic service type, because for example * they're shared between multiple services (Gxs, ...). */ public interface DynamicServiceType { int getServiceType(); void setServiceType(int serviceType); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsExchange.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; public abstract class GxsExchange extends Item implements DynamicServiceType { @RsSerialized private int transactionId; private int serviceType; @Override public int getServiceType() { return serviceType; } /** * GxsExchange items are shared between GxsServices. Make sure this is set by whatever creates the item. * * @param serviceType the service type */ @Override public void setServiceType(int serviceType) { this.serviceType = serviceType; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); } public int getTransactionId() { return transactionId; } public void setTransactionId(int transactionId) { this.transactionId = transactionId; } @Override public GxsExchange clone() { return (GxsExchange) super.clone(); } @Override public String toString() { return "GxsExchange{" + "transactionId=" + transactionId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncGroupItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.GxsId; /** * Item used to send the list of new groups that we have for a peer. */ public class GxsSyncGroupItem extends GxsExchange { public static final byte REQUEST = 0x1; public static final byte RESPONSE = 0x2; @RsSerialized private byte flags; @RsSerialized private GxsId gxsId; @RsSerialized private int publishTimestamp; @RsSerialized private GxsId authorGxsId; @SuppressWarnings("unused") public GxsSyncGroupItem() { } public GxsSyncGroupItem(byte flags, GxsGroupItem groupItem, int transactionId) { this.flags = flags; publishTimestamp = (int) groupItem.getPublished().getEpochSecond(); gxsId = groupItem.getGxsId(); authorGxsId = groupItem.getAuthorGxsId(); setTransactionId(transactionId); } public GxsSyncGroupItem(byte flags, GxsId gxsId, int transactionId) { this.flags = flags; this.gxsId = gxsId; setTransactionId(transactionId); } @Override public int getSubType() { return 2; } public GxsId getGxsId() { return gxsId; } public int getPublishTimestamp() { return publishTimestamp; } @Override public GxsSyncGroupItem clone() { return (GxsSyncGroupItem) super.clone(); } @Override public String toString() { return "GxsSyncGroupItem{" + "flags=" + flags + ", publishTimestamp=" + publishTimestamp + ", gxsId=" + gxsId + ", authorGxsId=" + authorGxsId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncGroupRequestItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.xrs.serialization.RsSerialized; import java.time.Instant; import static io.xeres.app.xrs.serialization.TlvType.STR_HASH_SHA1; /** * Item used to request new group list from a peer. Sent each minute with * the last syncing time. */ public class GxsSyncGroupRequestItem extends GxsExchange { @SuppressWarnings("unused") @RsSerialized private byte flags; // unused @SuppressWarnings("unused") @RsSerialized private int limit; // unused @SuppressWarnings("unused") @RsSerialized(tlvType = STR_HASH_SHA1) private String syncHash; // unused. This is old stuff where it used to transfer files instead of building tunnels @RsSerialized private int lastUpdated; // last group update @SuppressWarnings("unused") public GxsSyncGroupRequestItem() { } public GxsSyncGroupRequestItem(Instant lastUpdated) { this.lastUpdated = (int) lastUpdated.getEpochSecond(); } @Override public int getSubType() { return 1; } public int getLastUpdated() { return lastUpdated; } public void setLastUpdated(int lastUpdated) { this.lastUpdated = lastUpdated; } @Override public GxsSyncGroupRequestItem clone() { return (GxsSyncGroupRequestItem) super.clone(); } @Override public String toString() { return "GxsSyncGroupRequestItem{" + "lastUpdated=" + lastUpdated + ", super=" + super.toString() + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncGroupStatsItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.GxsId; /** * This item is used to request statistics about a group. * Note that it doesn't extend GxsExchange because it doesn't use transactions. */ public class GxsSyncGroupStatsItem extends Item implements DynamicServiceType { @RsSerialized private RequestType requestType; @RsSerialized private GxsId gxsId; @RsSerialized private int numberOfPosts; @RsSerialized private int lastPostTimestamp; private int serviceType; @SuppressWarnings("unused") public GxsSyncGroupStatsItem() { } public GxsSyncGroupStatsItem(RequestType requestType, GxsId gxsId) { this(requestType, gxsId, 0, 0); } public GxsSyncGroupStatsItem(RequestType requestType, GxsId gxsId, int lastPostTimestamp, int numberOfPosts) { this.requestType = requestType; this.gxsId = gxsId; this.lastPostTimestamp = lastPostTimestamp; this.numberOfPosts = numberOfPosts; } @Override public int getSubType() { return 3; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); // XXX: not sure... } @Override public int getServiceType() { return serviceType; } @Override public void setServiceType(int serviceType) { this.serviceType = serviceType; } public RequestType getRequestType() { return requestType; } public GxsId getGxsId() { return gxsId; } public int getNumberOfPosts() { return numberOfPosts; } public int getLastPostTimestamp() { return lastPostTimestamp; } @Override public GxsSyncGroupStatsItem clone() { return (GxsSyncGroupStatsItem) super.clone(); } @Override public String toString() { return "GxsSyncGroupStatsItem{" + "requestType=" + requestType + ", gxsId=" + gxsId + ", numberOfPosts=" + numberOfPosts + ", lastPostTimestamp=" + lastPostTimestamp + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncMessageItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; public class GxsSyncMessageItem extends GxsExchange { public static final byte REQUEST = 0x1; public static final byte RESPONSE = 0x2; @RsSerialized private byte flags; @RsSerialized private GxsId gxsId; @RsSerialized private MsgId msgId; @RsSerialized private GxsId authorGxsId; @SuppressWarnings("unused") public GxsSyncMessageItem() { } public GxsSyncMessageItem(byte flags, GxsMessageItem messageItem, int transactionId) { this.flags = flags; gxsId = messageItem.getGxsId(); msgId = messageItem.getMsgId(); authorGxsId = messageItem.getAuthorGxsId(); setTransactionId(transactionId); } public GxsSyncMessageItem(byte flags, GxsId gxsId, MsgId msgId, int transactionId) { this.flags = flags; this.gxsId = gxsId; this.msgId = msgId; setTransactionId(transactionId); } @Override public int getSubType() { return 8; } public GxsId getGxsId() { return gxsId; } public MsgId getMsgId() { return msgId; } @Override public GxsSyncMessageItem clone() { return (GxsSyncMessageItem) super.clone(); } @Override public String toString() { return "GxsSyncMessageItem{" + "flags=" + flags + ", gxsId=" + gxsId + ", msgId=" + msgId + ", authorGxsId=" + authorGxsId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncMessageRequestItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.GxsId; import java.time.Duration; import java.time.Instant; import static io.xeres.app.xrs.serialization.TlvType.STR_HASH_SHA1; /** * Item used to request messages from a peer's group. */ public class GxsSyncMessageRequestItem extends GxsExchange { public static final byte USE_HASHED_GROUP_ID = 0x2; // Use this when implementing circles (avoids someone outside the circle to know to which group we're subscribed) @RsSerialized private byte flags; @RsSerialized private int limit; // how far back to sync data @RsSerialized(tlvType = STR_HASH_SHA1) private String syncHash; @RsSerialized private GxsId gxsId; @RsSerialized private int lastUpdated; @SuppressWarnings("unused") public GxsSyncMessageRequestItem() { } public GxsSyncMessageRequestItem(GxsId gxsId, Instant lastUpdated, Duration limit) { this.gxsId = gxsId; this.lastUpdated = (int) lastUpdated.getEpochSecond(); this.limit = (int) Instant.now().minus(limit).getEpochSecond(); } @Override public int getSubType() { return 16; } public int getLimit() { return limit; } public void setLimit(int limit) { this.limit = limit; } public String getSyncHash() { return syncHash; } public void setSyncHash(String syncHash) { this.syncHash = syncHash; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public int getLastUpdated() { return lastUpdated; } public void setLastUpdated(int lastUpdated) { this.lastUpdated = lastUpdated; } @Override public GxsSyncMessageRequestItem clone() { return (GxsSyncMessageRequestItem) super.clone(); } @Override public String toString() { return "GxsSyncMessageRequestItem{" + "flags=" + flags + ", gxsId=" + gxsId + ", syncHash='" + syncHash + '\'' + ", lastUpdated=" + Instant.ofEpochSecond(lastUpdated) + ", limit=" + Instant.ofEpochSecond(limit) + ", super=" + super.toString() + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncNotifyItem.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; /** * Item used to tell the peer that there have been changes, and it should request them immediately without * waiting for the next sync delay. */ public class GxsSyncNotifyItem extends Item implements DynamicServiceType { private int serviceType; @Override public int getSubType() { return 144; } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); // XXX: not sure... } @Override public int getServiceType() { return serviceType; } @Override public void setServiceType(int serviceType) { this.serviceType = serviceType; } @Override public GxsSyncNotifyItem clone() { return (GxsSyncNotifyItem) super.clone(); } @Override public String toString() { return "GxsSyncNotifyItem {}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsTransactionItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.app.xrs.serialization.FieldSize; import io.xeres.app.xrs.serialization.RsSerialized; import java.util.Set; /** * This item is used to make a transaction, which guarantees * that a collection of items have been received. */ public class GxsTransactionItem extends GxsExchange { @RsSerialized(fieldSize = FieldSize.SHORT) private Set flags; @RsSerialized private int itemCount; @RsSerialized private int updateTimestamp; private int timestamp; // Not serialized, used for timeout detection (XXX: I don't think I need it) @SuppressWarnings("unused") public GxsTransactionItem() { } public GxsTransactionItem(Set flags, int itemCount, int updateTimestamp, int transactionId) { this.flags = flags; this.itemCount = itemCount; this.updateTimestamp = updateTimestamp; setTransactionId(transactionId); } public GxsTransactionItem(Set flags, int transactionId) { this.flags = flags; setTransactionId(transactionId); } @Override public int getSubType() { return 64; } public Set getFlags() { return flags; } public int getItemCount() { return itemCount; } public int getUpdateTimestamp() { return updateTimestamp; } public int getTimestamp() { return timestamp; } @Override public GxsTransactionItem clone() { return (GxsTransactionItem) super.clone(); } @Override public String toString() { return "GxsTransactionItem{" + "transactionFlag=" + flags + ", itemCount=" + itemCount + ", updateTimestamp=" + updateTimestamp + ", super=" + super.toString() + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsTransferGroupItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.xrs.item.ItemHeader; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.common.id.GxsId; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.EnumSet; import java.util.Set; /** * This is used to transfer group data within transactions. This is usually * backed by a GxsGroupItem which can serialize directly when not used in transactions. */ public class GxsTransferGroupItem extends GxsExchange implements RsSerializable { private byte position; // used for splitting up groups private GxsId gxsId; private byte[] group; // actual group data; the service specific data (ie. avatar, etc...)) private byte[] meta; // binary data for the group meta that is sent to our friends @SuppressWarnings("unused") public GxsTransferGroupItem() { } public GxsTransferGroupItem(GxsGroupItem gxsGroupItem, int transactionId, RsServiceType serviceType) { gxsId = gxsGroupItem.getGxsId(); setTransactionId(transactionId); setServiceType(serviceType.getType()); var groupBuf = Unpooled.buffer(); var itemHeader = new ItemHeader(groupBuf, getServiceType(), gxsGroupItem.getSubType()); itemHeader.writeHeader(); var groupSize = gxsGroupItem.writeDataObject(groupBuf, EnumSet.noneOf(SerializationFlags.class)); itemHeader.writeSize(groupSize); var metaBuf = Unpooled.buffer(); gxsGroupItem.writeMetaObject(metaBuf, EnumSet.noneOf(SerializationFlags.class)); group = getArray(groupBuf); meta = getArray(metaBuf); groupBuf.release(); metaBuf.release(); } public void toGxsGroupItem(GxsGroupItem gxsGroupItem) { var buf = Unpooled.copiedBuffer(meta, group); gxsGroupItem.readMetaObject(buf); ItemHeader.readHeader(buf, getServiceType(), gxsGroupItem.getSubType()); gxsGroupItem.readDataObject(buf); buf.release(); } @Override public int getSubType() { return 4; } public byte getPosition() { return position; } public GxsId getGxsId() { return gxsId; } public byte[] getGroup() { return group; } public byte[] getMeta() { return meta; } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = Serializer.serialize(buf, getTransactionId()); size += Serializer.serialize(buf, position); size += Serializer.serialize(buf, gxsId, GxsId.class); size += Serializer.serializeTlvBinary(buf, getServiceType(), group); size += Serializer.serializeTlvBinary(buf, getServiceType(), meta); return size; } @Override public void readObject(ByteBuf buf) { setTransactionId(Serializer.deserializeInt(buf)); position = Serializer.deserializeByte(buf); gxsId = (GxsId) Serializer.deserializeIdentifier(buf, GxsId.class); group = Serializer.deserializeTlvBinary(buf, getServiceType()); meta = Serializer.deserializeTlvBinary(buf, getServiceType()); } @Override public GxsTransferGroupItem clone() { return (GxsTransferGroupItem) super.clone(); } @Override public String toString() { return "GxsTransferGroupItem{" + "position=" + position + ", gxsId=" + gxsId + '}'; } private static byte[] getArray(ByteBuf buf) { var out = new byte[buf.writerIndex()]; buf.readBytes(out); return out; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsTransferMessageItem.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.xrs.item.ItemHeader; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.EnumSet; import java.util.Set; public class GxsTransferMessageItem extends GxsExchange implements RsSerializable { private byte position; private GxsId gxsId; private MsgId msgId; private byte[] message; private byte[] meta; @SuppressWarnings("unused") public GxsTransferMessageItem() { } public GxsTransferMessageItem(GxsMessageItem gxsMessageItem, int transactionId, RsServiceType serviceType) { gxsId = gxsMessageItem.getGxsId(); msgId = gxsMessageItem.getMsgId(); setTransactionId(transactionId); setServiceType(serviceType.getType()); // XXX: sign the message. add the signature stuff to GxsMessageItem var messageBuf = Unpooled.buffer(); var itemHeader = new ItemHeader(messageBuf, getServiceType(), gxsMessageItem.getSubType()); itemHeader.writeHeader(); var messageSize = gxsMessageItem.writeDataObject(messageBuf, EnumSet.noneOf(SerializationFlags.class)); itemHeader.writeSize(messageSize); var metaBuf = Unpooled.buffer(); gxsMessageItem.writeMetaObject(metaBuf, EnumSet.noneOf(SerializationFlags.class)); message = getArray(messageBuf); meta = getArray(metaBuf); messageBuf.release(); metaBuf.release(); } public GxsMessageItem toGxsMessageItem(GxsMessageItem gxsMessageItem) { var buf = Unpooled.copiedBuffer(meta, message); gxsMessageItem.readMetaObject(buf); ItemHeader.readHeader(buf, getServiceType(), gxsMessageItem.getSubType()); gxsMessageItem.readDataObject(buf); buf.release(); return gxsMessageItem; } public int getMessageType() { var buf = Unpooled.wrappedBuffer(message); var subType = ItemHeader.getSubType(buf); buf.release(); return subType; } @Override public int getSubType() { return 32; } public byte getPosition() { return position; } public GxsId getGxsId() { return gxsId; } public MsgId getMsgId() { return msgId; } public byte[] getMessage() { return message; } public byte[] getMeta() { return meta; } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = Serializer.serialize(buf, getTransactionId()); size += Serializer.serialize(buf, position); size += Serializer.serialize(buf, msgId, MsgId.class); size += Serializer.serialize(buf, gxsId, GxsId.class); size += Serializer.serializeTlvBinary(buf, getServiceType(), message); size += Serializer.serializeTlvBinary(buf, getServiceType(), meta); return size; } @Override public void readObject(ByteBuf buf) { setTransactionId(Serializer.deserializeInt(buf)); position = Serializer.deserializeByte(buf); msgId = (MsgId) Serializer.deserializeIdentifier(buf, MsgId.class); gxsId = (GxsId) Serializer.deserializeIdentifier(buf, GxsId.class); message = Serializer.deserializeTlvBinary(buf, getServiceType()); meta = Serializer.deserializeTlvBinary(buf, getServiceType()); } @Override public GxsTransferMessageItem clone() { return (GxsTransferMessageItem) super.clone(); } @Override public String toString() { return "GxsTransferMessageItem{" + "position=" + position + ", gxsId=" + gxsId + ", msgId=" + msgId + '}'; } private static byte[] getArray(ByteBuf buf) { var out = new byte[buf.writerIndex()]; buf.readBytes(out); return out; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/RequestType.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; public enum RequestType { NONE, // Unused REQUEST, RESPONSE } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxs/item/TransactionFlags.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import java.util.EnumSet; import java.util.Set; public enum TransactionFlags { // States START, // FLAG_BEGIN_P1 START_ACKNOWLEDGE, // FLAG_BEGIN_P2 END_SUCCESS, // FLAG_END_SUCCESS CANCEL, // FLAG_CANCEL (not used it seems) END_FAIL_NUM, // FLAG_END_FAIL_NUM (not used it seems) END_FAIL_TIMEOUT, // FLAG_END_FAIL_TIMEOUT (not used it seems) END_FAIL_FULL, // FLAG_END_FAIL_FULL (not used it seems) UNUSED, // Types TYPE_GROUP_LIST_RESPONSE, // FLAG_TYPE_GRP_LIST_RESP TYPE_MESSAGE_LIST_RESPONSE, // FLAG_TYPE_MSG_LIST_RESP TYPE_GROUP_LIST_REQUEST, // FLAG_TYPE_GRP_LIST_REQ TYPE_MESSAGE_LIST_REQUEST, // FLAG_TYPE_MSG_LIST_REQ TYPE_GROUPS, // FLAG_TYPE_GRPS TYPE_MESSAGES, // FLAG_TYPE_MESSAGES TYPE_ENCRYPTED_DATA; // FLAG_TYPE_ENCRYPTED_DATA (not used it seems) public static Set ofStates() { return EnumSet.of( START, START_ACKNOWLEDGE, END_SUCCESS, CANCEL, END_FAIL_NUM, END_FAIL_TIMEOUT, END_FAIL_FULL ); } public static Set ofTypes() { return EnumSet.of( TYPE_GROUP_LIST_RESPONSE, TYPE_MESSAGE_LIST_RESPONSE, TYPE_GROUP_LIST_REQUEST, TYPE_MESSAGE_LIST_REQUEST, TYPE_GROUPS, TYPE_MESSAGES, TYPE_ENCRYPTED_DATA); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/DestinationHash.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; import io.xeres.common.id.GxsId; import io.xeres.common.id.Sha1Sum; import io.xeres.common.util.SecureRandomUtils; final class DestinationHash { private DestinationHash() { throw new UnsupportedOperationException("Utility class"); } public static Sha1Sum createRandomHash(GxsId to) { var buf = new byte[Sha1Sum.LENGTH]; SecureRandomUtils.nextBytes(buf); System.arraycopy(to.getBytes(), 0, buf, 4, GxsId.LENGTH); return new Sha1Sum(buf); } public static GxsId getGxsIdFromHash(Sha1Sum hash) { var buf = new byte[GxsId.LENGTH]; System.arraycopy(hash.getBytes(), 4, buf, 0, GxsId.LENGTH); return new GxsId(buf); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/GxsTunnelRsClient.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.service.RsServiceSlave; import io.xeres.common.id.GxsId; public interface GxsTunnelRsClient extends RsServiceSlave { /** * Called to initialize the gxs tunnel client. * * @param gxsTunnelRsService the {@link GxsTunnelRsService}. Is used to call service methods. * @return the service number */ int onGxsTunnelInitialization(GxsTunnelRsService gxsTunnelRsService); /** * Called when data is received from the tunnel. * * @param tunnelId the tunnel id * @param data the data */ void onGxsTunnelDataReceived(Location tunnelId, byte[] data); /** * Called when a remote is requesting to establish a tunnel. * * @param sender the sender of the request * @param tunnelId the tunnel id * @param clientSide true if it's a client tunnel, false means it's a server tunnel * @return true if the tunnel is accepted */ boolean onGxsTunnelDataAuthorization(GxsId sender, Location tunnelId, boolean clientSide); /** * Called when the tunnel status changes. * * @param tunnelId the tunnel id * @param destination the destination of the tunnel * @param status the new status */ void onGxsTunnelStatusChanged(Location tunnelId, GxsId destination, GxsTunnelStatus status); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/GxsTunnelRsService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; import io.xeres.app.crypto.aes.AES; import io.xeres.app.crypto.dh.DiffieHellman; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.crypto.hmac.sha1.Sha1HMac; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.service.IdentityService; import io.xeres.app.xrs.common.SecurityKey; import io.xeres.app.xrs.common.Signature; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemUtils; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceMaster; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.gxstunnel.item.*; import io.xeres.app.xrs.service.turtle.TurtleRouter; import io.xeres.app.xrs.service.turtle.TurtleRsClient; import io.xeres.app.xrs.service.turtle.item.*; import io.xeres.common.id.GxsId; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.ExecutorUtils; import io.xeres.common.util.SecureRandomUtils; import org.bouncycastle.util.BigIntegers; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import javax.crypto.interfaces.DHPublicKey; import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.nio.ByteBuffer; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; import static io.xeres.app.xrs.common.SecurityKey.Flags.DISTRIBUTION_ADMIN; import static io.xeres.app.xrs.common.SecurityKey.Flags.TYPE_PUBLIC_ONLY; import static io.xeres.app.xrs.service.gxstunnel.GxsTunnelStatus.*; import static io.xeres.app.xrs.service.gxstunnel.TunnelDhInfo.Status.HALF_KEY_DONE; import static io.xeres.app.xrs.service.gxstunnel.TunnelDhInfo.Status.UNINITIALIZED; import static io.xeres.common.protocol.xrs.RsServiceType.GXS_TUNNELS; import static io.xeres.common.protocol.xrs.RsServiceType.TURTLE_ROUTER; /** * Generic tunnel service. *

* Services wanting to use it just need to implement {@link GxsTunnelRsClient}. * They can then request a tunnel from their identity to another identity and get a * handle (tunnel id). They can send data using that tunnel id. Several services can * use the same tunnel if the destination is the same. A service id is used to differentiate * between services. */ @Component public class GxsTunnelRsService extends RsService implements RsServiceMaster, TurtleRsClient { private static final Logger log = LoggerFactory.getLogger(GxsTunnelRsService.class); private static final Duration TUNNEL_DELAY_BETWEEN_RESEND = Duration.ofSeconds(10); private static final Duration TUNNEL_KEEP_ALIVE_TIMEOUT = Duration.ofSeconds(6); private static final Duration TUNNEL_MANAGEMENT_DELAY = Duration.ofSeconds(2); private static final Duration TUNNEL_MESSAGES_DUPLICATE_DELAY = Duration.ofMinutes(10); private final AtomicLong counter = new AtomicLong(); private final Map clients = new HashMap<>(); private final RsServiceRegistry rsServiceRegistry; private final DatabaseSessionManager databaseSessionManager; private final IdentityService identityService; private ScheduledExecutorService executorService; /** * Current peers we can talk to. Key is a tunnel id. */ private final Map contacts = new ConcurrentHashMap<>(); /** * Current virtual peers. Key is a turtle virtual peer. */ private final Map dhPeers = new ConcurrentHashMap<>(); private final ReentrantLock tunnelDataItemLock = new ReentrantLock(); private final PriorityQueue tunnelDataItems = new PriorityQueue<>(); private GxsId ownGxsId; private TurtleRouter turtleRouter; public GxsTunnelRsService(RsServiceRegistry rsServiceRegistry, DatabaseSessionManager databaseSessionManager, IdentityService identityService) { super(rsServiceRegistry); this.rsServiceRegistry = rsServiceRegistry; this.databaseSessionManager = databaseSessionManager; this.identityService = identityService; } @Override public void initialize() { try (var ignored = new DatabaseSession(databaseSessionManager)) { ownGxsId = identityService.getOwnIdentity().getGxsId(); } executorService = ExecutorUtils.createFixedRateExecutor(this::manageTunnels, getInitPriority().getMaxTime() + TUNNEL_DELAY_BETWEEN_RESEND.toSeconds(), TUNNEL_MANAGEMENT_DELAY.toSeconds()); } @Override public void cleanup() { ExecutorUtils.cleanupExecutor(executorService); } private void manageTunnels() { var now = Instant.now(); manageResending(now); manageDiggingAndCleanup(now); } private void manageResending(Instant now) { tunnelDataItemLock.lock(); try { var item = tunnelDataItems.peek(); if (item == null || Duration.between(item.getLastSendingAttempt(), now).compareTo(TUNNEL_DELAY_BETWEEN_RESEND) < 0) { return; } tunnelDataItems.poll(); // We need to remove it so that the priority is changed log.debug("Resending tunnel data item for tunnel {}", item.getLocation()); sendEncryptedTunnelData(item.getLocation(), item); item.updateLastSendingAttempt(); tunnelDataItems.offer(item); // Insert back with the proper priority } finally { tunnelDataItemLock.unlock(); } // XXX: there should be a way to remove them?! I think it's only when the tunnel is removed, but I can't see where it's done in RS } private void manageDiggingAndCleanup(Instant now) { var it = contacts.entrySet().iterator(); while (it.hasNext()) { var tunnelPeerInfoEntry = it.next(); // Remove tunnels that were remotely closed as we // cannot use them anymore. if (tunnelPeerInfoEntry.getValue().getStatus() == REMOTELY_CLOSED && tunnelPeerInfoEntry.getValue().getLastContact().plusSeconds(20).isBefore(now)) { log.debug("Removing tunnel {}", tunnelPeerInfoEntry.getKey()); it.remove(); continue; } // Re-digg tunnels that have died out of inaction if (tunnelPeerInfoEntry.getValue().getStatus() == CAN_TALK && tunnelPeerInfoEntry.getValue().getLastContact().plusSeconds(20).plus(TUNNEL_KEEP_ALIVE_TIMEOUT).isBefore(now)) { log.debug("Connection interrupted with tunnelPeerInfo"); tunnelPeerInfoEntry.getValue().setStatus(TUNNEL_DOWN); notifyClients(tunnelPeerInfoEntry.getKey(), tunnelPeerInfoEntry.getValue().getStatus()); // XXX: OK?! tunnelPeerInfoEntry.getValue().clearLocation(); // Reset the turtle router monitoring. Avoids having to wait 60 seconds for the tunnel to die. if (tunnelPeerInfoEntry.getValue().getDirection() == TunnelDirection.SERVER) { log.debug("Forcing new tunnel"); turtleRouter.forceReDiggTunnel(DestinationHash.createRandomHash(tunnelPeerInfoEntry.getValue().getDestinationGxsId())); } } // Send keep alive to active tunnels. if (tunnelPeerInfoEntry.getValue().getStatus() == CAN_TALK && tunnelPeerInfoEntry.getValue().getLastKeepAliveSent().plus(TUNNEL_KEEP_ALIVE_TIMEOUT).isBefore(now)) { log.debug("Sending keep alive to tunnel {}", tunnelPeerInfoEntry.getKey()); sendEncryptedTunnelData(tunnelPeerInfoEntry.getKey(), new GxsTunnelStatusItem(GxsTunnelStatusItem.Status.KEEP_ALIVE)); tunnelPeerInfoEntry.getValue().updateLastKeepAlive(); } // Clean old received messages tunnelPeerInfoEntry.getValue().cleanupReceivedMessagesOlderThan(TUNNEL_MESSAGES_DUPLICATE_DELAY); } } private void sendTunnelDataItem(Location destination, GxsTunnelDataItem item) { tunnelDataItemLock.lock(); try { log.debug("Sending tunnel data item {} to tunnel {}", item, destination); sendEncryptedTunnelData(destination, item); item.setForResending(destination); tunnelDataItems.offer(item); } finally { tunnelDataItemLock.unlock(); } } @Override public RsServiceType getServiceType() { return GXS_TUNNELS; } @Override public void addRsSlave(GxsTunnelRsClient client) { var serviceId = client.onGxsTunnelInitialization(this); clients.put(serviceId, client); } @Override public void handleItem(PeerConnection sender, Item item) { // Nothing to handle } @Override public void initializeTurtle(TurtleRouter turtleRouter) { this.turtleRouter = turtleRouter; } @Override public boolean handleTunnelRequest(PeerConnection sender, Sha1Sum hash) { // Only answer request that are for us. var destination = DestinationHash.getGxsIdFromHash(hash); var isForUs = ownGxsId.equals(destination); if (isForUs) { log.trace("Tunnel request from {} is for us", sender); } return isForUs; } @Override public void receiveTurtleData(TurtleGenericTunnelItem item, Sha1Sum hash, Location virtualLocation, TunnelDirection tunnelDirection) { log.debug("Received tunnel data item {} from {} (direction: {})", item, virtualLocation, tunnelDirection); switch (item) { case TurtleGenericDataItem turtleGenericDataItem -> { var buf = ByteBuffer.wrap(turtleGenericDataItem.getTunnelData()); // The packet's first 8 bytes contain the IV if (buf.remaining() < 8) { log.error("Gxs tunnel data contains less than 8 bytes, dropping"); return; } if (hasNoIv(buf)) { // Skip IV placeholder buf.position(8); buf.compact(); buf.position(0); var deserializedItem = ItemUtils.deserializeItem(buf.array(), rsServiceRegistry); if (deserializedItem instanceof GxsTunnelDhPublicKeyItem gxsTunnelDhPublicKeyItem) { handleRecvDhPublicKeyItem(virtualLocation, gxsTunnelDhPublicKeyItem); } else { log.warn("Unknown deserialized item: {}", deserializedItem); } } else { // Encrypted data handleEncryptedData(hash, virtualLocation, buf); } } case null -> throw new IllegalStateException("Null item"); default -> log.warn("Unknown packet subtype received from turtle data: {}", item.getSubType()); } } private boolean hasNoIv(ByteBuffer buf) { buf.mark(); var result = buf.getLong(0) == 0L; buf.reset(); return result; } private void handleRecvDhPublicKeyItem(Location virtualLocation, GxsTunnelDhPublicKeyItem item) { log.debug("Received DH public key from {}", virtualLocation); var tunnelDhInfo = dhPeers.get(virtualLocation); if (tunnelDhInfo == null) { log.error("DH: Cannot find tunnelDhInfo for {}", virtualLocation); return; } PublicKey signerPublicKey; try (var ignored = new DatabaseSession(databaseSessionManager)) { signerPublicKey = identityService.findByGxsId(item.getSignature().getGxsId()) .map(GxsGroupItem::getAdminPublicKey) .orElse(getPublicKeySecurely(item.getSignerPublicKey())); } if (signerPublicKey == null) { log.error("DH: Cannot find/process signer public key for {}", tunnelDhInfo); return; } if (!item.getSignerPublicKey().getKeyGxsId().equals(item.getSignature().getGxsId())) { log.error("DH: Signature does not match public key for {}", tunnelDhInfo); return; } if (!RSA.verify(signerPublicKey, item.getSignature().getData(), BigIntegers.asUnsignedByteArray(item.getPublicKey()))) { log.error("DH: Signature verification failed for {}", tunnelDhInfo); return; } if (tunnelDhInfo.getKeyPair() == null) { log.error("DH: No information on tunnelDhInfo {}", tunnelDhInfo); return; } if (tunnelDhInfo.getStatus() == TunnelDhInfo.Status.KEY_AVAILABLE) { log.debug("Key already available for {}, restarting DH session...", tunnelDhInfo); restartDhSession(virtualLocation); } var tunnelId = VirtualLocation.fromGxsIds(ownGxsId, item.getSignerPublicKey().getKeyGxsId()); tunnelDhInfo.setTunnelId(tunnelId); var publicKey = DiffieHellman.getPublicKey(item.getPublicKey()); byte[] commonSecret; try { commonSecret = DiffieHellman.generateCommonSecretKey(tunnelDhInfo.getKeyPair().getPrivate(), publicKey); } catch (IllegalArgumentException _) { log.error("DH: Cannot generate common secret key for {}", tunnelDhInfo); return; } tunnelDhInfo.setStatus(TunnelDhInfo.Status.KEY_AVAILABLE); var tunnelPeerInfo = contacts.computeIfAbsent(tunnelId, _ -> new TunnelPeerInfo()); tunnelPeerInfo.activate(generateAesKey(commonSecret), virtualLocation, tunnelDhInfo.getDirection(), item.getSignature().getGxsId()); log.debug("Sending distant connection ack for tunnel {}", tunnelId); sendEncryptedTunnelData(tunnelId, new GxsTunnelStatusItem(GxsTunnelStatusItem.Status.ACK_DISTANT_CONNECTION)); } private static PublicKey getPublicKeySecurely(SecurityKey securityKey) { if (!securityKey.getFlags().contains(TYPE_PUBLIC_ONLY)) { log.warn("Public key misses public flag"); return null; } try { RSA.getPrivateKey(securityKey.getData()); log.warn("Public key is in fact a private key, rejecting."); return null; } catch (NoSuchAlgorithmException | InvalidKeySpecException _) { // All good } PublicKey publicKey; try { publicKey = RSA.getPublicKeyFromPkcs1(securityKey.getData()); } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) { log.warn("Couldn't decode public key: {}", e.getMessage()); return null; } var gxsId = RSA.getGxsId(publicKey); if (!securityKey.getKeyGxsId().equals(gxsId)) { // RS used to generate those keys. They're still accepted, but they // will be removed one day. if (!securityKey.getKeyGxsId().equals(RSA.getGxsIdInsecure(publicKey))) { log.warn("Old style key has wrong fingerprint, rejecting."); return null; } log.warn("Using old style key. The peer should generate a new identity though."); } return publicKey; } private byte[] generateAesKey(byte[] commonSecret) { var aesKey = new byte[16]; var digest = new Sha1MessageDigest(); digest.update(commonSecret); System.arraycopy(digest.getBytes(), 0, aesKey, 0, 16); return aesKey; } private void handleEncryptedData(Sha1Sum hash, Location virtualLocation, ByteBuffer buf) { if (buf.remaining() < 8 + Sha1Sum.LENGTH) { log.error("Encrypted data for hash {}, virtual location {} is too short", hash, virtualLocation); return; } var tunnelDhInfo = dhPeers.get(virtualLocation); if (tunnelDhInfo == null) { log.error("Incoming item not coming out of a registered tunnel for hash {}, virtual location {}. This is unexpected.", hash, virtualLocation); return; } if (tunnelDhInfo.getTunnelId() == null) { log.error("No tunnel id for tunnelDhInfo for virtual location {}, this shouldn't happen", virtualLocation); return; } var tunnelPeerInfo = contacts.get(tunnelDhInfo.getTunnelId()); if (tunnelPeerInfo == null) { log.error("Cannot find tunnel tunnelDhInfo {}, virtual location {}", tunnelDhInfo, virtualLocation); return; } var iv = new byte[8]; buf.get(iv); var hmac = new byte[Sha1Sum.LENGTH]; buf.get(hmac); var encryptedItem = new byte[buf.remaining()]; buf.get(encryptedItem); var hmacCheck = new Sha1HMac(new SecretKeySpec(tunnelPeerInfo.getAesKey(), "AES")); hmacCheck.update(encryptedItem); if (!Arrays.equals(hmac, hmacCheck.getBytes())) { log.error("HMAC check failed for tunnelDhInfo {}, virtual location {}. Resetting DH session.", tunnelDhInfo, virtualLocation); restartDhSession(virtualLocation); return; } byte[] decryptedItem; try { decryptedItem = AES.decrypt(tunnelPeerInfo.getAesKey(), iv, encryptedItem); } catch (IllegalArgumentException e) { log.error("Decryption failed for tunnelDhInfo {}, virtual location {}. : {}. Resetting DH session.", tunnelDhInfo, virtualLocation, e.getMessage()); restartDhSession(virtualLocation); return; } tunnelPeerInfo.setStatus(CAN_TALK); tunnelPeerInfo.updateLastContact(); var item = ItemUtils.deserializeItem(decryptedItem, rsServiceRegistry); if (item.getServiceType() == RsServiceType.NONE.getType()) { log.error("Deserialization failed for tunnelDhInfo {}, virtual location {}", tunnelDhInfo, virtualLocation); return; } tunnelPeerInfo.addReceivedSize(decryptedItem.length); handleIncomingItem(tunnelDhInfo.getTunnelId(), item); } private void handleIncomingItem(Location tunnelId, Item item) { switch (item) { case GxsTunnelDataItem gxsTunnelDataItem -> handleTunnelDataItem(tunnelId, gxsTunnelDataItem); case GxsTunnelDataAckItem gxsTunnelDataAckItem -> handleTunnelDataItemAck(gxsTunnelDataAckItem); case GxsTunnelStatusItem gxsTunnelStatusItem -> handleTunnelStatusItem(tunnelId, gxsTunnelStatusItem); default -> log.warn("Unknown packet subtype received from encrypted data: {}", item.getSubType()); } } private void handleTunnelDataItem(Location tunnelId, GxsTunnelDataItem item) { // Acknowledge reception var ackItem = new GxsTunnelDataAckItem(item.getCounter()); log.debug("Sending ack for tunnel {}", tunnelId); sendEncryptedTunnelData(tunnelId, ackItem); var client = clients.get(item.getServiceId()); if (client == null) { log.warn("No registered service with ID {}, rejecting item", item.getServiceId()); return; } var isClientSide = false; var tunnelPeerInfo = contacts.get(tunnelId); if (tunnelPeerInfo == null) { log.error("No contact found for {}", item.getServiceId()); return; } tunnelPeerInfo.addService(item.getServiceId()); isClientSide = tunnelPeerInfo.getDirection() == TunnelDirection.SERVER; // We check if we already received. if (tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(item.getCounter())) { log.warn("Tunnel peer already received a message for {}", item.getServiceId()); return; } if (client.onGxsTunnelDataAuthorization(tunnelPeerInfo.getDestinationGxsId(), tunnelId, isClientSide)) { client.onGxsTunnelDataReceived(tunnelId, item.getTunnelData()); } } private void handleTunnelDataItemAck(GxsTunnelDataAckItem item) { tunnelDataItemLock.lock(); try { tunnelDataItems.removeIf(gxsTunnelDataItem -> gxsTunnelDataItem.getCounter() == item.getCounter()); } finally { tunnelDataItemLock.unlock(); } } private void handleTunnelStatusItem(Location tunnelId, GxsTunnelStatusItem item) { switch (item.getStatus()) { case CLOSING_DISTANT_CONNECTION -> { var tunnelPeerInfo = contacts.get(tunnelId); if (tunnelPeerInfo == null) { log.error("Cannot mark tunnel connection as closed. No connected opened for tunnel id {}", tunnelId); return; } if (tunnelPeerInfo.getDirection() == TunnelDirection.CLIENT) { tunnelPeerInfo.setStatus(REMOTELY_CLOSED); } else { tunnelPeerInfo.setStatus(TUNNEL_DOWN); } log.debug("Remote tunnel for tunnel id {} closed", tunnelId); notifyClients(tunnelId, REMOTELY_CLOSED); } case KEEP_ALIVE -> log.debug("Received keep alive for tunnel {}", tunnelId); // Nothing to do, decryption method updated the activity for the tunnel case ACK_DISTANT_CONNECTION -> notifyClients(tunnelId, CAN_TALK); default -> log.warn("Unknown status received: {}", item.getStatus()); } } private void notifyClients(Location tunnelId, GxsTunnelStatus status) { var tunnelPeerInfo = contacts.get(tunnelId); if (tunnelPeerInfo == null) { return; } tunnelPeerInfo.getClientServices().forEach(serviceId -> { var client = clients.get(serviceId); if (client != null) { client.onGxsTunnelStatusChanged(tunnelId, tunnelPeerInfo.getDestinationGxsId(), status); } }); } @Override public List receiveSearchRequest(byte[] query, int maxHits) { return List.of(); } @Override public void receiveSearchRequestString(PeerConnection sender, String keywords) { } @Override public void receiveSearchResult(int requestId, TurtleSearchResultItem item) { } @Override public void addVirtualPeer(Sha1Sum hash, Location virtualLocation, TunnelDirection direction) { log.debug("Received new virtual peer {} for hash {}, direction {}", virtualLocation, hash, direction); var tunnelDhInfo = dhPeers.computeIfAbsent(virtualLocation, _ -> new TunnelDhInfo()); tunnelDhInfo.clear(); tunnelDhInfo.setDirection(direction); tunnelDhInfo.setHash(hash); tunnelDhInfo.setStatus(UNINITIALIZED); if (direction == TunnelDirection.SERVER) { var found = contacts.values().stream() .filter(tunnelPeerInfo -> tunnelPeerInfo.getHash().equals(hash)) .findAny(); if (found.isEmpty()) { log.error("No pre-registered peer for hash {} on client side", hash); return; } if (found.get().getStatus() == CAN_TALK) { log.error("Session already opened and alive"); return; } } log.debug("Adding virtual peer {} for hash {}", virtualLocation, hash); restartDhSession(virtualLocation); } @Override public void removeVirtualPeer(Sha1Sum hash, Location virtualLocation) { var tunnelDhInfo = dhPeers.remove(virtualLocation); if (tunnelDhInfo == null) { log.error("Cannot remove virtual peer {} because it's not found", virtualLocation); return; } var tunnelPeerInfo = contacts.get(tunnelDhInfo.getTunnelId()); if (tunnelPeerInfo == null) { log.error("Cannot find tunnel id {} in contact list", tunnelDhInfo.getTunnelId()); return; } // Notify all clients that this tunnel is down. if (tunnelDhInfo.getTunnelId().equals(virtualLocation)) { tunnelPeerInfo.setStatus(TUNNEL_DOWN); tunnelPeerInfo.clearLocation(); tunnelPeerInfo.getClientServices().forEach(serviceId -> { var client = clients.get(serviceId); if (client != null) { client.onGxsTunnelStatusChanged(tunnelDhInfo.getTunnelId(), tunnelPeerInfo.getDestinationGxsId(), TUNNEL_DOWN); } }); } } private void restartDhSession(Location virtualLocation) { var tunnelDhInfo = dhPeers.computeIfAbsent(virtualLocation, _ -> new TunnelDhInfo()); tunnelDhInfo.setStatus(UNINITIALIZED); tunnelDhInfo.setKeyPair(DiffieHellman.generateKeys()); tunnelDhInfo.setStatus(HALF_KEY_DONE); sendDhPublicKey(virtualLocation, tunnelDhInfo.getKeyPair()); } private void sendDhPublicKey(Location virtualLocation, KeyPair keyPair) { assert keyPair != null; log.debug("Sending DH public key to {}", virtualLocation); // Sign the public key try (var ignored = new DatabaseSession(databaseSessionManager)) { var ownIdentity = identityService.getOwnIdentity(); var signerSecurityKey = new SecurityKey(ownIdentity.getGxsId(), EnumSet.of(DISTRIBUTION_ADMIN, TYPE_PUBLIC_ONLY), ownIdentity.getPublished(), null, RSA.getPublicKeyAsPkcs1(ownIdentity.getAdminPublicKey())); var publicKeyNum = ((DHPublicKey) keyPair.getPublic()).getY(); var signature = new Signature(ownIdentity.getGxsId(), RSA.sign(ownIdentity.getAdminPrivateKey(), BigIntegers.asUnsignedByteArray(publicKeyNum))); var item = new GxsTunnelDhPublicKeyItem(publicKeyNum, signature, signerSecurityKey); var serializedItem = ItemUtils.serializeItem(item, this); // The preceding IV is made of zeroes as this is the only clear item that is sent. var data = new byte[serializedItem.length + 8]; System.arraycopy(serializedItem, 0, data, 8, serializedItem.length); turtleRouter.sendTurtleData(virtualLocation, new TurtleGenericFastDataItem(data)); } catch (IOException e) { throw new IllegalArgumentException("Cannot read public key from database: " + e.getMessage(), e); } } private void sendEncryptedTunnelData(Location destination, GxsTunnelItem item) { var serializedItem = ItemUtils.serializeItem(item, this); var tunnelPeerInfo = contacts.get(destination); if (tunnelPeerInfo == null) { log.error("Cannot find tunnelPeerInfo for {} when trying to send encrypted data", destination); return; } if (tunnelPeerInfo.getStatus() != CAN_TALK) { log.error("Cannot talk to tunnel id {}, status is {}", destination, tunnelPeerInfo.getStatus()); return; } tunnelPeerInfo.addSentSize(serializedItem.length); var iv = new byte[8]; SecureRandomUtils.nextBytes(iv); var key = tunnelPeerInfo.getAesKey(); var encryptedItem = AES.encrypt(key, iv, serializedItem); var turtleItem = new TurtleGenericFastDataItem(createTurtleData(key, iv, encryptedItem)); turtleRouter.sendTurtleData(tunnelPeerInfo.getLocation(), turtleItem); } /** * Asks for a tunnel. The service will request it to the turtle router, and exchange an AES key using DH. * When the tunnel is established, a {@link GxsTunnelRsClient#onGxsTunnelStatusChanged(Location, GxsId, GxsTunnelStatus)} method will be received. * Data can then be sent and received in the tunnel. A same tunnel can be used by several clients, hence they're differentiated * by the serviceId parameter. * * @param from the originating identity * @param to the destination identity * @param serviceId the service id * @return a tunnel id or null if it already exists */ public Location requestSecuredTunnel(GxsId from, GxsId to, int serviceId) { var hash = DestinationHash.createRandomHash(to); var tunnelId = VirtualLocation.fromGxsIds(from, to); log.debug("Requesting secured tunnel for gxs id {}, resulting tunnel id: {}", to, tunnelId); if (contacts.putIfAbsent(tunnelId, new TunnelPeerInfo(hash, to, serviceId)) != null) { log.error("Tunnel {} already exists", tunnelId); return null; } turtleRouter.startMonitoringTunnels(hash, this, false); return tunnelId; } /** * Gets the destination GxS identity from a tunnel. * * @param tunnelId the tunnel id * @return the identity */ public GxsId getGxsFromTunnel(Location tunnelId) { var tunnelPeerInfo = contacts.get(tunnelId); if (tunnelPeerInfo == null) { return null; } return tunnelPeerInfo.getDestinationGxsId(); } /** * Sends data through the tunnel. If a tunnel is present, retries are performed automatically until the reception is acknowledged by the other end. * * @param tunnelId the tunnel id * @param serviceId the service id * @param data the data * @return true if successful, false if the tunnel doesn't exist */ public boolean sendData(Location tunnelId, int serviceId, byte[] data) { var tunnelPeerInfo = contacts.get(tunnelId); if (tunnelPeerInfo == null) { log.error("No tunnel peer info found for {}", tunnelId); return false; } var client = clients.get(serviceId); if (client == null) { log.error("Cannot find client for {}", serviceId); return false; } sendTunnelDataItem(tunnelId, new GxsTunnelDataItem(getUniquePacketCounter(), serviceId, data)); return true; } /** * Closes and established tunnel. All further data will be refused but the tunnel will be kept alive for a little * while until all pending data is delivered. Clients will receive a {@link GxsTunnelRsClient#onGxsTunnelStatusChanged(Location, GxsId, GxsTunnelStatus)} method * once the tunnel gets closed. * * @param tunnelId the tunnel id * @param serviceId the service id */ public void closeExistingTunnel(Location tunnelId, int serviceId) { var tunnelPeerInfo = contacts.get(tunnelId); if (tunnelPeerInfo == null) { log.error("Cannot close distant tunnel connection. No connection opened for tunnel id {}", tunnelId); return; } if (tunnelPeerInfo.getLocation() == null) { log.warn("Connection already closed for tunnel id {}", tunnelId); return; } Sha1Sum hash; var tunnelDhInfo = dhPeers.get(tunnelPeerInfo.getLocation()); if (tunnelDhInfo != null) { hash = tunnelDhInfo.getHash(); } else { hash = tunnelPeerInfo.getHash(); } if (!tunnelPeerInfo.getClientServices().contains(serviceId)) { log.error("Tunnel {} is not associated with service {}", tunnelId, serviceId); return; } tunnelPeerInfo.removeService(serviceId); if (tunnelPeerInfo.getClientServices().isEmpty()) { // No clients, we can close the tunnel. log.debug("Sending close tunnel status to tunnel id {}", tunnelId); sendEncryptedTunnelData(tunnelId, new GxsTunnelStatusItem(GxsTunnelStatusItem.Status.CLOSING_DISTANT_CONNECTION)); if (tunnelPeerInfo.getDirection() == TunnelDirection.SERVER) { turtleRouter.stopMonitoringTunnels(hash); } contacts.remove(tunnelId); } } private long getUniquePacketCounter() { return counter.getAndIncrement(); } private byte[] createTurtleData(byte[] aesKey, byte[] iv, byte[] encryptedItem) { var turtleData = new byte[iv.length + Sha1Sum.LENGTH + encryptedItem.length]; System.arraycopy(iv, 0, turtleData, 0, iv.length); var hmac = new Sha1HMac(new SecretKeySpec(aesKey, "AES")); hmac.update(encryptedItem); System.arraycopy(hmac.getBytes(), 0, turtleData, iv.length, Sha1Sum.LENGTH); System.arraycopy(encryptedItem, 0, turtleData, iv.length + Sha1Sum.LENGTH, encryptedItem.length); return turtleData; } @Override public RsServiceType getMasterServiceType() { return TURTLE_ROUTER; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/GxsTunnelStatus.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; public enum GxsTunnelStatus { UNKNOWN, TUNNEL_DOWN, CAN_TALK, REMOTELY_CLOSED } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/TunnelDhInfo.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.service.turtle.item.TunnelDirection; import io.xeres.common.id.Sha1Sum; import java.security.KeyPair; /** * Used to keep track of a Diffie-Hellman session. */ class TunnelDhInfo { public enum Status { UNINITIALIZED, HALF_KEY_DONE, KEY_AVAILABLE } private Status status; private Sha1Sum hash; private TunnelDirection direction; private KeyPair keyPair; private Location tunnelId; public Status getStatus() { return status; } public void setStatus(Status status) { this.status = status; } public Sha1Sum getHash() { return hash; } public void setHash(Sha1Sum hash) { this.hash = hash; } public TunnelDirection getDirection() { return direction; } public void setDirection(TunnelDirection direction) { this.direction = direction; } public KeyPair getKeyPair() { return keyPair; } public void setKeyPair(KeyPair keyPair) { this.keyPair = keyPair; } public Location getTunnelId() { return tunnelId; } public void setTunnelId(Location tunnelId) { this.tunnelId = tunnelId; } public void clear() { status = Status.UNINITIALIZED; keyPair = null; tunnelId = null; } @Override public String toString() { return "TunnelDhInfo{" + "status=" + status + ", direction=" + direction + ", tunnelId=" + tunnelId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/TunnelPeerInfo.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.service.turtle.item.TunnelDirection; import io.xeres.common.id.GxsId; import io.xeres.common.id.Sha1Sum; import java.time.Duration; import java.time.Instant; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * A tunnel established with a peer. */ class TunnelPeerInfo { private Instant lastContact; private Instant lastKeepAliveSent; private byte[] aesKey; private Sha1Sum hash; /** * Tells if the tunnel is open or not. */ private GxsTunnelStatus status; /** * The virtual turtle peer. */ private Location location; /** * Identity we're talking to. */ private GxsId destinationGxsId; /** * If we are a client (managing the tunnel) or a server. */ private TunnelDirection direction; /** * Services using this tunnel. */ private final Set clientServices = new HashSet<>(); /** * Keeps last received messages, to avoid duplicates. */ private final Map receivedMessages = new ConcurrentHashMap<>(); private long totalSent; private long totalReceived; public TunnelPeerInfo(Sha1Sum hash, GxsId destinationGxsId, int serviceId) { var now = Instant.now(); lastContact = now; lastKeepAliveSent = now; status = GxsTunnelStatus.TUNNEL_DOWN; direction = TunnelDirection.SERVER; this.hash = hash; this.destinationGxsId = destinationGxsId; clientServices.add(serviceId); } public TunnelPeerInfo() { } public void activate(byte[] aesKey, Location location, TunnelDirection direction, GxsId destination) { var now = Instant.now(); lastContact = now; lastKeepAliveSent = now; status = GxsTunnelStatus.CAN_TALK; this.aesKey = aesKey; this.location = location; this.direction = direction; this.destinationGxsId = destination; } public Sha1Sum getHash() { return hash; } public GxsTunnelStatus getStatus() { return status; } public void setStatus(GxsTunnelStatus status) { this.status = status; } public void clearLocation() { location = null; } public Location getLocation() { return location; } public byte[] getAesKey() { return aesKey; } public TunnelDirection getDirection() { return direction; } public GxsId getDestinationGxsId() { return destinationGxsId; } public Set getClientServices() { return clientServices; } public Instant getLastContact() { return lastContact; } public Instant getLastKeepAliveSent() { return lastKeepAliveSent; } public void updateLastKeepAlive() { lastKeepAliveSent = Instant.now(); } public void addSentSize(int size) { totalSent += size; } public void addReceivedSize(int size) { totalReceived += size; } public void updateLastContact() { lastContact = Instant.now(); } public void addService(int serviceId) { clientServices.add(serviceId); } public void removeService(int serviceId) { clientServices.remove(serviceId); } public boolean checkIfMessageAlreadyReceivedAndRecord(long messageId) { return receivedMessages.putIfAbsent(messageId, Instant.now()) != null; } public void cleanupReceivedMessagesOlderThan(Duration delay) { var now = Instant.now(); receivedMessages.entrySet().removeIf(entry -> entry.getValue().plus(delay).isAfter(now)); } @Override public String toString() { return "TunnelPeerInfo{" + "status=" + status + ", location=" + location + ", destination=" + destinationGxsId + ", direction=" + direction + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/VirtualLocation.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; import java.nio.ByteBuffer; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; final class VirtualLocation { private VirtualLocation() { throw new UnsupportedOperationException("Utility class"); } public static Location fromGxsIds(GxsId ownId, GxsId distantId) { var buf = new byte[GxsId.LENGTH * 2]; // Sort the IDs, that way the same ID is generated on both sides. // This helps with debugging. if (ownId.compareTo(distantId) < 0) { System.arraycopy(ownId.getBytes(), 0, buf, 0, GxsId.LENGTH); System.arraycopy(distantId.getBytes(), 0, buf, GxsId.LENGTH, distantId.getLength()); } else { System.arraycopy(distantId.getBytes(), 0, buf, 0, GxsId.LENGTH); System.arraycopy(ownId.getBytes(), 0, buf, GxsId.LENGTH, ownId.getLength()); } var digest = new Sha1MessageDigest(); digest.update(buf); var wrap = ByteBuffer.wrap(digest.getBytes()); // Only get the first 16 bytes var out = new byte[LocationIdentifier.LENGTH]; wrap.get(out); return Location.createLocation("GxsTunnelVirtualLocation", new LocationIdentifier(out)); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelDataAckItem.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel.item; import io.xeres.app.xrs.serialization.RsSerialized; public class GxsTunnelDataAckItem extends GxsTunnelItem { @RsSerialized private long counter; @Override public int getSubType() { return 4; } public GxsTunnelDataAckItem() { // Needed } public GxsTunnelDataAckItem(long counter) { this.counter = counter; } public long getCounter() { return counter; } @Override public GxsTunnelDataAckItem clone() { return (GxsTunnelDataAckItem) super.clone(); } @Override public String toString() { return "GxsTunnelDataAckItem{" + "counter=" + counter + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelDataItem.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel.item; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.serialization.RsSerialized; import java.time.Instant; public class GxsTunnelDataItem extends GxsTunnelItem implements Comparable { @RsSerialized private long counter; @RsSerialized private int flags; // Not used @RsSerialized private int serviceId; @RsSerialized private byte[] tunnelData; // Used for resending private Instant lastSendingAttempt = Instant.EPOCH; private Location location; @SuppressWarnings("unused") public GxsTunnelDataItem() { } public GxsTunnelDataItem(long counter, int serviceId, byte[] tunnelData) { this.counter = counter; this.serviceId = serviceId; this.tunnelData = tunnelData; } @Override public int getSubType() { return 1; } public long getCounter() { return counter; } public int getServiceId() { return serviceId; } public byte[] getTunnelData() { return tunnelData; } public void updateLastSendingAttempt() { lastSendingAttempt = Instant.now(); } public Instant getLastSendingAttempt() { return lastSendingAttempt; } public void setForResending(Location location) { this.location = location; updateLastSendingAttempt(); } public Location getLocation() { return location; } @Override public int compareTo(GxsTunnelDataItem o) { return lastSendingAttempt.compareTo(o.lastSendingAttempt); } @Override public GxsTunnelDataItem clone() { return (GxsTunnelDataItem) super.clone(); } @Override public String toString() { return "GxsTunnelDataItem{" + "serviceId=" + serviceId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelDhPublicKeyItem.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel.item; import io.xeres.app.xrs.common.SecurityKey; import io.xeres.app.xrs.common.Signature; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.serialization.TlvType; import java.math.BigInteger; public class GxsTunnelDhPublicKeyItem extends GxsTunnelItem { @RsSerialized private BigInteger publicKey; @RsSerialized(tlvType = TlvType.SIGNATURE) private Signature signature; @RsSerialized(tlvType = TlvType.SECURITY_KEY) private SecurityKey signerPublicKey; @Override public int getSubType() { return 2; } @SuppressWarnings("unused") public GxsTunnelDhPublicKeyItem() { } public GxsTunnelDhPublicKeyItem(BigInteger publicKey, Signature signature, SecurityKey signerPublicKey) { this.publicKey = publicKey; this.signature = signature; this.signerPublicKey = signerPublicKey; } public BigInteger getPublicKey() { return publicKey; } public Signature getSignature() { return signature; } public SecurityKey getSignerPublicKey() { return signerPublicKey; } @Override public GxsTunnelDhPublicKeyItem clone() { return (GxsTunnelDhPublicKeyItem) super.clone(); } @Override public String toString() { return "GxsTunnelDhPublicKeyItem{" + "publicKey=" + publicKey + ", signature=" + signature + ", signerPublicKey=" + signerPublicKey + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.common.protocol.xrs.RsServiceType; public abstract class GxsTunnelItem extends Item { @Override public int getServiceType() { return RsServiceType.GXS_TUNNELS.getType(); } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); // Same as Chat service } @Override public GxsTunnelItem clone() { return (GxsTunnelItem) super.clone(); } @Override public String toString() { return "GxsTunnelItem{}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelStatusItem.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel.item; import io.xeres.app.xrs.serialization.RsSerialized; import java.util.EnumSet; import java.util.Set; public class GxsTunnelStatusItem extends GxsTunnelItem { public enum Status { UNUSED_1, UNUSED_2, UNUSED_3, UNUSED_4, UNUSED_5, UNUSED_6, UNUSED_7, UNUSED_8, UNUSED_9, UNUSED_10, CLOSING_DISTANT_CONNECTION, ACK_DISTANT_CONNECTION, KEEP_ALIVE } @RsSerialized private Set status; @SuppressWarnings("unused") public GxsTunnelStatusItem() { } public GxsTunnelStatusItem(Status status) { this.status = EnumSet.of(status); } @Override public int getSubType() { return 3; } public Status getStatus() { // XXX: we should add some warning when a status we don't know is wedged in there return status.stream().findFirst().orElse(Status.UNUSED_1); } @Override public GxsTunnelStatusItem clone() { return (GxsTunnelStatusItem) super.clone(); } @Override public String toString() { return "GxsTunnelStatusItem{" + "status=" + status + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/heartbeat/HeartbeatRsService.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.heartbeat; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.heartbeat.item.HeartbeatItem; import io.xeres.common.protocol.xrs.RsServiceType; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; import static io.xeres.common.protocol.xrs.RsServiceType.HEARTBEAT; @Component public class HeartbeatRsService extends RsService { private final PeerConnectionManager peerConnectionManager; public HeartbeatRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager) { super(rsServiceRegistry); this.peerConnectionManager = peerConnectionManager; } @Override public RsServiceType getServiceType() { return HEARTBEAT; } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.NORMAL; } @Override public void initialize(PeerConnection peerConnection) { peerConnection.scheduleAtFixedRate(() -> peerConnectionManager.writeItem(peerConnection, new HeartbeatItem(), this), 5, 5, TimeUnit.SECONDS); } @Override public void handleItem(PeerConnection sender, Item item) { // do nothing } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/heartbeat/item/HeartbeatItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.heartbeat.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.common.protocol.xrs.RsServiceType; public class HeartbeatItem extends Item { @Override public int getServiceType() { return RsServiceType.HEARTBEAT.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return ItemPriority.IMPORTANT.getPriority(); } @Override public HeartbeatItem clone() { return (HeartbeatItem) super.clone(); } @Override public String toString() { return "HeartbeatItem{}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/identity/IdentityManager.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.IdentityService; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.identity.Type; import io.xeres.common.util.ExecutorUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import java.time.Duration; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.stream.Collectors; /** * Manages GxsId requests, caching and storage in an intelligent way, like: *

    *
  • group requests to ask in batches
  • *
  • remembers which peer is likely to answer requests (basic routing)
  • *
  • caches recent GxsIds
  • *
*/ @Component public class IdentityManager { private static final Logger log = LoggerFactory.getLogger(IdentityManager.class); private final Map> pendingGxsIds = new HashMap<>(); private final Set friendGxsIds = new HashSet<>(); private static final Duration TIME_BETWEEN_REQUESTS = Duration.ofSeconds(5); private static final int MAXIMUM_IDS_PER_LOCATION = 5; private final IdentityRsService identityRsService; private final IdentityService identityService; private final PeerConnectionManager peerConnectionManager; private final ScheduledExecutorService executorService; // XXX: try to fix the circular dependency injection public IdentityManager(@Lazy IdentityRsService identityRsService, IdentityService identityService, PeerConnectionManager peerConnectionManager) { this.identityRsService = identityRsService; this.identityService = identityService; this.peerConnectionManager = peerConnectionManager; executorService = ExecutorUtils.createFixedRateExecutor(this::requestGxsIds, TIME_BETWEEN_REQUESTS.toSeconds()); } public void shutdown() { ExecutorUtils.cleanupExecutor(executorService); } /** * Gets a gxs group, if available. Otherwise, put a request to fetch it later * * @param peerConnection the peer to try to get the gxs group from * @param gxsId the gxs group id * @return the gxs group, or null if not found yet */ public IdentityGroupItem getGxsGroup(PeerConnection peerConnection, GxsId gxsId) { synchronized (pendingGxsIds) { return identityService.findByGxsId(gxsId).orElseGet(() -> { var gxsIds = pendingGxsIds.getOrDefault(peerConnection.getLocation().getId(), ConcurrentHashMap.newKeySet()); gxsIds.add(gxsId); pendingGxsIds.put(peerConnection.getLocation().getId(), gxsIds); return null; }); } } public IdentityGroupItem getGxsGroup(GxsId gxsId) { return identityService.findByGxsId(gxsId).orElse(null); } public void fetchGxsGroups(PeerConnection peerConnection, Set gxsIds) { synchronized (pendingGxsIds) { var existing = identityService.findAll(gxsIds).stream() .map(GxsGroupItem::getGxsId) .collect(Collectors.toSet()); var remaining = gxsIds.stream() .filter(gxsId -> !existing.contains(gxsId)) .collect(Collectors.toSet()); if (!remaining.isEmpty()) { var pendingMap = pendingGxsIds.getOrDefault(peerConnection.getLocation().getId(), ConcurrentHashMap.newKeySet()); pendingMap.addAll(gxsIds); pendingGxsIds.put(peerConnection.getLocation().getId(), pendingMap); } } } public void setAsFriend(Set gxsIds) { synchronized (pendingGxsIds) { var remaining = setExistingAsFriend(gxsIds); friendGxsIds.addAll(remaining); } } void requestGxsIds() { synchronized (pendingGxsIds) { pendingGxsIds.forEach((locationId, gxsIds) -> { var gxsIdsToGet = gxsIds.stream().limit(MAXIMUM_IDS_PER_LOCATION).toList(); var peerConnection = peerConnectionManager.getPeerByLocation(locationId); if (peerConnection != null) { identityRsService.requestGxsGroups(peerConnection, gxsIdsToGet); gxsIdsToGet.forEach(gxsIds::remove); // XXX: if the peer is not there anymore, we should try to get it from other peers... } }); // Remove all entries with empty sets pendingGxsIds.entrySet().removeIf(entry -> entry.getValue().isEmpty()); // Set peer identities as friends friendGxsIds.clear(); friendGxsIds.addAll(setExistingAsFriend(friendGxsIds)); } } private Set setExistingAsFriend(Set gxsIds) { var existing = identityService.findAll(gxsIds); var convertible = existing.stream() .filter(identityGroupItem -> identityGroupItem.getType() == Type.OTHER) .collect(Collectors.toSet()); convertible.forEach(identityGroupItem -> { identityGroupItem.setType(Type.FRIEND); identityRsService.saveIdentity(identityGroupItem); }); var existingGxsIds = existing.stream() .map(GxsGroupItem::getGxsId) .collect(Collectors.toSet()); return gxsIds.stream() .filter(gxsId -> !existingGxsIds.contains(gxsId)) .collect(Collectors.toSet()); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/identity/IdentityReputation.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; /** * Class to handle identity's reputational score. This mostly depends on the identity's affinity to a profile, * our friends' opinion of the identity and our own opinion. */ final class IdentityReputation { /** * Identity's profile is known to us. This gives a high score. */ private static final int PROFILE_KNOWN_SCORE = 50; /** * Identity is linked to a profile. This gives a middle score. */ private static final int PROFILE_UNKNOWN_SCORE = 20; /** * Identity is not linked to a profile. This gives the lowest score. */ private static final int ANONYMOUS_SCORE = 5; private IdentityReputation() { throw new UnsupportedOperationException("Utility class"); } /** * Updates the identity reputation score. * * @param identity the identity to update * @param profileLinked if the identity is linked to a profile * @param profileKnown if the identity's profile is known to us */ public static void updateScore(IdentityGroupItem identity, boolean profileLinked, boolean profileKnown) { int identityScore; if (profileLinked) { if (profileKnown) { identityScore = PROFILE_KNOWN_SCORE; } else { identityScore = PROFILE_UNKNOWN_SCORE; } } else { identityScore = ANONYMOUS_SCORE; } identity.setIdentityScore(identityScore); identity.setOverallScore(identityScore + identity.getOwnOpinion() + identity.getPeerOpinion()); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/identity/IdentityRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.gxs.GxsCircleType; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.database.model.gxs.GxsPrivacyFlags; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.IdentityService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.ResourceCreationState; import io.xeres.app.service.SettingsService; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.util.GxsUtils; import io.xeres.app.xrs.common.CommentMessageItem; import io.xeres.app.xrs.common.VoteMessageItem; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.gxs.GxsAuthentication; import io.xeres.app.xrs.service.gxs.GxsHelperService; import io.xeres.app.xrs.service.gxs.GxsRsService; import io.xeres.app.xrs.service.gxs.GxsTransactionManager; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.dto.identity.IdentityConstants; import io.xeres.common.gxs.GxsGroupConstants; import io.xeres.common.id.*; import io.xeres.common.identity.Type; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.ExecutorUtils; import jakarta.persistence.EntityNotFoundException; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.security.InvalidKeyException; import java.security.KeyPair; import java.security.SignatureException; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.ScheduledExecutorService; import java.util.stream.Collectors; import static io.xeres.app.service.ResourceCreationState.*; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR; import static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_AUTHOR; import static io.xeres.app.xrs.service.identity.ValidationState.*; import static io.xeres.common.protocol.xrs.RsServiceType.GXS_IDENTITY; @Component public class IdentityRsService extends GxsRsService { private static final Duration PENDING_VALIDATION_START = Duration.ofSeconds(60); private static final Duration PENDING_VALIDATION_DELAY = Duration.ofSeconds(2); private static final Duration PENDING_VALIDATION_FULL_QUERY_DELAY = Duration.ofSeconds(60); private static final int PENDING_IDENTITIES_MAX = 32; private ScheduledExecutorService executorService; private final DatabaseSessionManager databaseSessionManager; private final Queue pendingIdentities = new ArrayDeque<>(PENDING_IDENTITIES_MAX); private Instant lastFullQuery = Instant.EPOCH; private final IdentityService identityService; private final SettingsService settingsService; private final ProfileService profileService; private final GxsHelperService gxsHelperService; private final ContactNotificationService contactNotificationService; public IdentityRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityService identityService, SettingsService settingsService, ProfileService profileService, IdentityManager identityManager, GxsHelperService gxsHelperService, ContactNotificationService contactNotificationService) { super(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService); this.databaseSessionManager = databaseSessionManager; this.identityService = identityService; this.settingsService = settingsService; this.profileService = profileService; this.gxsHelperService = gxsHelperService; this.contactNotificationService = contactNotificationService; } @Override public RsServiceType getServiceType() { return GXS_IDENTITY; } @Override protected GxsAuthentication getAuthentication() { return new GxsAuthentication.Builder() .withRequirements(EnumSet.of(ROOT_NEEDS_AUTHOR, CHILD_NEEDS_AUTHOR)) .build(); } @Override public void initialize() { super.initialize(); executorService = ExecutorUtils.createFixedRateExecutor(this::checkForProfileValidation, getInitPriority().getMaxTime() + PENDING_VALIDATION_START.toSeconds(), PENDING_VALIDATION_DELAY.toSeconds()); } @Override public void cleanup() { super.cleanup(); ExecutorUtils.cleanupExecutor(executorService); } private void checkForProfileValidation() { var identity = pendingIdentities.poll(); if (identity == null) { // Search for identities not validated yet var now = Instant.now(); if (lastFullQuery.isBefore(now)) { try (var ignored = new DatabaseSession(databaseSessionManager)) { pendingIdentities.addAll(identityService.findIdentitiesToValidate(PENDING_IDENTITIES_MAX)); lastFullQuery = now.plus(PENDING_VALIDATION_FULL_QUERY_DELAY); } } } else { try (var ignored = new DatabaseSession(databaseSessionManager)) { var validationResult = validate(identity); switch (validationResult.validationState()) { case VALID -> { IdentityReputation.updateScore(identity, true, true); identity.setNextValidation(null); linkWithProfileIfFound(identity, validationResult.pgpIdentifier()); identityService.save(identity); contactNotificationService.addOrUpdateIdentities(List.of(identity)); } case INVALID -> { identityService.delete(identity); contactNotificationService.removeIdentities(List.of(identity)); // This might be re-added immediately by discovery if it's on a friend. RS has the same problem } case NOT_FOUND -> { IdentityReputation.updateScore(identity, true, false); identity.computeNextValidationAttempt(); identityService.save(identity); contactNotificationService.addOrUpdateIdentities(List.of(identity)); } } } } } private ValidationResult validate(IdentityGroupItem identity) { var pgpId = PGP.getIssuer(identity.getProfileSignature()); if (pgpId == 0) { log.error("Found anonymous signature. Brute forcing it is not supported."); return new ValidationResult(INVALID, pgpId); } var profile = profileService.findProfileByPgpIdentifier(pgpId).orElse(null); if (profile == null) { log.debug("PGP profile not found for identity {}, retrying later", identity); return new ValidationResult(NOT_FOUND, pgpId); } var computedHash = makeProfileHash(identity.getGxsId(), profile.getProfileFingerprint()); if (!identity.getProfileHash().equals(computedHash)) { log.error("Wrong profile hash for identity {}", identity); return new ValidationResult(INVALID, pgpId); } try { PGP.verify(PGP.getPGPPublicKey(profile.getPgpPublicKeyData()), Objects.requireNonNull(identity.getProfileSignature()), new ByteArrayInputStream(computedHash.getBytes())); log.debug("Successful PGP profile validation for identity {}", identity); } catch (IOException | SignatureException | PGPException | InvalidKeyException | NullPointerException e) { log.error("Profile signature verification failed for identity {}: {}", identity, e.getMessage()); return new ValidationResult(INVALID, pgpId); } return new ValidationResult(VALID, pgpId); } private void linkWithProfileIfFound(IdentityGroupItem identity, long pgpId) { profileService.findProfileByPgpIdentifier(pgpId).ifPresent(identity::setProfile); } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { super.handleItem(sender, item); // This is required for the @Transactional to work } @Override protected List onAvailableGroupListRequest(PeerConnection recipient) { return identityService.findAllSubscribed(); } @Override protected Set onAvailableGroupListResponse(Map ids) { // From the received list, we keep all identities that have a more recent publishing date than those // we already have. If it's a new identity, we don't want it. var existingMap = identityService.findAll(ids.keySet()).stream() .collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished)); ids.entrySet().removeIf(gxsIdInstantEntry -> { var existing = existingMap.get(gxsIdInstantEntry.getKey()); return existing == null || !gxsIdInstantEntry.getValue().isAfter(existing); }); return ids.keySet(); } @Override protected List onGroupListRequest(Set ids) { return identityService.findAll(ids); } @Override protected boolean onGroupReceived(IdentityGroupItem identityGroupItem) { log.debug("Saving id {}", identityGroupItem.getGxsId()); // XXX: important! there should be some checks to make sure there's no malicious overwrite (probably a simple validation should do as id == fingerprint of key) identityGroupItem.setSubscribed(true); if (identityGroupItem.getDiffusionFlags().contains(GxsPrivacyFlags.SIGNED_ID)) { identityGroupItem.setNextValidation(Instant.now()); } return true; } @Override protected void onGroupsSaved(List items) { // We only send the notification for contacts that don't require a validation. // The others will appear upon validation (or be deleted if they're not validated). var itemsToNotify = items.stream() .filter(identityGroupItem -> !identityGroupItem.getDiffusionFlags().contains(GxsPrivacyFlags.SIGNED_ID)) .toList(); contactNotificationService.addOrUpdateIdentities(itemsToNotify); } @Override protected List onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since) { return Collections.emptyList(); } @Override protected List onMessageListRequest(GxsId gxsId, Set msgIds) { return Collections.emptyList(); } @Override protected List onMessageListResponse(GxsId gxsId, Set msgIds) { return Collections.emptyList(); } @Override protected boolean onMessageReceived(GxsMessageItem item) { return false; // we don't receive messages } @Override protected void onMessagesSaved(List items) { // nothing to do since we don't receive them } @Override protected boolean onCommentReceived(CommentMessageItem item) { return false; } @Override protected void onCommentsSaved(List items) { // Nothing to do } @Override protected boolean onVoteReceived(VoteMessageItem item) { return false; } @Override protected void onVotesSaved(List items) { // Nothing to do } @Override protected void syncMessages(PeerConnection recipient) { // Nothing to do } @Transactional public ResourceCreationState generateOwnIdentity(String name, boolean signed) { if (!settingsService.isOwnProfilePresent()) { log.error("Cannot create an identity without a profile; Create a profile first"); return FAILED; } if (!settingsService.hasOwnLocation()) { log.error("Cannot create an identity without a location; Create a location first"); return FAILED; } if (identityService.hasOwnIdentity()) { return ALREADY_EXISTS; } var gxsIdGroupItem = createGroup(name, false); try { createOwnIdentity(gxsIdGroupItem, signed); } catch (PGPException | IOException e) { log.error("Couldn't generate identity: {}", e.getMessage()); return FAILED; } return CREATED; } @Transactional public long createOwnIdentity(String name, KeyPair keyPair) throws PGPException, IOException { var gxsIdGroupItem = createGroup(name, keyPair, null); return createOwnIdentity(gxsIdGroupItem, true); } private long createOwnIdentity(IdentityGroupItem gxsIdGroupItem, boolean signed) throws PGPException, IOException { gxsIdGroupItem.setType(Type.OWN); gxsIdGroupItem.setCircleType(GxsCircleType.PUBLIC); log.debug("Own identity's GxsId: {}", gxsIdGroupItem.getGxsId()); if (signed) { var ownProfile = profileService.getOwnProfile(); computeHashAndSignature(gxsIdGroupItem, ownProfile); gxsIdGroupItem.setProfile(ownProfile); // This is because of some backward compatibility, ideally it should be PUBLIC | REAL_ID // PRIVATE is equal to REAL_ID_deprecated gxsIdGroupItem.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PRIVATE, GxsPrivacyFlags.SIGNED_ID)); } else { gxsIdGroupItem.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC)); // XXX: what should the serviceString have? } gxsIdGroupItem.setSubscribed(true); return saveIdentity(gxsIdGroupItem, true).getId(); } /** * Fixes a profile signature. Xeres used to generate bugged signatures because of a mistake (upper case GxsId instead of lowercase). * While RS will apparently accept them normally, Xeres will delete them. */ @Transactional public void fixOwnProfile() throws PGPException, IOException { if (!profileService.hasOwnProfile() || !identityService.hasOwnIdentity()) { return; // Nothing to do. There's no profile/identity yet. } var ownProfile = profileService.getOwnProfile(); var ownIdentity = identityService.getOwnIdentity(); ownIdentity.setProfile(ownProfile); computeHashAndSignature(ownIdentity, ownProfile); saveIdentity(ownIdentity, true); } /** * Fixes an identity signature. Xeres did the same as RS by serializing the service string with the identity. * Since this string is for local data, this was wrong. Removing it requires recomputing the signatures, though. */ @Transactional public void fixOwnIdentity() { if (!identityService.hasOwnIdentity()) { return; // Nothing to do. There's no identity yet. } var ownIdentity = identityService.getOwnIdentity(); ownIdentity.updatePublished(); saveIdentity(ownIdentity, true); } private void computeHashAndSignature(IdentityGroupItem gxsIdGroupItem, Profile profile) throws PGPException, IOException { var hash = makeProfileHash(gxsIdGroupItem.getGxsId(), profile.getProfileFingerprint()); gxsIdGroupItem.setProfileHash(hash); gxsIdGroupItem.setProfileSignature(makeProfileSignature(PGP.getPGPSecretKey(settingsService.getSecretProfileKey()), hash)); } @Transactional public IdentityGroupItem saveIdentity(IdentityGroupItem identityGroupItem) { return saveIdentity(identityGroupItem, false); } private IdentityGroupItem saveIdentity(IdentityGroupItem identityGroupItem, boolean updateGroup) { signGroupIfNeeded(identityGroupItem); var savedIdentity = identityService.save(identityGroupItem); if (updateGroup) { gxsHelperService.setLastServiceGroupsUpdateNow(RsServiceType.GXS_IDENTITY); } return savedIdentity; } @Transactional public IdentityGroupItem saveOwnIdentityImage(long id, MultipartFile file) throws IOException { if (id != IdentityConstants.OWN_IDENTITY_ID) { throw new EntityNotFoundException("Identity " + id + " is not our own"); } if (file == null || file.isEmpty()) { throw new IllegalArgumentException("Avatar image is empty"); } var identity = identityService.findById(id).orElseThrow(); identity.setImage(GxsUtils.getScaledGroupImage(file, GxsGroupConstants.IMAGE_SIDE_SIZE)); identity.updatePublished(); return saveIdentity(identity, true); } @Transactional public IdentityGroupItem deleteOwnIdentityImage(long id) { if (id != IdentityConstants.OWN_IDENTITY_ID) { throw new EntityNotFoundException("Identity " + id + " is not our own"); } var identity = identityService.findById(id).orElseThrow(); identity.setImage(null); identity.updatePublished(); return saveIdentity(identity, true); } @Override public void shutdown() { contactNotificationService.shutdown(); } static Sha1Sum makeProfileHash(GxsId gxsId, ProfileFingerprint fingerprint) { var gxsIdAsciiUpper = Id.toAsciiBytes(gxsId); var md = new Sha1MessageDigest(); md.update(gxsIdAsciiUpper); md.update(fingerprint.getBytes()); return md.getSum(); } private static byte[] makeProfileSignature(PGPSecretKey pgpSecretKey, Sha1Sum hashToSign) throws PGPException, IOException { var out = new ByteArrayOutputStream(); PGP.sign(pgpSecretKey, new ByteArrayInputStream(hashToSign.getBytes()), out, PGP.Armor.NONE); return out.toByteArray(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/identity/ValidationResult.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity; record ValidationResult(ValidationState validationState, long pgpIdentifier) { } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/identity/ValidationState.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity; enum ValidationState { VALID, INVALID, NOT_FOUND } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/identity/item/IdentityGroupItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity.item; import io.netty.buffer.ByteBuf; import io.xeres.app.database.model.gxs.GxsGroupItem; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.TlvType; import io.xeres.common.id.GxsId; import io.xeres.common.id.Sha1Sum; import io.xeres.common.identity.Type; import io.xeres.common.util.ByteUnitUtils; import jakarta.persistence.*; import org.apache.commons.lang3.ArrayUtils; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.*; @Entity(name = "identity_group") public class IdentityGroupItem extends GxsGroupItem { @Transient public static final IdentityGroupItem EMPTY = new IdentityGroupItem(); @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "profile_id") private Profile profile; @Embedded @AttributeOverride(name = "identifier", column = @Column(name = "profile_hash")) private Sha1Sum profileHash; // hash of the gxsId + public key private byte[] profileSignature; // PGP id is in there private Instant nextValidation; @Transient private List recognitionTags = new ArrayList<>(); // not used (but serialized) private byte[] image; private Type type = Type.OTHER; private Instant lastUsage; private int overallScore = 5; private int identityScore = 5; private int ownOpinion; private int peerOpinion; private int validationAttempt; private Instant lastValidation; @Transient private boolean oldVersion; // Needed because RS added image later, and it would break signature verification otherwise public IdentityGroupItem() { } public IdentityGroupItem(GxsId gxsId, String name) { setGxsId(gxsId); setName(name); updatePublished(); } @Override public int getSubType() { return 2; } public void computeNextValidationAttempt() { setValidationAttempt(getValidationAttempt() + 1); setLastValidation(Instant.now()); setNextValidation(getLastValidation().plus(Duration.ofDays(Math.min(getValidationAttempt(), 30)))); } public Profile getProfile() { return profile; } public void setProfile(Profile profile) { this.profile = profile; } public Sha1Sum getProfileHash() { return profileHash; } public void setProfileHash(Sha1Sum profileHash) { this.profileHash = profileHash; } public byte[] getProfileSignature() { return profileSignature; } public void setProfileSignature(byte[] profileSignature) { this.profileSignature = ArrayUtils.isNotEmpty(profileSignature) ? profileSignature : null; } public Instant getNextValidation() { return nextValidation; } public void setNextValidation(Instant nextValidation) { this.nextValidation = nextValidation; } public boolean hasImage() { return image != null; } public byte[] getImage() { return image; } public void setImage(byte[] image) { if (ArrayUtils.isNotEmpty(image)) { this.image = image; } else { this.image = null; } } public Instant getLastUsage() { return lastUsage; } public void setLastUsage(Instant lastUsage) { this.lastUsage = lastUsage; } public int getOverallScore() { return overallScore; } public void setOverallScore(int overallScore) { this.overallScore = overallScore; } public int getIdentityScore() { return identityScore; } public void setIdentityScore(int identityScore) { this.identityScore = identityScore; } public int getOwnOpinion() { return ownOpinion; } public void setOwnOpinion(int ownOpinion) { this.ownOpinion = ownOpinion; } public int getPeerOpinion() { return peerOpinion; } public void setPeerOpinion(int peerOpinion) { this.peerOpinion = peerOpinion; } public int getValidationAttempt() { return validationAttempt; } public void setValidationAttempt(int validationAttempt) { this.validationAttempt = validationAttempt; } public Instant getLastValidation() { return lastValidation; } public void setLastValidation(Instant lastValidation) { this.lastValidation = lastValidation; } public Type getType() { return type; } public void setType(Type type) { this.type = type; } @Override public int writeDataObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, profileHash, Sha1Sum.class); size += serialize(buf, TlvType.STR_SIGN, profileSignature); size += serialize(buf, TlvType.SET_RECOGN, recognitionTags); if (!oldVersion) { size += serialize(buf, TlvType.IMAGE, image); } return size; } @Override public void readDataObject(ByteBuf buf) { profileHash = (Sha1Sum) deserializeIdentifier(buf, Sha1Sum.class); setProfileSignature((byte[]) deserialize(buf, TlvType.STR_SIGN)); //noinspection unchecked recognitionTags = (List) deserialize(buf, TlvType.SET_RECOGN); if (buf.isReadable()) { setImage((byte[]) deserialize(buf, TlvType.IMAGE)); } else { oldVersion = true; } } @Override public IdentityGroupItem clone() { return (IdentityGroupItem) super.clone(); } @Override public String toString() { return "IdentityGroupItem{" + "name=" + getName() + ", gxsId=" + getGxsId() + ", profile=" + profile + ", profileHash=" + profileHash + ", profileSignature=" + (profileSignature != null ? ("yes, " + ByteUnitUtils.fromBytes(profileSignature.length)) : "no") + ", nextValidation=" + nextValidation + ", recognitionTags=" + recognitionTags + ", image=" + (image != null ? ("yes, " + ByteUnitUtils.fromBytes(image.length)) : "no") + ", type=" + type + ", oldVersion=" + oldVersion + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/rtt/RttRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.rtt; import io.xeres.app.application.events.PeerDisconnectedEvent; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.rtt.item.RttPingItem; import io.xeres.app.xrs.service.rtt.item.RttPongItem; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.rest.statistics.RttPeer; import io.xeres.common.rest.statistics.RttStatisticsResponse; import org.apache.commons.collections4.queue.CircularFifoQueue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import static io.xeres.common.protocol.xrs.RsServiceType.RTT; @Component public class RttRsService extends RsService { private static final Logger log = LoggerFactory.getLogger(RttRsService.class); private final PeerConnectionManager peerConnectionManager; private static final int KEY_COUNTER = 1; public static final int KEY_RTT = 2; private final Map> peers = new ConcurrentHashMap<>(); public RttRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager) { super(rsServiceRegistry); this.peerConnectionManager = peerConnectionManager; } @Override public RsServiceType getServiceType() { return RTT; } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.NORMAL; } @Override public void initialize(PeerConnection peerConnection) { peerConnection.scheduleAtFixedRate( () -> peerConnectionManager.writeItem(peerConnection, new RttPingItem(getCounter(peerConnection), get64bitsTimeStamp()), this), 0, 10, TimeUnit.SECONDS); var means = peers.computeIfAbsent(peerConnection.getLocation().getId(), _ -> new CircularFifoQueue<>(20)); means.clear(); } @EventListener public void onPeerDisconnectedEvent(PeerDisconnectedEvent event) { peers.remove(event.id()); } private int getCounter(PeerConnection peerConnection) { var counter = (int) peerConnection.getServiceData(this, KEY_COUNTER).orElse(1); peerConnection.putServiceData(this, KEY_COUNTER, ++counter); return counter; } private static long get64bitsTimeStamp() { var now = Instant.now().truncatedTo(ChronoUnit.MICROS); return (now.getEpochSecond() << 32) + now.getNano() / 1_000L; } private static Instant getInstantFromTimestamp(long timestamp) { return Instant.ofEpochSecond(timestamp >> 32 & 0xffffffffL, (timestamp & 0xffffffffL) * 1_000L); } @Override public void handleItem(PeerConnection sender, Item item) { if (item instanceof RttPingItem pingItem) { var pong = new RttPongItem(pingItem, get64bitsTimeStamp()); peerConnectionManager.writeItem(sender, pong, this); } else if (item instanceof RttPongItem pongItem) { var now = Instant.now(); var ping = getInstantFromTimestamp(pongItem.getPingTimestamp()); var pong = getInstantFromTimestamp(pongItem.getPongTimestamp()); var rtt = Duration.between(ping, now); var offset = Duration.between(pong, now.minus(rtt.dividedBy(2))); var peerTime = now.plus(offset); log.debug("RTT: {}, offset: {}, peerTime: {}", rtt, offset, peerTime); sender.putServiceData(this, KEY_RTT, rtt.toMillis()); var means = peers.get(sender.getLocation().getId()); means.add(offset); if (means.isAtFullCapacity()) { var mean = means.stream() .mapToLong(Duration::toMillis) .average() .orElse(0.0) / 1000.0; if (Math.abs(mean) > 120.0) { log.warn("Peer {}'s time is drifting ({} seconds)", sender, mean); } } } } public RttStatisticsResponse getStatistics() { List rttPeers = new ArrayList<>(peerConnectionManager.getNumberOfPeers()); peerConnectionManager.doForAllPeers(peerConnection -> rttPeers.add(new RttPeer(peerConnection.getLocation().getId(), peerConnection.getLocation().getProfile().getName() + "@" + peerConnection.getLocation().getSafeName(), (long) peerConnection.getServiceData(this, KEY_RTT).orElse(0L))), this); return new RttStatisticsResponse(rttPeers); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/rtt/item/RttPingItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.rtt.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; public class RttPingItem extends Item { @RsSerialized private int sequenceNumber; @RsSerialized private long timestamp; @SuppressWarnings("unused") public RttPingItem() { } public RttPingItem(int sequenceNumber, long timeStamp) { this.sequenceNumber = sequenceNumber; timestamp = timeStamp; } @Override public int getServiceType() { return RsServiceType.RTT.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return ItemPriority.REALTIME.getPriority(); } public int getSequenceNumber() { return sequenceNumber; } public long getTimestamp() { return timestamp; } @Override public RttPingItem clone() { return (RttPingItem) super.clone(); } @Override public String toString() { return "RttPingItem{" + "sequenceNumber=" + sequenceNumber + ", pingTimeStamp=" + timestamp + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/rtt/item/RttPongItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.rtt.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; public class RttPongItem extends Item { @RsSerialized private int sequenceNumber; @RsSerialized private long pingTimestamp; @RsSerialized private long pongTimestamp; @SuppressWarnings("unused") public RttPongItem() { } public RttPongItem(RttPingItem pingItem, long timeStamp) { sequenceNumber = pingItem.getSequenceNumber(); pingTimestamp = pingItem.getTimestamp(); pongTimestamp = timeStamp; } @Override public int getServiceType() { return RsServiceType.RTT.getType(); } @Override public int getSubType() { return 2; } @Override public int getPriority() { return ItemPriority.REALTIME.getPriority(); } public long getPingTimestamp() { return pingTimestamp; } public long getPongTimestamp() { return pongTimestamp; } @Override public RttPongItem clone() { return (RttPongItem) super.clone(); } @Override public String toString() { return "RttPongItem{" + "sequenceNumber=" + sequenceNumber + ", pingTimeStamp=" + pingTimestamp + ", pongTimeStamp=" + pongTimestamp + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/serviceinfo/ServiceInfoRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.serviceinfo; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.serviceinfo.item.ServiceInfo; import io.xeres.app.xrs.service.serviceinfo.item.ServiceListItem; import io.xeres.common.protocol.xrs.RsServiceType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.PriorityQueue; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import static io.xeres.common.protocol.xrs.RsServiceType.PACKET_SLICING_PROBE; import static io.xeres.common.protocol.xrs.RsServiceType.SERVICE_INFO; import static java.util.stream.Collectors.joining; @Component public class ServiceInfoRsService extends RsService { private static final Logger log = LoggerFactory.getLogger(ServiceInfoRsService.class); private final PeerConnectionManager peerConnectionManager; private final RsServiceRegistry rsServiceRegistry; public ServiceInfoRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager) { super(rsServiceRegistry); this.peerConnectionManager = peerConnectionManager; this.rsServiceRegistry = rsServiceRegistry; } public void init(PeerConnection peerConnection) { sendFirstServiceList(peerConnection); //XXX: if sending and receiving at the same time (5 seconds makes it happen), then it can resend back. solution? put a timer before sending back? } @Override public RsServiceType getServiceType() { return SERVICE_INFO; } @Override public void handleItem(PeerConnection sender, Item item) { if (item instanceof ServiceListItem serviceListItem) { // RS requests services twice upon first connection (bug?) if (!sender.canSendServices()) { return; } var services = new PriorityQueue(); serviceListItem.getServices().forEach((_, serviceInfo) -> { var rsService = rsServiceRegistry.getServiceFromType(serviceInfo.getType()); if (rsService != null) { sender.addService(rsService); services.add(rsService); } }); if (log.isDebugEnabled()) { log.debug("Enabling services {} to peer {}", services.stream().map(rsService -> rsService.getServiceType().name()).collect(joining(", ")), sender); } sendFirstServiceList(sender); initializeServices(sender, services); } } private void sendFirstServiceList(PeerConnection peerConnection) { var services = new HashMap(); var allServices = rsServiceRegistry.getServices(); allServices.stream() .filter(Predicate.not(rsService -> rsService.getServiceType() == PACKET_SLICING_PROBE)) // we hide this as it's not strictly a service in RS' terms .forEach(rsService -> { var serviceType = rsService.getServiceType(); var type = 2 << 24 | rsService.getServiceType().getType() << 8; services.put(type, new ServiceInfo(serviceType.getName(), type, rsService.getServiceType().getVersionMajor(), rsService.getServiceType().getVersionMinor())); }); peerConnectionManager.writeItem(peerConnection, new ServiceListItem(services), this); } private static void initializeServices(PeerConnection peerConnection, PriorityQueue services) { RsService rsService; while ((rsService = services.poll()) != null) { if (rsService.getInitPriority() != RsServiceInitPriority.OFF) { var finalRsService = rsService; peerConnection.schedule(() -> finalRsService.initialize(peerConnection), ThreadLocalRandom.current().nextInt(rsService.getInitPriority().getMinTime(), rsService.getInitPriority().getMaxTime() + 1), TimeUnit.SECONDS); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/serviceinfo/item/ServiceInfo.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.serviceinfo.item; import io.netty.buffer.ByteBuf; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import java.util.Set; import static io.xeres.app.xrs.serialization.Serializer.*; public class ServiceInfo implements RsSerializable { private String name; private int serviceType; private short versionMajor; private short versionMinor; private short minVersionMajor; private short minVersionMinor; public ServiceInfo() { } public ServiceInfo(String name, int serviceType, short versionMajor, short versionMinor) { this.name = name; this.serviceType = serviceType; this.versionMajor = versionMajor; this.versionMinor = versionMinor; minVersionMajor = versionMajor; minVersionMinor = versionMinor; } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += serialize(buf, name); size += serialize(buf, serviceType); size += serialize(buf, versionMajor); size += serialize(buf, versionMinor); size += serialize(buf, minVersionMajor); size += serialize(buf, minVersionMinor); return size; } @Override public void readObject(ByteBuf buf) { name = deserializeString(buf); serviceType = deserializeInt(buf); versionMajor = deserializeShort(buf); versionMinor = deserializeShort(buf); minVersionMajor = deserializeShort(buf); minVersionMinor = deserializeShort(buf); } public String getName() { return name; } public int getServiceType() { return serviceType; } public int getType() { return (serviceType >> 8) & 0xffff; } public short getVersionMajor() { return versionMajor; } public short getVersionMinor() { return versionMinor; } public short getMinVersionMajor() { return minVersionMajor; } public short getMinVersionMinor() { return minVersionMinor; } @Override public String toString() { return "ServiceInfo{" + "name='" + name + '\'' + ", type=" + serviceType + ", version=" + versionMajor + "." + versionMinor + ", min=" + minVersionMajor + "." + minVersionMinor + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/serviceinfo/item/ServiceListItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.serviceinfo.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; import java.util.HashMap; import java.util.Map; public class ServiceListItem extends Item { @RsSerialized private Map services = new HashMap<>(); @SuppressWarnings("unused") public ServiceListItem() { } public ServiceListItem(Map services) { this.services = services; } @Override public int getServiceType() { return RsServiceType.SERVICE_INFO.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return 7; } public Map getServices() { return services; } @Override public ServiceListItem clone() { return (ServiceListItem) super.clone(); } @Override public String toString() { return "ServiceListItem{" + "map=" + services.values() + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/sliceprobe/SliceProbeRsService.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.sliceprobe; import io.xeres.app.net.peer.PeerAttribute; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.common.protocol.xrs.RsServiceType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import static io.xeres.common.protocol.xrs.RsServiceType.PACKET_SLICING_PROBE; @Component public class SliceProbeRsService extends RsService { private static final Logger log = LoggerFactory.getLogger(SliceProbeRsService.class); public SliceProbeRsService(RsServiceRegistry rsServiceRegistry) { super(rsServiceRegistry); } @Override public RsServiceType getServiceType() { return PACKET_SLICING_PROBE; } @Override public void handleItem(PeerConnection sender, Item item) { if (!Boolean.TRUE.equals(sender.getCtx().channel().attr(PeerAttribute.MULTI_PACKET).get())) { log.debug("Received slice probe, switching to new packet format for current session"); sender.getCtx().channel().attr(PeerAttribute.MULTI_PACKET).set(true); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/sliceprobe/item/SliceProbeItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.sliceprobe.item; import io.netty.channel.ChannelHandlerContext; import io.xeres.app.xrs.item.Item; import io.xeres.common.protocol.xrs.RsServiceType; public class SliceProbeItem extends Item { public static SliceProbeItem from(ChannelHandlerContext ctx) { var sliceProbeItem = new SliceProbeItem(); sliceProbeItem.setOutgoing(ctx.alloc(), null); return sliceProbeItem; } @Override public int getServiceType() { return RsServiceType.PACKET_SLICING_PROBE.getType(); } @Override public int getSubType() { return 0xCC; } @Override public SliceProbeItem clone() { return (SliceProbeItem) super.clone(); } @Override public String toString() { return "SliceProbeItem{}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/ChatStatus.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status; public enum ChatStatus { // Order and values matter OFFLINE, AWAY, BUSY, ONLINE, INACTIVE // RS uses that like "Away" except it's automatic. We don't use it. } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/GetIdleTime.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status; public interface GetIdleTime { int getIdleTime(); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/IdleChecker.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status; import org.springframework.stereotype.Component; @Component public class IdleChecker { private final GetIdleTime getIdleTime; public IdleChecker(@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") GetIdleTime getIdleTime) { this.getIdleTime = getIdleTime; } public int getIdleTime() { return getIdleTime.getIdleTime(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/StatusRsService.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.LocationService; import io.xeres.app.service.MessageService; import io.xeres.app.service.notification.availability.AvailabilityNotificationService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceInitPriority; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.status.item.StatusItem; import io.xeres.common.location.Availability; import io.xeres.common.protocol.xrs.RsServiceType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.concurrent.TimeUnit; import static io.xeres.common.message.MessagePath.chatPrivateDestination; import static io.xeres.common.message.MessageType.CHAT_AVAILABILITY; import static io.xeres.common.protocol.xrs.RsServiceType.STATUS; @Component public class StatusRsService extends RsService { private static final Logger log = LoggerFactory.getLogger(StatusRsService.class); private Availability availability = Availability.AVAILABLE; private final PeerConnectionManager peerConnectionManager; private final MessageService messageService; private final LocationService locationService; private final AvailabilityNotificationService availabilityNotificationService; private final DatabaseSessionManager databaseSessionManager; private boolean locked; public StatusRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, MessageService messageService, LocationService locationService, AvailabilityNotificationService availabilityNotificationService, DatabaseSessionManager databaseSessionManager) { super(rsServiceRegistry); this.peerConnectionManager = peerConnectionManager; this.messageService = messageService; this.locationService = locationService; this.availabilityNotificationService = availabilityNotificationService; this.databaseSessionManager = databaseSessionManager; } @Override public RsServiceType getServiceType() { return STATUS; } @Override public RsServiceInitPriority getInitPriority() { return RsServiceInitPriority.NORMAL; } @Override public void initialize(PeerConnection peerConnection) { peerConnection.schedule( () -> peerConnectionManager.writeItem(peerConnection, new StatusItem(ChatStatus.ONLINE), this), 0, TimeUnit.SECONDS); } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { if (item instanceof StatusItem statusItem) { log.debug("Got status {} from peer {}", statusItem.getStatus(), sender); var newStatus = toAvailability(statusItem.getStatus()); locationService.setAvailability(sender.getLocation(), newStatus); availabilityNotificationService.changeAvailability(sender.getLocation(), newStatus); messageService.sendToConsumers(chatPrivateDestination(), CHAT_AVAILABILITY, sender.getLocation().getLocationIdentifier(), newStatus); } } private Availability toAvailability(ChatStatus status) { return switch (status) { case ONLINE -> Availability.AVAILABLE; case AWAY, INACTIVE, OFFLINE -> Availability.AWAY; case BUSY -> Availability.BUSY; }; } private ChatStatus toChatStatus(Availability availability) { return switch (availability) { case AVAILABLE -> ChatStatus.ONLINE; case AWAY -> ChatStatus.AWAY; case BUSY -> ChatStatus.BUSY; case OFFLINE -> ChatStatus.OFFLINE; }; } public void changeAvailability(Availability availability) { locked = false; changeAvailabilityAutomatically(availability); locked = availability != Availability.AVAILABLE; } public void changeAvailabilityAutomatically(Availability availability) { if (!locked && availability != this.availability) { try (var session = new DatabaseSession(databaseSessionManager)) { var ownLocation = locationService.findOwnLocation().orElseThrow(); this.availability = availability; locationService.setAvailability(ownLocation, availability); availabilityNotificationService.changeAvailability(ownLocation, availability); peerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, new StatusItem(toChatStatus(availability)), this), this); } } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeGeneric.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status.idletimer; import io.xeres.app.xrs.service.status.GetIdleTime; public class GetIdleTimeGeneric implements GetIdleTime { @Override public int getIdleTime() { return 0; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeLinux.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status.idletimer; import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.NativeLong; import com.sun.jna.Structure; import com.sun.jna.Structure.FieldOrder; import com.sun.jna.platform.unix.X11; import io.xeres.app.xrs.service.status.GetIdleTime; public class GetIdleTimeLinux implements GetIdleTime { @SuppressWarnings("unused") @FieldOrder({"window", "state", "kind", "tilOrSince", "idle", "eventMask"}) public static class XScreenSaverInfo extends Structure { public X11.Window window; public int state; public int kind; public NativeLong tilOrSince; public NativeLong idle; public NativeLong eventMask; } private interface Xss extends Library { Xss INSTANCE = Native.load("Xss", Xss.class); @SuppressWarnings("UnusedReturnValue") int XScreenSaverQueryInfo(X11.Display display, X11.Drawable drawable, XScreenSaverInfo xScreenSaverInfo); } @Override public int getIdleTime() { X11.Display display = null; X11.Window window; XScreenSaverInfo xScreenSaverInfo; var idleMillis = 0L; try { display = X11.INSTANCE.XOpenDisplay(null); window = X11.INSTANCE.XDefaultRootWindow(display); xScreenSaverInfo = new XScreenSaverInfo(); Xss.INSTANCE.XScreenSaverQueryInfo(display, window, xScreenSaverInfo); idleMillis = xScreenSaverInfo.idle.longValue(); } catch (NoClassDefFoundError | UnsatisfiedLinkError _) { // No X11 library (console-only). There's no way to get idle time then. } finally { if (display != null) { X11.INSTANCE.XCloseDisplay(display); } } return (int) (idleMillis / 1000); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeMac.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status.idletimer; import com.sun.jna.Library; import com.sun.jna.Native; import io.xeres.app.xrs.service.status.GetIdleTime; public class GetIdleTimeMac implements GetIdleTime { private interface ApplicationServices extends Library { ApplicationServices INSTANCE = Native.load("ApplicationServices", ApplicationServices.class); int kCGAnyInputEventType = ~0; int kCGEventSourceStateCombinedSessionState = 0; double CGEventSourceSecondsSinceLastEventType(int sourceStateId, int eventType); } @Override public int getIdleTime() { var idleTimeSeconds = ApplicationServices.INSTANCE.CGEventSourceSecondsSinceLastEventType( ApplicationServices.kCGEventSourceStateCombinedSessionState, ApplicationServices.kCGAnyInputEventType); return (int) idleTimeSeconds; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeWindows.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status.idletimer; import com.sun.jna.platform.win32.Kernel32; import com.sun.jna.platform.win32.User32; import com.sun.jna.platform.win32.WinUser; import io.xeres.app.xrs.service.status.GetIdleTime; public class GetIdleTimeWindows implements GetIdleTime { @Override public int getIdleTime() { var lastInputInfo = new WinUser.LASTINPUTINFO(); User32.INSTANCE.GetLastInputInfo(lastInputInfo); return (Kernel32.INSTANCE.GetTickCount() - lastInputInfo.dwTime) / 1000; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/status/item/StatusItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.status.ChatStatus; import io.xeres.common.protocol.xrs.RsServiceType; import java.time.Instant; public class StatusItem extends Item { @RsSerialized private int sendTime; @RsSerialized private ChatStatus status; @SuppressWarnings("unused") public StatusItem() { } public StatusItem(ChatStatus status) { sendTime = (int) Instant.now().getEpochSecond(); this.status = status; } @Override public int getServiceType() { return RsServiceType.STATUS.getType(); } @Override public int getSubType() { return 1; } public int getSendTime() { return sendTime; } public ChatStatus getStatus() { return status; } @Override public StatusItem clone() { return (StatusItem) super.clone(); } @Override public String toString() { return "StatusItem{" + "sendTime=" + sendTime + ", status=" + status + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/HashInfo.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import java.time.Instant; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * Keeps track of the activity for the file hashes that the turtle router is asked to monitor. */ class HashInfo { private final Set tunnels = ConcurrentHashMap.newKeySet(); private int lastRequest; private Instant lastDiggTime; private final TurtleRsClient client; private final boolean aggressiveMode; // if set, allows creation of concurrent tunnels (for example 4 tunnels to download 1 file) /** * Creates a HashInfo to keep track of the activity regarding a file hash, thus is usually paired with one. * * @param aggressiveMode if true, allow the use of multiple tunnels for one hash * @param client the {@link TurtleRsClient} */ public HashInfo(boolean aggressiveMode, TurtleRsClient client) { lastDiggTime = Instant.EPOCH; this.client = client; this.aggressiveMode = aggressiveMode; } public int getLastRequest() { return lastRequest; } public void setLastRequest(int lastRequest) { this.lastRequest = lastRequest; } public void addTunnel(int tunnelId) { tunnels.add(tunnelId); } public TurtleRsClient getClient() { return client; } public Set getTunnels() { return tunnels; } public void removeTunnel(int tunnelId) { tunnels.remove(tunnelId); } public boolean hasTunnels() { return !tunnels.isEmpty(); } public Instant getLastDiggTime() { return lastDiggTime; } public void setLastDiggTime(Instant lastDiggTime) { this.lastDiggTime = lastDiggTime; } public boolean isAggressiveMode() { return aggressiveMode; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/SearchRequest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.model.location.Location; import java.time.Instant; /** * Keeps track of search requests. */ class SearchRequest { private final Location source; private final Instant lastUsed; private final int depth; private final String keywords; private int resultCount; private final int hitLimit; private TurtleRsClient client; /** * Creates a search request. * * @param source where the search request came from * @param depth depth of the search request, used to optimize tunnel length * @param keywords search string * @param resultCount number of responses to this search request, useful to avoid spamming tunnel responses * @param hitLimit maximum number of hits allowed for this search request */ public SearchRequest(Location source, int depth, String keywords, int resultCount, int hitLimit) { this.source = source; lastUsed = Instant.now(); this.depth = depth; this.keywords = keywords; this.resultCount = resultCount; this.hitLimit = hitLimit; } public SearchRequest(TurtleRsClient client, Location source, int depth, String keywords, int resultCount, int hitLimit) { this(source, depth, keywords, resultCount, hitLimit); this.client = client; } public Location getSource() { return source; } public Instant getLastUsed() { return lastUsed; } public int getDepth() { return depth; } public String getKeywords() { return keywords; } public int getResultCount() { return resultCount; } public int getHitLimit() { return hitLimit; } public boolean isFull() { return resultCount >= hitLimit; } public void addResultCount(int value) { resultCount += value; } public TurtleRsClient getClient() { return client; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/Tunnel.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.Sha1Sum; import java.time.Instant; /** * Keeps track of tunnels. */ class Tunnel { private final Location source; private final Location destination; private final Location virtualLocation; private Sha1Sum hash; private Instant lastUsed; private long transferredBytes; private double speedBps; /** * Creates a tunnel. * * @param tunnelId the tunnel id, it will define the virtual location * @param source where packets come from * @param destination where packets go to (might not be the final recipient, the virtual location is) * @param hash hash of the file for this tunnel */ public Tunnel(int tunnelId, Location source, Location destination, Sha1Sum hash) { this.source = source; this.destination = destination; this.hash = hash; virtualLocation = VirtualLocation.fromTunnel(tunnelId); lastUsed = Instant.now(); } public Location getSource() { return source; } public Location getDestination() { return destination; } public Location getVirtualLocation() { return virtualLocation; } public Sha1Sum getHash() { return hash; } public void setHash(Sha1Sum hash) { this.hash = hash; } public double getSpeedBps() { return speedBps; } public void setSpeedBps(double speedBps) { this.speedBps = speedBps; } public void addTransferredBytes(long transferredBytes) { this.transferredBytes += transferredBytes; } public Instant getLastUsed() { return lastUsed; } public long getTransferredBytes() { return transferredBytes; } public void clearTransferredBytes() { transferredBytes = 0; } public void stamp() { lastUsed = Instant.now(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/TunnelProbability.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.xrs.service.turtle.item.TurtleSearchRequestItem; import io.xeres.app.xrs.service.turtle.item.TurtleTunnelRequestItem; import io.xeres.common.util.SecureRandomUtils; import static io.xeres.app.xrs.service.turtle.TurtleRsService.MAX_TUNNEL_DEPTH; /** * Calculates probabilities of forwarding turtle tunnels. */ class TunnelProbability { private static final int TUNNEL_REQUEST_PACKET_SIZE = 50; private static final int MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND = 20; private static final int DISTANCE_SQUEEZING_POWER = 8; private static final double[] DEPTH_PEER_PROBABILITY = new double[]{1.0, 0.99, 0.9, 0.7, 0.6, 0.5, 0.4}; private final int bias; public TunnelProbability() { bias = SecureRandomUtils.nextInt(); } /** * Finds out if a search request subclass is forwardable. Its depth has to be lower than MAX_TUNNEL_DEPTH. There's a random * bias to let some packets pass to avoid a successful search by depth attack. * * @param item a {@link TurtleSearchRequestItem}, not null * @return true if forwardable */ public boolean isForwardable(TurtleSearchRequestItem item) { return isForwardable(item.getRequestId(), item.getDepth()); } /** * Finds out if a tunnel request is forwardable. Its depth has to be lower than MAX_TUNNEL_DEPTH. There's a random * bias to let some packets pass to avoid a successful search by depth attack. * * @param item a {@link TurtleTunnelRequestItem}, not null * @return true if forwardable */ public boolean isForwardable(TurtleTunnelRequestItem item) { return isForwardable(item.getPartialTunnelId(), item.getDepth()); } private boolean isForwardable(int id, int depth) { var randomBypass = depth >= MAX_TUNNEL_DEPTH && (((bias ^ id) & 0x7) == 2); return depth < MAX_TUNNEL_DEPTH || randomBypass; } public void incrementDepth(TurtleSearchRequestItem item) { item.setDepth(incrementDepth(item.getRequestId(), item.getDepth())); } public void incrementDepth(TurtleTunnelRequestItem item) { item.setDepth(incrementDepth(item.getPartialTunnelId(), item.getDepth())); } private short incrementDepth(int id, short depth) { var randomDepthSkipShift = depth == 1 && (((bias ^ id) & 0x7) == 6); if (!randomDepthSkipShift) { depth++; } return depth; } public int getBias() { return bias; } /** * Gets the forwarding probability of a tunnel request. *

* A particular care is taken to not flood the network: *
    *
  • if the number of tunnel requests to forward per seconds is below {@link #MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND}, keep the traffic
  • *
  • if the limit is approached, start dropping with long tunnels first
  • *
* Variables involved: *
    *
  • distanceToMaximum: in [0,inf] is the proportion of the current up TR speed with respect to the maximum allowed speed. This is estimated * as an average between the average number of TR over the 60 last seconds and the current TR up speed
  • *
  • correctedDistance: in [0,inf] is a squeezed version of distance: small values become very small and large values become very large
  • *
  • {@link #DEPTH_PEER_PROBABILITY}: basic probability of forwarding when the speed limit is reached
  • *
  • forwardProbability: final probability of forwarding the packet, per peer
  • *
* When the number of peers increases, the speed limit is reached faster, but the behavior per peer is the same. * * @param item a {@link TurtleTunnelRequestItem}, not null * @param tunnelRequestsUpload the bandwidth of tunnel requests (up) in bytes per seconds * @param tunnelRequestsDownload the bandwidth of tunnel requests (down) in bytes per seconds * @param numberOfPeers the number of connected peers * @return a probability value between 0.0 and 1.0, both inclusive */ public double getForwardingProbability(TurtleTunnelRequestItem item, double tunnelRequestsUpload, double tunnelRequestsDownload, int numberOfPeers) { var distanceToMaximum = Math.min(100.0, tunnelRequestsUpload / (TUNNEL_REQUEST_PACKET_SIZE * MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND)); var correctedDistance = Math.pow(distanceToMaximum, DISTANCE_SQUEEZING_POWER); var forwardProbability = Math.pow(DEPTH_PEER_PROBABILITY[Math.min(DEPTH_PEER_PROBABILITY.length - 1, item.getDepth())], correctedDistance); if (forwardProbability * numberOfPeers < 1.0 && numberOfPeers > 0) { forwardProbability = 1.0 / numberOfPeers; if (tunnelRequestsDownload / TUNNEL_REQUEST_PACKET_SIZE > MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND) { forwardProbability *= MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND * TUNNEL_REQUEST_PACKET_SIZE / tunnelRequestsDownload; } } return forwardProbability; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/TunnelRequest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.model.location.Location; import java.time.Instant; import java.util.HashSet; import java.util.Set; /** * Keeps track of tunnel requests. */ class TunnelRequest { private final Location source; private final Instant lastUsed; private final int depth; private final Set responses; /** * Creates a tunnel request. * * @param source where the request came from * @param depth depth of the request, used to optimize tunnel length */ public TunnelRequest(Location source, int depth) { this.source = source; lastUsed = Instant.now(); this.depth = depth; responses = new HashSet<>(); } public Location getSource() { return source; } public Instant getLastUsed() { return lastUsed; } public int getDepth() { return depth; } public Set getResponses() { return responses; } public boolean hasResponseAlready(int id) { return responses.contains(id); } public void addResponse(int id) { responses.add(id); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleRouter.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.model.location.Location; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import io.xeres.common.id.Sha1Sum; /** * Represents a Turtle Router. It is given to Turtle Clients in the initialization method to enable its functions to be called anytime. *

* Only encrypted hashes are supported. */ public interface TurtleRouter { /** * Starts monitoring tunnels for a given hash. *

* Should be called before downloading a file so that the turtle router can provide the tunnels for it. * * @param hash the encrypted hash to monitor tunnels for * @param client the {@link TurtleRsClient} * @param allowMultiTunnels true to allow multiple tunnels to be created (aggressive mode), otherwise only use one tunnel */ void startMonitoringTunnels(Sha1Sum hash, TurtleRsClient client, boolean allowMultiTunnels); /** * Stops monitoring tunnels for a given hash. *

* Should be called after a download is finished (successfully or not) so that the tunnels can be cleaned up. * * @param hash the encrypted hash to stops monitoring tunnels for */ void stopMonitoringTunnels(Sha1Sum hash); /** * Forces to re-digg a tunnel. * * @param hash the encrypted hash to re-digg a tunnel for */ void forceReDiggTunnel(Sha1Sum hash); /** * Sends data using Turtle. * * @param virtualPeer the virtual peer to send data to * @param item the data represented by any subclass of {@link TurtleGenericTunnelItem} */ void sendTurtleData(Location virtualPeer, TurtleGenericTunnelItem item); /** * Checks if a location is a virtual turtle peer. * * @param location the location * @return true if it's a virtual turtle peer */ boolean isVirtualPeer(Location location); /** * Performs a tunnel search. * * @param search the search string * @param client a {@link TurtleRsClient} * @return the search id */ int turtleSearch(String search, TurtleRsClient client); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleRsClient.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.xrs.service.RsServiceSlave; import io.xeres.app.xrs.service.filetransfer.FileTransferRsService; import io.xeres.app.xrs.service.turtle.item.TunnelDirection; import io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem; import io.xeres.app.xrs.service.turtle.item.TurtleSearchResultItem; import io.xeres.common.id.Sha1Sum; import java.util.List; /** * Represents a turtle clients. For example the {@link FileTransferRsService file transfer service} is a turtle client and * will receive events from the {@link TurtleRsService turtle router}. */ public interface TurtleRsClient extends RsServiceSlave { /** * Called to initialize the turtle client. * * @param turtleRouter the {@link TurtleRouter}. Keep it somewhere so that you can call its methods. */ void initializeTurtle(TurtleRouter turtleRouter); /** * Called to ask if this hash can be handled. *

* It usually boils down to searching it in some database or list. * * @param sender the {@link PeerConnection} where it comes from * @param hash the encrypted hash * @return true if it can be handled */ boolean handleTunnelRequest(PeerConnection sender, Sha1Sum hash); /** * Called when receiving data from a tunnel. * * @param item a {@link TurtleGenericTunnelItem} subclass * @param hash the encrypted hash from which the data is related to * @param virtualLocation the virtual location * @param tunnelDirection if data is from a {@link TunnelDirection#SERVER} or a {@link TunnelDirection#CLIENT} */ void receiveTurtleData(TurtleGenericTunnelItem item, Sha1Sum hash, Location virtualLocation, TunnelDirection tunnelDirection); /** * Called to ask to search for something. * * @param query the search query * @param maxHits the maximum number of hits to send back * @return the search results */ List receiveSearchRequest(byte[] query, int maxHits); // XXX: return a list of results (TurtleFileInfoV2.. actually it's generic stuff so service dependent) void receiveSearchRequestString(PeerConnection sender, String keywords); // XXX: experimental for now... /** * Called when receiving search results. * * @param requestId the request id the search result belongs to * @param item a {@link TurtleSearchResultItem} subclass containing the results */ void receiveSearchResult(int requestId, TurtleSearchResultItem item); // XXX: document that only encrypted hashes are supported /** * Called when a virtual peer related to a hash is added. * * @param hash the encrypted hash * @param virtualLocation the virtual location to add * @param direction the direction of the tunnel, either {@link TunnelDirection#SERVER} or {@link TunnelDirection#CLIENT} */ void addVirtualPeer(Sha1Sum hash, Location virtualLocation, TunnelDirection direction); /** * Called when a virtual peer related to a hash is removed. * * @param hash the encrypted hash * @param virtualLocation the virtual location to remove */ void removeVirtualPeer(Sha1Sum hash, Location virtualLocation); } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleRsService.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.DatabaseSession; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.file.File; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.LocationService; import io.xeres.app.service.file.FileService; import io.xeres.app.util.expression.ExpressionMapper; import io.xeres.app.util.expression.NameExpression; import io.xeres.app.util.expression.StringExpression; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceMaster; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.turtle.item.*; import io.xeres.common.file.FileType; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.util.ExecutorUtils; import io.xeres.common.util.SecureRandomUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import static io.xeres.common.protocol.xrs.RsServiceType.TURTLE_ROUTER; /** * Implementation of the {@link TurtleRouter}. Only supports encrypted hashes. */ @Component public class TurtleRsService extends RsService implements RsServiceMaster, TurtleRouter { private static final Logger log = LoggerFactory.getLogger(TurtleRsService.class); /** * Time between tunnel management runs. */ private static final Duration TUNNEL_MANAGEMENT_DELAY = Duration.ofSeconds(2); /** * Maximum tunnel depth, that is the number of friends beyond you that are reachable. */ public static final int MAX_TUNNEL_DEPTH = 6; /** * Time between checks of empty tunnels. */ public static final Duration EMPTY_TUNNELS_DIGGING_TIME = Duration.ofSeconds(50); /** * Time between checks of normal tunnels. */ private static final Duration REGULAR_TUNNELS_DIGGING_TIME = Duration.ofMinutes(5); /** * Time between tunnels cleanup. */ private static final Duration TUNNEL_CLEANING_TIME = Duration.ofSeconds(10); /** * Time between tunnel speed estimation runs. */ private static final Duration SPEED_ESTIMATE_TIME = Duration.ofSeconds(5); /** * Maximum number of search requests allowed in the cache. */ private static final int MAX_SEARCH_REQUEST_IN_CACHE = 120; /** * Maximum number of search results forwarded by default. */ private static final int MAX_SEARCH_HITS = 100; private static final int MAX_SEARCH_REQUEST_ACCEPTED_SERIAL_SIZE = 200; private static final int MAX_SEARCH_RESPONSE_SERIAL_SIZE = 10000; /** * Maximum lifetime of unused tunnels. */ private static final Duration MAX_TUNNEL_IDLE_TIME = Duration.ofSeconds(60); /** * Lifetime of search requests in the cache. */ private static final Duration SEARCH_REQUEST_LIFETIME = Duration.ofMinutes(10); /** * Lifetime of tunnel requests in the cache. */ private static final Duration TUNNEL_REQUEST_LIFETIME = Duration.ofMinutes(10); /** * Lifetime of an ongoing search requests. Results coming after that time are dropped. */ private static final Duration SEARCH_REQUEST_TIMEOUT = Duration.ofSeconds(20); /** * Lifetime of an ongoing tunnel requests. Results coming after that time are dropped. */ private static final Duration TUNNEL_REQUEST_TIMEOUT = Duration.ofSeconds(20); private final TunnelProbability tunnelProbability = new TunnelProbability(); private final Map searchRequestsOrigins = new ConcurrentHashMap<>(); private final Map tunnelRequestsOrigins = new ConcurrentHashMap<>(); private final Map incomingHashes = new ConcurrentHashMap<>(); private final Map localTunnels = new ConcurrentHashMap<>(); private final Map virtualPeers = new ConcurrentHashMap<>(); private final Set hashesToRemove = ConcurrentHashMap.newKeySet(); private final Map outgoingTunnelClients = new ConcurrentHashMap<>(); private final List turtleClients = new ArrayList<>(); private final PeerConnectionManager peerConnectionManager; private final LocationService locationService; private final DatabaseSessionManager databaseSessionManager; private final FileService fileService; private ScheduledExecutorService executorService; private Location ownLocation; private Instant lastTunnelCleanup = Instant.EPOCH; private Instant lastSpeedEstimation = Instant.EPOCH; private TurtleStatistics turtleStatistics = new TurtleStatistics(); private final TurtleStatistics turtleStatisticsBuffer = new TurtleStatistics(); protected TurtleRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, LocationService locationService, DatabaseSessionManager databaseSessionManager, FileService fileService) { super(rsServiceRegistry); this.peerConnectionManager = peerConnectionManager; this.locationService = locationService; this.databaseSessionManager = databaseSessionManager; this.fileService = fileService; } @Override public RsServiceType getServiceType() { return TURTLE_ROUTER; } @Override public void addRsSlave(TurtleRsClient client) { turtleClients.add(client); client.initializeTurtle(this); } @Override public void initialize() { try (var ignored = new DatabaseSession(databaseSessionManager)) { ownLocation = locationService.findOwnLocation().orElseThrow(); } executorService = ExecutorUtils.createFixedRateExecutor(this::manageAll, getInitPriority().getMaxTime() + TUNNEL_MANAGEMENT_DELAY.toSeconds() / 2, TUNNEL_MANAGEMENT_DELAY.toSeconds()); } @Override public void cleanup() { ExecutorUtils.cleanupExecutor(executorService); } @Transactional @Override public void handleItem(PeerConnection sender, Item item) { switch (item) { case TurtleGenericTunnelItem turtleGenericTunnelItem -> routeGenericTunnel(sender, turtleGenericTunnelItem); case TurtleTunnelRequestItem turtleTunnelRequestItem -> handleTunnelRequest(sender, turtleTunnelRequestItem); case TurtleTunnelResultItem turtleTunnelResultItem -> handleTunnelResult(sender, turtleTunnelResultItem); case TurtleSearchRequestItem turtleSearchRequestItem -> handleSearchRequest(sender, turtleSearchRequestItem); case TurtleSearchResultItem turtleSearchResultItem -> handleSearchResult(sender, turtleSearchResultItem); default -> log.debug("Unknown item {}", item); } } @Override public void forceReDiggTunnel(Sha1Sum hash) { if (!incomingHashes.containsKey(hash)) { return; } diggTunnel(hash); } @Override public void sendTurtleData(Location virtualPeer, TurtleGenericTunnelItem item) { var tunnelId = virtualPeers.get(virtualPeer.getLocationIdentifier()); if (tunnelId == null) { log.warn("Couldn't find tunnel for virtual peer id {} when sending data", virtualPeer.getLocationIdentifier()); return; } var tunnel = localTunnels.get(tunnelId); if (tunnel == null) { log.warn("Client asked to send a packet through a tunnel that has been deleted."); return; } item.setTunnelId(tunnelId); if (item.shouldStampTunnel()) { tunnel.stamp(); } if (tunnel.getSource().equals(ownLocation)) { item.setDirection(TunnelDirection.SERVER); log.trace("Sending turtle item {} to {} (server)", item, tunnel.getDestination()); var itemFuture = peerConnectionManager.writeItem(tunnel.getDestination(), item, this); turtleStatisticsBuffer.addToDataDownload(itemFuture.getSize()); tunnel.addTransferredBytes(itemFuture.getSize()); } else if (tunnel.getDestination().equals(ownLocation)) { item.setDirection(TunnelDirection.CLIENT); log.trace("Sending turtle item {} to {} (client)", item, tunnel.getSource()); var itemFuture = peerConnectionManager.writeItem(tunnel.getSource(), item, this); turtleStatisticsBuffer.addToDataUpload(itemFuture.getSize()); tunnel.addTransferredBytes(itemFuture.getSize()); } else { log.error("Asked to send a packet into a tunnel that is not registered, dropping packet"); } } @Override public void startMonitoringTunnels(Sha1Sum hash, TurtleRsClient client, boolean allowMultiTunnels) { log.debug("Start monitoring tunnels for (encrypted) hash {}", hash); hashesToRemove.remove(hash); // if the file hash was scheduled for removal, cancel it incomingHashes.putIfAbsent(hash, new HashInfo(allowMultiTunnels, client)); } @Override public void stopMonitoringTunnels(Sha1Sum hash) { log.debug("Stop monitoring tunnels for (encrypted) hash {}", hash); hashesToRemove.add(hash); } private void routeGenericTunnel(PeerConnection sender, TurtleGenericTunnelItem item) { log.trace("Routing generic tunnel {} from {}", item, sender); var tunnel = localTunnels.get(item.getTunnelId()); if (tunnel == null) { log.error("Got item {} with unknown tunnel id {} from {}, dropping", item, item.getTunnelId(), sender); return; } if (item.shouldStampTunnel()) { tunnel.stamp(); } if (sender.getLocation().equals(tunnel.getDestination())) { item.setDirection(TunnelDirection.CLIENT); } else if (sender.getLocation().equals(tunnel.getSource())) { item.setDirection(TunnelDirection.SERVER); } else { log.error("Generic tunnel mismatch source/destination id"); return; } if (sender.getLocation().equals(tunnel.getDestination()) && !tunnel.getSource().equals(ownLocation)) { log.trace("Forwarding generic item {} to {}", item, tunnel.getSource()); var itemFuture = peerConnectionManager.writeItem(tunnel.getSource(), item.clone(), this); turtleStatisticsBuffer.addToForwardTotal(itemFuture.getSize()); tunnel.addTransferredBytes(itemFuture.getSize()); return; } if (sender.getLocation().equals(tunnel.getSource()) && !tunnel.getDestination().equals(ownLocation)) { log.trace("Forwarding generic item {} to {}", item, tunnel.getDestination()); var itemFuture = peerConnectionManager.writeItem(tunnel.getDestination(), item.clone(), this); turtleStatisticsBuffer.addToForwardTotal(itemFuture.getSize()); tunnel.addTransferredBytes(itemFuture.getSize()); return; } // Item is for us turtleStatisticsBuffer.addToDataDownload(item.getItemSize()); handleReceiveGenericTunnel(item, tunnel); } @Override public boolean isVirtualPeer(Location location) { return virtualPeers.containsKey(location.getLocationIdentifier()); } private void handleReceiveGenericTunnel(TurtleGenericTunnelItem item, Tunnel tunnel) { TurtleRsClient client = null; if (tunnel.getSource().equals(ownLocation)) { var hashInfo = incomingHashes.get(tunnel.getHash()); if (hashInfo == null) { log.warn("Hash {} for client side tunnel endpoint {} has been removed (late response?), dropping", tunnel.getHash(), item.getTunnelId()); return; } client = hashInfo.getClient(); } else if (tunnel.getDestination().equals(ownLocation)) { client = outgoingTunnelClients.get(item.getTunnelId()); if (client == null) { log.warn("Hash {} for server side tunnel endpoint {} has been removed (late response?), dropping", tunnel.getHash(), item.getTunnelId()); return; } } assert client != null; client.receiveTurtleData(item, tunnel.getHash(), tunnel.getVirtualLocation(), item.getDirection()); } private void handleTunnelRequest(PeerConnection sender, TurtleTunnelRequestItem item) { log.trace("Received tunnel request from peer {}: {}", sender, item); turtleStatisticsBuffer.addToTunnelRequestsDownload(item.getItemSize()); // RS sometimes sends null (0000...) hashes if (item.getHash() == null) { log.debug("Null hash in tunnel request, dropping..."); return; } if (isBanned(item.getHash())) { log.debug("Rejecting banned file hash {}", item.getHash()); return; } if (tunnelRequestsOrigins.putIfAbsent(item.getRequestId(), new TunnelRequest(sender.getLocation(), item.getDepth())) != null) { // This can happen when the same tunnel request is relayed by different peers. // Simply drop it. log.debug("Requests {} already exists", item.getRequestId()); return; } Optional clientWithSearchResult = Optional.empty(); // If it's not from us, perform a local search. if (!sender.getLocation().equals(ownLocation)) { clientWithSearchResult = turtleClients.stream() .filter(turtleRsClient -> turtleRsClient.handleTunnelRequest(sender, item.getHash())) .findFirst(); } // If a client found something, send the search result back. if (clientWithSearchResult.isPresent()) { var tunnelId = item.getPartialTunnelId() ^ generatePersonalFilePrint(item.getHash(), tunnelProbability.getBias(), false); log.debug("Honoring tunnel request from peer {}: {}. generated tunnel id: {}", sender, item, tunnelId); var resultItem = new TurtleTunnelResultItem(tunnelId, item.getRequestId()); var tunnel = new Tunnel(tunnelId, sender.getLocation(), ownLocation, item.getHash()); localTunnels.put(tunnelId, tunnel); virtualPeers.put(tunnel.getVirtualLocation().getLocationIdentifier(), tunnelId); outgoingTunnelClients.put(tunnelId, clientWithSearchResult.get()); peerConnectionManager.writeItem(sender, resultItem, this); clientWithSearchResult.get().addVirtualPeer(item.getHash(), tunnel.getVirtualLocation(), TunnelDirection.CLIENT); return; } // Perturb the partial tunnel id so that: // - the tunnel id is unique for a given route // - better balance of bandwidth for a given transfer // - avoids the waste of items that get lost when re-routing a tunnel item.setPartialTunnelId(generatePersonalFilePrint(item.getHash(), item.getPartialTunnelId() ^ tunnelProbability.getBias(), true)); if (tunnelProbability.isForwardable(item)) { var probability = tunnelProbability.getForwardingProbability( item, turtleStatistics.getTunnelRequestsUpload(), turtleStatistics.getTunnelRequestsDownload(), peerConnectionManager.getNumberOfPeers());// XXX: there's a difference with RS here, it's the number of peers USING the turtle service. do we care? peerConnectionManager.doForAllPeersExceptSender(peerConnection -> { var itemToSend = item.clone(); tunnelProbability.incrementDepth(itemToSend); if (SecureRandomUtils.nextDouble() <= probability) { var itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this); turtleStatisticsBuffer.addToTunnelRequestsUpload(itemFuture.getSize()); } }, sender, this); } } private void handleTunnelResult(PeerConnection sender, TurtleTunnelResultItem item) { log.debug("Got tunnel result from {}: {}", sender, item); var tunnelRequest = tunnelRequestsOrigins.get(item.getRequestId()); if (tunnelRequest == null) { log.warn("Tunnel result has no peer direction."); return; } if (tunnelRequest.hasResponseAlready(item.getTunnelId())) { log.error("Received a tunnel response twice. This should not happen."); return; } else { tunnelRequest.addResponse(item.getTunnelId()); } // Transitive tunnel var tunnel = localTunnels.computeIfAbsent(item.getTunnelId(), tunnelId -> new Tunnel(tunnelId, tunnelRequest.getSource(), sender.getLocation(), null)); if (Duration.between(tunnelRequest.getLastUsed(), Instant.now()).compareTo(TUNNEL_REQUEST_TIMEOUT) > 0) { log.warn("Tunnel request is known but the tunnel result arrived too late, dropping"); return; } // Check if it's for ourselves if (tunnelRequest.getSource().equals(ownLocation)) { var hashInfo = findHashInfoByRequest(item.getRequestId()); hashInfo.ifPresent(hInfo -> { hInfo.getValue().addTunnel(item.getTunnelId()); // Local tunnel tunnel.setHash(hInfo.getKey()); virtualPeers.put(tunnel.getVirtualLocation().getLocationIdentifier(), item.getTunnelId()); hInfo.getValue().getClient().addVirtualPeer(hInfo.getKey(), tunnel.getVirtualLocation(), TunnelDirection.SERVER); }); } else { // Forward the result back to its source peerConnectionManager.writeItem(tunnelRequest.getSource(), new TurtleTunnelResultItem(item.getTunnelId(), item.getRequestId()), this); } } private Optional> findHashInfoByRequest(int requestId) { return incomingHashes.entrySet().stream() .filter(entry -> entry.getValue().getLastRequest() == requestId) .findFirst(); } int generatePersonalFilePrint(Sha1Sum hash, int bias, boolean symmetrical) { var buf = hash.toString() + ownLocation.getLocationIdentifier().toString(); int result = bias; var decal = 0; for (var i = 0; i < buf.length(); i++) { result += (int) (7 * buf.charAt(i) + Integer.toUnsignedLong(decal)); if (symmetrical) { decal = (int) (Integer.toUnsignedLong(decal) * 44497 + 15641 + (Integer.toUnsignedLong(result) % 86243)); } else { decal = (int) (Integer.toUnsignedLong(decal) * 86243 + 15649 + (Integer.toUnsignedLong(result) % 44497)); } } return result; } private void handleSearchRequest(PeerConnection sender, TurtleSearchRequestItem item) { log.debug("Received search request from peer {}: {}", sender, item); var itemSize = item.getItemSize(); turtleStatisticsBuffer.addToSearchRequestsDownload(itemSize); if (itemSize > MAX_SEARCH_REQUEST_ACCEPTED_SERIAL_SIZE) { log.warn("Got an arbitrary large size from {} of size {} and depth {}. Dropping", sender, itemSize, item.getDepth()); return; } if (searchRequestsOrigins.size() > MAX_SEARCH_REQUEST_IN_CACHE) // XXX: no expiration for those?? { log.debug("Request cache is full. Check if a peer is flooding."); return; } var searchResults = performLocalSearch(item, MAX_SEARCH_HITS); searchResults.forEach(turtleSearchResultItem -> { turtleSearchResultItem.setRequestId(item.getRequestId()); peerConnectionManager.writeItem(sender, turtleSearchResultItem, this); }); var searchRequest = new SearchRequest(sender.getLocation(), item.getDepth(), item.getKeywords(), searchResults.size(), MAX_SEARCH_HITS); if (searchRequestsOrigins.putIfAbsent(item.getRequestId(), searchRequest) != null) { log.debug("Request {} already in cache", item.getRequestId()); return; } // XXX: experimental turtleClients.forEach(turtleRsClient -> turtleRsClient.receiveSearchRequestString(sender, item.getKeywords())); // Do not search further if enough has been sent back already. if (searchRequest.isFull()) { return; } if (tunnelProbability.isForwardable(item)) { peerConnectionManager.doForAllPeersExceptSender(peerConnection -> { var itemToSend = item.clone(); tunnelProbability.incrementDepth(itemToSend); var itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this); turtleStatisticsBuffer.addToSearchRequestsUpload(itemFuture.getSize()); }, sender, this); } } @Override public int turtleSearch(String search, TurtleRsClient client) // XXX: put a size limit there in the search string length... { var id = SecureRandomUtils.nextInt(); TurtleFileSearchRequestItem item; // "foobar" -> exact search if (search.startsWith("\"") && search.endsWith("\"")) { search = search.substring(1, search.length() - 1); item = new TurtleStringSearchRequestItem(search); } else if (search.contains(" ")) // The Stuff -> search all terms, in this case "Stuff, The" is a match { var nameExpression = new NameExpression(StringExpression.Operator.CONTAINS_ALL, search, false); item = ExpressionMapper.toItem(List.of(nameExpression)); } else // One word is just a string search { item = new TurtleStringSearchRequestItem(search); } item.setRequestId(id); var request = new SearchRequest(client, ownLocation, 0, search, 0, MAX_SEARCH_HITS); searchRequestsOrigins.put(id, request); peerConnectionManager.doForAllPeers(peerConnection -> { var itemToSend = item.clone(); var itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this); turtleStatisticsBuffer.addToSearchRequestsUpload(itemFuture.getSize()); }, this); return id; } private List performLocalSearch(TurtleSearchRequestItem item, int maxHits) { List results = new ArrayList<>(); if (item instanceof TurtleFileSearchRequestItem fileSearchItem) { log.debug("Received file search: {}, subclass: {}", fileSearchItem.getKeywords(), fileSearchItem.getClass().getSimpleName()); return mapResults(searchFiles(fileSearchItem).stream() .filter(this::isSearchable) .limit(maxHits) .sorted(Comparator.comparing(File::getModified).reversed()) // Get the most recents first .map(file -> new TurtleFileInfo(file.getName(), file.getHash(), file.getSize())) .toList()); } else if (item instanceof TurtleGenericSearchRequestItem genericSearchRequestItem) { log.debug("Received generic search: {}", genericSearchRequestItem.getKeywords()); // XXX: generic search } return results; } private List searchFiles(TurtleFileSearchRequestItem turtleFileSearchRequestItem) { return switch (turtleFileSearchRequestItem) { case TurtleStringSearchRequestItem item -> fileService.searchFiles(item.getKeywords()); case TurtleRegExpSearchRequestItem item -> fileService.searchFiles(item.getExpressions()); default -> throw new IllegalStateException("Unexpected value: " + turtleFileSearchRequestItem); }; } private boolean isSearchable(File file) { if (file.getType() == FileType.DIRECTORY) { return false; } var share = fileService.findShareForFile(file).orElseThrow(() -> new IllegalStateException("File " + file + " is not in any share. Shouldn't happen.")); return share.isSearchable(); } private static List mapResults(List fileInfos) { List results = new ArrayList<>(); TurtleFileSearchResultItem item = null; var fileInfoSize = 0; for (TurtleFileInfo fileInfo : fileInfos) { if (item == null) { item = new TurtleFileSearchResultItem(); results.add(item); fileInfoSize = 0; } item.addFileInfo(fileInfo); fileInfoSize += fileInfo.getSize(); if (fileInfoSize > MAX_SEARCH_RESPONSE_SERIAL_SIZE) { item = null; } } return results; } private void handleSearchResult(PeerConnection sender, TurtleSearchResultItem item) { log.debug("Received search result from peer {}: {}", sender, item); //noinspection StatementWithEmptyBody if (item instanceof TurtleFileSearchResultItem _) { // XXX: remove all the isBanned() files from the result set } var searchRequest = searchRequestsOrigins.get(item.getRequestId()); if (searchRequest == null) { log.warn("Search result for request {} doesn't exist in the cache", item); return; } if (Duration.between(searchRequest.getLastUsed(), Instant.now()).compareTo(SEARCH_REQUEST_TIMEOUT) > 0) { log.debug("Search result arrived too late, dropping..."); return; } if (searchRequest.getSource().equals(ownLocation)) { log.debug("Search result is for us, forwarding to right service..."); searchRequest.addResultCount(item.getCount()); searchRequest.getClient().receiveSearchResult(item.getRequestId(), item); return; } if (searchRequest.isFull()) { log.warn("Exceeded turtle search result to forward. Request {} already forwarded: {}, max allowed: {}, dropping item with {} elements...", item.getRequestId(), searchRequest.getResultCount(), searchRequest.getHitLimit(), item.getCount()); return; } // Update the count and make sure we don't exceed the limit before forwarding searchRequest.addResultCount(item.getCount()); if (searchRequest.isFull()) { item.trim(searchRequest.getHitLimit()); } // Forward the item to origin peerConnectionManager.writeItem(searchRequest.getSource(), item.clone(), this); } private static boolean isBanned(Sha1Sum hash) { return false; // TODO: implement } private void manageAll() { manageTunnels(); computeTrafficInformation(); cleanTunnelsIfNeeded(); estimateSpeedIfNeeded(); } private void manageTunnels() { var now = Instant.now(); incomingHashes.entrySet().stream() .filter(entry -> { var hashInfo = entry.getValue(); var totalSpeed = hashInfo.getTunnels().stream() .mapToDouble(tunnelId -> localTunnels.get(tunnelId).getSpeedBps()) .sum(); var tunnelKeepingFactor = (Math.max(1.0, totalSpeed / (50 * 1024)) - 1.0) + 1.0; return ((!hashInfo.hasTunnels() && Duration.between(hashInfo.getLastDiggTime(), now).compareTo(EMPTY_TUNNELS_DIGGING_TIME) > 0) || (hashInfo.isAggressiveMode() && Duration.between(hashInfo.getLastDiggTime(), now).compareTo(Duration.ofSeconds((long) (REGULAR_TUNNELS_DIGGING_TIME.toSeconds() * tunnelKeepingFactor))) > 0)); }) .sorted(Comparator.comparing(entry -> entry.getValue().getLastDiggTime())) .map(Map.Entry::getKey) .findFirst() // Digg at most 1 tunnel each 2 seconds .ifPresent(this::diggTunnel); } private void computeTrafficInformation() { turtleStatistics = turtleStatistics.multiply(0.9f).add(turtleStatisticsBuffer.multiply(0.1f / TUNNEL_MANAGEMENT_DELAY.toSeconds())); turtleStatisticsBuffer.reset(); } private void diggTunnel(Sha1Sum hash) { var requestId = SecureRandomUtils.nextInt(); log.debug("Digging tunnel for hash {}, requestId: {}", hash, requestId); var hashInfo = incomingHashes.get(hash); hashInfo.setLastRequest(requestId); hashInfo.setLastDiggTime(Instant.now()); var item = new TurtleTunnelRequestItem(hash, requestId, generatePersonalFilePrint(hash, tunnelProbability.getBias(), true)); tunnelRequestsOrigins.put(item.getRequestId(), new TunnelRequest(ownLocation, item.getDepth())); peerConnectionManager.doForAllPeers(peerConnection -> { var itemToSend = item.clone(); var itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this); turtleStatisticsBuffer.addToTunnelRequestsUpload(itemFuture.getSize()); }, this); } private void cleanTunnelsIfNeeded() { var now = Instant.now(); if (Duration.between(lastTunnelCleanup, now).compareTo(TUNNEL_CLEANING_TIME) <= 0) { return; } lastTunnelCleanup = now; var virtualPeersToRemove = new HashMap>(); // Hashes marked for removal hashesToRemove.stream() .map(incomingHashes::remove) .filter(Objects::nonNull) .map(HashInfo::getTunnels) .forEach(tunnelId -> tunnelId.forEach(id -> closeTunnel(id, virtualPeersToRemove))); hashesToRemove.clear(); // Search requests searchRequestsOrigins.entrySet().removeIf(entry -> Duration.between(entry.getValue().getLastUsed(), now).compareTo(SEARCH_REQUEST_LIFETIME) > 0); // Tunnel requests tunnelRequestsOrigins.entrySet().removeIf(entry -> Duration.between(entry.getValue().getLastUsed(), now).compareTo(TUNNEL_REQUEST_LIFETIME) > 0); // Tunnels localTunnels.entrySet().stream() .filter(entry -> Duration.between(entry.getValue().getLastUsed(), now).compareTo(MAX_TUNNEL_IDLE_TIME) > 0) .forEach(entry -> closeTunnel(entry.getKey(), virtualPeersToRemove)); // Remove all the virtual peer ids from the clients virtualPeersToRemove.forEach((client, entry) -> client.removeVirtualPeer(entry.getKey(), entry.getValue())); } private void closeTunnel(int id, Map> sourcesToRemove) { log.debug("Closing tunnel {}", id); var tunnel = localTunnels.remove(id); if (tunnel == null) { log.error("Cannot close tunnel {} because it doesn't exist", id); return; } if (tunnel.getSource().equals(ownLocation)) { // This is a starting tunnel. // Remove the virtual peer from the virtual peers list virtualPeers.remove(tunnel.getVirtualLocation().getLocationIdentifier()); // Remove the tunnel id from the file hash Optional.ofNullable(incomingHashes.get(tunnel.getHash())).ifPresent(hashInfo -> { hashInfo.removeTunnel(id); sourcesToRemove.put(hashInfo.getClient(), new AbstractMap.SimpleEntry<>(tunnel.getHash(), tunnel.getVirtualLocation())); }); } else if (tunnel.getDestination().equals(ownLocation)) { // This is an ending tunnel. var client = outgoingTunnelClients.remove(id); if (client != null) { sourcesToRemove.put(client, new AbstractMap.SimpleEntry<>(tunnel.getHash(), tunnel.getVirtualLocation())); // Remove associated virtual peers virtualPeers.remove(tunnel.getVirtualLocation().getLocationIdentifier()); } } } private void estimateSpeedIfNeeded() { var now = Instant.now(); if (Duration.between(lastSpeedEstimation, now).compareTo(SPEED_ESTIMATE_TIME) <= 0) { return; } lastSpeedEstimation = now; localTunnels.forEach((_, tunnel) -> { var speedEstimate = tunnel.getTransferredBytes() / (double) SPEED_ESTIMATE_TIME.toSeconds(); tunnel.setSpeedBps(0.75 * tunnel.getSpeedBps() + 0.25 * speedEstimate); tunnel.clearTransferredBytes(); }); } public TurtleStatistics getStatistics() { return turtleStatistics.getStatistics(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleStatistics.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; /** * Records statistics for Turtle. *

* Everything is in bytes per seconds. */ public class TurtleStatistics { private float forwardTotal; private float dataUpload; private float dataDownload; private float tunnelRequestsUpload; private float tunnelRequestsDownload; private float searchRequestsUpload; private float searchRequestsDownload; private float totalUpload; private float totalDownload; public TurtleStatistics() { } private TurtleStatistics(TurtleStatistics from) { forwardTotal = from.forwardTotal; dataUpload = from.dataUpload; dataDownload = from.dataDownload; tunnelRequestsUpload = from.tunnelRequestsUpload; tunnelRequestsDownload = from.tunnelRequestsDownload; searchRequestsUpload = from.searchRequestsUpload; searchRequestsDownload = from.searchRequestsDownload; totalUpload = from.totalUpload; totalDownload = from.totalDownload; } public synchronized void reset() { forwardTotal = 0.0f; dataUpload = 0.0f; dataDownload = 0.0f; tunnelRequestsUpload = 0.0f; tunnelRequestsDownload = 0.0f; searchRequestsUpload = 0.0f; searchRequestsDownload = 0.0f; totalUpload = 0.0f; totalDownload = 0.0f; } public synchronized TurtleStatistics multiply(float number) { var result = new TurtleStatistics(this); result.forwardTotal *= number; result.dataUpload *= number; result.dataDownload *= number; result.tunnelRequestsUpload *= number; result.tunnelRequestsDownload *= number; result.searchRequestsUpload *= number; result.searchRequestsDownload *= number; result.totalUpload *= number; result.totalDownload *= number; return result; } public synchronized TurtleStatistics add(float number) { var result = new TurtleStatistics(this); result.forwardTotal += number; result.dataUpload += number; result.dataDownload += number; result.tunnelRequestsUpload += number; result.tunnelRequestsDownload += number; result.searchRequestsUpload += number; result.searchRequestsDownload += number; result.totalUpload += number; result.totalDownload += number; return result; } public synchronized TurtleStatistics add(TurtleStatistics other) { var result = new TurtleStatistics(this); result.forwardTotal += other.forwardTotal; result.dataUpload += other.dataUpload; result.dataDownload += other.dataDownload; result.tunnelRequestsUpload += other.tunnelRequestsUpload; result.tunnelRequestsDownload += other.tunnelRequestsDownload; result.searchRequestsUpload += other.searchRequestsUpload; result.searchRequestsDownload += other.searchRequestsDownload; result.totalUpload += other.totalUpload; result.totalDownload += other.totalDownload; return result; } public synchronized void addToTunnelRequestsDownload(int size) { tunnelRequestsDownload += size; } public synchronized void addToTunnelRequestsUpload(int size) { tunnelRequestsUpload += size; } public synchronized void addToSearchRequestsDownload(int size) { searchRequestsDownload += size; } public synchronized void addToSearchRequestsUpload(int size) { searchRequestsUpload += size; } public synchronized void addToForwardTotal(int size) { forwardTotal += size; } public synchronized void addToDataDownload(int size) { dataDownload += size; } public synchronized void addToDataUpload(int size) { dataUpload += size; } public float getForwardTotal() { return forwardTotal; } public float getDataUpload() { return dataUpload; } public float getDataDownload() { return dataDownload; } public float getTunnelRequestsUpload() { return tunnelRequestsUpload; } public float getTunnelRequestsDownload() { return tunnelRequestsDownload; } public float getSearchRequestsUpload() { return searchRequestsUpload; } public float getSearchRequestsDownload() { return searchRequestsDownload; } public float getTotalUpload() { return totalUpload; } public float getTotalDownload() { return totalDownload; } public TurtleStatistics getStatistics() { return new TurtleStatistics(this); } @Override public synchronized String toString() { return "TrafficStatistics [forwardTotal=" + forwardTotal + ", dataUpload=" + dataUpload + ", dataDownload=" + dataDownload + ", tunnelRequestsUpload=" + tunnelRequestsUpload + ", tunnelRequestsDownload=" + tunnelRequestsDownload + ", searchRequestsUpload=" + searchRequestsUpload + ", searchRequestsDownload=" + searchRequestsDownload + ", totalUpload=" + totalUpload + ", totalDownload=" + totalDownload + "]"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/VirtualLocation.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.model.location.Location; import io.xeres.common.id.LocationIdentifier; /** * Handles Virtual Locations, which are "distant" locations in the Turtle network (it could be your direct peer to, it's impossible to know). */ final class VirtualLocation { private VirtualLocation() { throw new UnsupportedOperationException("Utility class"); } /** * Creates a virtual location out of a tunnel id. *

* A virtual location performs more or less like a normal location. * * @param tunnelId the tunnel id * @return a virtual location */ public static Location fromTunnel(int tunnelId) { var buf = new byte[LocationIdentifier.LENGTH]; for (var i = 0; i < 4; i++) { buf[i] = (byte) ((tunnelId >> ((3 - i) * 8)) & 0xff); } return Location.createLocation("TurtleVirtualLocation", new LocationIdentifier(buf)); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/doc-files/search.puml ================================================ @startuml 'https://plantuml.com/class-diagram abstract class TurtleSearchRequestItem { int requestId; short depth; int getRequestId(); short getDepth(); } abstract class TurtleFileSearchRequestItem { String getKeywords(); } abstract class TurtleSearchResultItem { int requestId; short depth; getRequestId(); } class TurtleStringSearchRequestItem { String search; String getSearch(); } class TurtleRegExpSearchRequestItem { List tokens; List ints; List strings; String getKeywords() } class TurtleGenericSearchRequestItem { short serviceId; byte requestType; byte[] searchData; String getKeywords(); } class TurtleFileSearchResultItem { List results; List getResults(); } class TurtleGenericSearchResultItem { byte[] searchData; byte[] getSearchData(); } TurtleSearchRequestItem <|-- TurtleFileSearchRequestItem TurtleSearchRequestItem <|-- TurtleGenericSearchRequestItem TurtleFileSearchRequestItem <|-- TurtleStringSearchRequestItem TurtleFileSearchRequestItem <|-- TurtleRegExpSearchRequestItem TurtleSearchResultItem <|-- TurtleFileSearchResultItem TurtleSearchResultItem <|-- TurtleGenericSearchResultItem @enduml ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TunnelDirection.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; /** * The direction of the tunnel. Either {@link #CLIENT} or {@link #SERVER}. * If for example a packet has "client" set, then it means whoever sent it is a client. */ public enum TunnelDirection { /** * A client, For example when downloading a file from a remote node or when we started a distant chat. */ CLIENT, /** * A server, for example when serving a file to a remote node or receiving a distant chat. */ SERVER } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleFileInfo.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static io.xeres.app.xrs.serialization.TlvType.STR_NAME; /** * The representation of a file by turtle. */ public class TurtleFileInfo { @RsSerialized private long fileSize; @RsSerialized private Sha1Sum fileHash; @RsSerialized(tlvType = STR_NAME) private String fileName; public TurtleFileInfo() { // Needed } public TurtleFileInfo(String fileName, Sha1Sum fileHash, long fileSize) { this.fileName = fileName; this.fileHash = fileHash; this.fileSize = fileSize; } public long getFileSize() { return fileSize; } public Sha1Sum getFileHash() { return fileHash; } public String getFileName() { return fileName; } public int getSize() { return Long.BYTES + Sha1Sum.LENGTH + TLV_HEADER_SIZE + fileName.length(); } @Override public String toString() { return "TurtleFileInfo{" + "fileSize=" + fileSize + ", fileHash=" + fileHash + ", fileName='" + fileName + '\'' + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleFileSearchRequestItem.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; /** * The superclass of all file search requests. */ public abstract class TurtleFileSearchRequestItem extends TurtleSearchRequestItem { @Override public String getKeywords() { return ""; } @Override public TurtleFileSearchRequestItem clone() { return (TurtleFileSearchRequestItem) super.clone(); } @Override public String toString() { return "TurtleFileSearchRequestItem{}"; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleFileSearchResultItem.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.serialization.RsSerialized; import java.util.ArrayList; import java.util.List; /** * Used to provide the results of a file search. */ public class TurtleFileSearchResultItem extends TurtleSearchResultItem { @RsSerialized private List results = new ArrayList<>(); public TurtleFileSearchResultItem() { // Needed } @Override public int getSubType() { return 2; } @Override public int getCount() { return results.size(); } @Override public void trim(int size) { if (size < results.size()) { results = results.subList(0, size); } } public List getResults() { return results; } public void addFileInfo(TurtleFileInfo fileInfo) { results.add(fileInfo); } @Override public TurtleFileSearchResultItem clone() { return (TurtleFileSearchResultItem) super.clone(); } @Override public String toString() { return "TurtleFileSearchResultItem{" + "results=" + results + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericDataItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.serialization.RsSerialized; /** * Used by any service to pass on arbitrary data into a tunnel. */ public class TurtleGenericDataItem extends TurtleGenericTunnelItem { /** * The data. */ @RsSerialized private byte[] tunnelData; public TurtleGenericDataItem() { // Required } public TurtleGenericDataItem(byte[] data) { tunnelData = data; } @Override public int getSubType() { return 10; } @Override public boolean shouldStampTunnel() { return true; } public byte[] getTunnelData() { return tunnelData; } @Override public String toString() { return "TurtleGenericDataItem{" + "tunnelData.length=" + (tunnelData == null ? "[null]" : tunnelData.length) + '}'; } @Override public TurtleGenericDataItem clone() { return (TurtleGenericDataItem) super.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericFastDataItem.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.item.ItemPriority; /** * Used by any service to pass on arbitrary data into a tunnel. *

* Same as {@link TurtleGenericDataItem} but with a fast priority. Can be * used for example by distant chat. */ public class TurtleGenericFastDataItem extends TurtleGenericDataItem { public TurtleGenericFastDataItem() { // Required } public TurtleGenericFastDataItem(byte[] data) { super(data); } @Override public int getSubType() { return 22; } @Override public int getPriority() { return ItemPriority.INTERACTIVE.getPriority(); } @Override public String toString() { return "TurtleGenericFastDataItem{" + "tunnelData.length=" + (getTunnelData() == null ? "[null]" : getTunnelData().length) + '}'; } @Override public TurtleGenericFastDataItem clone() { return (TurtleGenericFastDataItem) super.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericSearchRequestItem.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.serialization.RsSerialized; /** * Used to do searches in a generic way. */ public class TurtleGenericSearchRequestItem extends TurtleSearchRequestItem { /** * The service to search. */ @RsSerialized private short serviceId; /** * The type of request. This is used to limite the number of responses. */ @RsSerialized private byte requestType; @RsSerialized private byte[] searchData; // XXX: not sure that's correct... @Override public String getKeywords() { return ""; // XXX: how to do that? } @SuppressWarnings("unused") public TurtleGenericSearchRequestItem() { } @Override public int getSubType() { return 11; } public short getServiceId() { return serviceId; } public byte getRequestType() { return requestType; } public byte[] getSearchData() { return searchData; } @Override public String toString() { return "TurtleGenericSearchRequestItem{" + "requestId=" + getRequestId() + ", depth=" + getDepth() + ", serviceId=" + serviceId + ", requestType=" + requestType + '}'; } @Override public TurtleGenericSearchRequestItem clone() { return (TurtleGenericSearchRequestItem) super.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericSearchResultItem.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.serialization.RsSerialized; import java.util.Arrays; /** * Used to provide a result for a generic search. */ public class TurtleGenericSearchResultItem extends TurtleSearchResultItem { @RsSerialized private byte[] searchData; // XXX: not sure it's the right data type @SuppressWarnings("unused") public TurtleGenericSearchResultItem() { } @Override public int getSubType() { return 12; } public byte[] getSearchData() { return searchData; } @Override public int getCount() { return searchData.length / 50; // XXX: this is an estimate... probably wrong } @Override public void trim(int size) { // XXX: implement? } @Override public TurtleGenericSearchResultItem clone() { return (TurtleGenericSearchResultItem) super.clone(); } @Override public String toString() { return "TurtleGenericSearchResultItem{" + "searchData=" + Arrays.toString(searchData) + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericTunnelItem.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; /** * The superclass of generic turtle packets. */ public abstract class TurtleGenericTunnelItem extends Item { /** * The tunnel id. */ @RsSerialized private int tunnelId; /** * The direction of the tunnel (client or server). This field is optional and only used * by the implementation if needed. */ private TunnelDirection direction; public abstract boolean shouldStampTunnel(); @Override public int getServiceType() { return RsServiceType.TURTLE_ROUTER.getType(); } @Override public int getPriority() { return ItemPriority.NORMAL.getPriority(); } public int getTunnelId() { return tunnelId; } public void setTunnelId(int tunnelId) { this.tunnelId = tunnelId; } public TunnelDirection getDirection() { return direction; } public void setDirection(TunnelDirection direction) { this.direction = direction; } @Override public TurtleGenericTunnelItem clone() { return (TurtleGenericTunnelItem) super.clone(); } @Override public String toString() { return "TurtleGenericTunnelItem{" + "tunnelId=" + tunnelId + ", direction=" + direction + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleRegExpSearchRequestItem.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.netty.buffer.ByteBuf; import io.xeres.app.util.expression.Expression; import io.xeres.app.util.expression.ExpressionMapper; import io.xeres.app.xrs.serialization.RsSerializable; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.serialization.Serializer; import io.xeres.app.xrs.serialization.TlvType; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.stream.Collectors; /** * Used to do a regexp search for a file. */ public class TurtleRegExpSearchRequestItem extends TurtleFileSearchRequestItem implements RsSerializable { private static final int MAX_TOKENS_LIMIT = 256; private List tokens; private List ints; private List strings; private String keywords; // Not serialized private List expressions; // Not serialized @SuppressWarnings("unused") public TurtleRegExpSearchRequestItem() { } public TurtleRegExpSearchRequestItem(List tokens, List ints, List strings) { this.tokens = tokens; this.ints = ints; this.strings = strings; } @Override public int getSubType() { return 9; } public List getTokens() { return tokens; } public List getInts() { return ints; } public List getStrings() { return strings; } public List getExpressions() { buildExpressionsIfNeeded(); return expressions; } @Override public String getKeywords() { if (keywords == null) { buildExpressionsIfNeeded(); keywords = expressions.stream() .map(Object::toString) .collect(Collectors.joining(" ")); } return keywords; } private void buildExpressionsIfNeeded() { if (expressions == null) { expressions = ExpressionMapper.toExpressions(this); } } @Override public String toString() { return "TurtleRegExpSearchRequestItem{" + "requestId=" + getRequestId() + ", depth=" + getDepth() + '}'; } @Override public TurtleRegExpSearchRequestItem clone() { return (TurtleRegExpSearchRequestItem) super.clone(); } @Override public int writeObject(ByteBuf buf, Set serializationFlags) { var size = 0; size += Serializer.serializeAnnotatedFields(buf, this); size += Serializer.serialize(buf, tokens.size()); size += tokens.stream() .mapToInt(value -> Serializer.serialize(buf, value)) .sum(); size += Serializer.serialize(buf, ints.size()); size += ints.stream() .mapToInt(value -> Serializer.serialize(buf, value)) .sum(); size += Serializer.serialize(buf, strings.size()); size += strings.stream() .mapToInt(value -> Serializer.serialize(buf, TlvType.STR_VALUE, value)) .sum(); return size; } @Override public void readObject(ByteBuf buf) { Serializer.deserializeAnnotatedFields(buf, this); var length = validateTokenLimit(Serializer.deserializeInt(buf)); tokens = new ArrayList<>(length); for (var i = 0; i < length; i++) { tokens.add(Serializer.deserializeByte(buf)); } length = validateTokenLimit(Serializer.deserializeInt(buf)); ints = new ArrayList<>(length); for (var i = 0; i < length; i++) { ints.add(Serializer.deserializeInt(buf)); } length = validateTokenLimit(Serializer.deserializeInt(buf)); strings = new ArrayList<>(length); for (var i = 0; i < length; i++) { strings.add((String) Serializer.deserialize(buf, TlvType.STR_VALUE)); } } private static int validateTokenLimit(int size) { if (size >= MAX_TOKENS_LIMIT) { throw new IllegalArgumentException("Maximum search tokens exceeded"); } return size; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleSearchRequestItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; /** * The superclass of all search request items. *

* Search class diagram */ public abstract class TurtleSearchRequestItem extends Item { @RsSerialized private int requestId; @RsSerialized private short depth; public abstract String getKeywords(); @Override public int getServiceType() { return RsServiceType.TURTLE_ROUTER.getType(); } @Override public int getPriority() { return ItemPriority.HIGH.getPriority(); } public int getRequestId() { return requestId; } public void setRequestId(int requestId) { this.requestId = requestId; } public short getDepth() { return depth; } public void setDepth(short depth) { this.depth = depth; } @Override public TurtleSearchRequestItem clone() { return (TurtleSearchRequestItem) super.clone(); } @Override public String toString() { return "TurtleSearchRequestItem{" + "requestId=" + requestId + ", depth=" + depth + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleSearchResultItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; /** * The superclass of all search result items. */ public abstract class TurtleSearchResultItem extends Item { @RsSerialized private int requestId; @SuppressWarnings("unused") @RsSerialized private short depth; // Always set to 0, not used public abstract int getCount(); public abstract void trim(int size); @Override public int getServiceType() { return RsServiceType.TURTLE_ROUTER.getType(); } public int getRequestId() { return requestId; } public void setRequestId(int requestId) { this.requestId = requestId; } @Override public TurtleSearchResultItem clone() { return (TurtleSearchResultItem) super.clone(); } @Override public String toString() { return "TurtleSearchResultItem{" + "requestId=" + requestId + ", depth=" + depth + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleStringSearchRequestItem.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.serialization.RsClassSerializedReversed; import io.xeres.app.xrs.serialization.RsSerialized; import static io.xeres.app.xrs.serialization.TlvType.STR_VALUE; /** * Used to do a string search for a file. */ @RsClassSerializedReversed public class TurtleStringSearchRequestItem extends TurtleFileSearchRequestItem { /** * The keywords to search for. Separated by spaces. */ @RsSerialized(tlvType = STR_VALUE) private String keywords; @SuppressWarnings("unused") public TurtleStringSearchRequestItem() { } public TurtleStringSearchRequestItem(String keywords) { this.keywords = keywords; } @Override public int getSubType() { return 1; } @Override public String getKeywords() { return keywords; } @Override public String toString() { return "TurtleStringSearchRequestItem{" + "search='" + keywords + '\'' + ", requestId=" + getRequestId() + ", depth=" + getDepth() + '}'; } @Override public TurtleStringSearchRequestItem clone() { return (TurtleStringSearchRequestItem) super.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleTunnelRequestItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.id.Sha1Sum; import io.xeres.common.protocol.xrs.RsServiceType; /** * Used for opening a tunnel. */ public class TurtleTunnelRequestItem extends Item { /** * Hash to match. */ @RsSerialized private Sha1Sum hash; /** * Randomly generated request id. */ @RsSerialized private int requestId; /** * Incomplete tunnel id that will be completed at destination. */ @RsSerialized private int partialTunnelId; /** * Used for limiting the search depth. */ @RsSerialized private short depth; @SuppressWarnings("unused") public TurtleTunnelRequestItem() { } public TurtleTunnelRequestItem(Sha1Sum hash, int requestId, int partialTunnelId) { this.hash = hash; this.requestId = requestId; this.partialTunnelId = partialTunnelId; } @Override public int getServiceType() { return RsServiceType.TURTLE_ROUTER.getType(); } @Override public int getSubType() { return 3; } public Sha1Sum getHash() { return hash; } public int getRequestId() { return requestId; } public int getPartialTunnelId() { return partialTunnelId; } public void setPartialTunnelId(int partialTunnelId) { this.partialTunnelId = partialTunnelId; } public short getDepth() { return depth; } public void setDepth(short depth) { this.depth = depth; } @Override public String toString() { return "TurtleTunnelOpenItem{" + "fileHash=" + hash + ", requestId=" + requestId + ", partialTunnelId=" + partialTunnelId + ", depth=" + depth + '}'; } @Override public TurtleTunnelRequestItem clone() { return (TurtleTunnelRequestItem) super.clone(); } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleTunnelResultItem.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; /** * Used for acknowledging that a tunnel has been opened. */ public class TurtleTunnelResultItem extends Item { /** * The id of the tunnel. Should be identical for a tunnel between two same peers for the same hash. */ @RsSerialized private int tunnelId; /** * Randomly generated request id corresponding to the initial request. */ @RsSerialized private int requestId; @SuppressWarnings("unused") public TurtleTunnelResultItem() { } public TurtleTunnelResultItem(int tunnelId, int requestId) { this.tunnelId = tunnelId; this.requestId = requestId; } @Override public int getServiceType() { return RsServiceType.TURTLE_ROUTER.getType(); } @Override public int getSubType() { return 4; } public int getTunnelId() { return tunnelId; } public int getRequestId() { return requestId; } @Override public TurtleTunnelResultItem clone() { return (TurtleTunnelResultItem) super.clone(); } @Override public String toString() { return "TurtleTunnelOkItem{" + "tunnelId=" + tunnelId + ", requestId=" + requestId + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/voip/LockBasedSingleEntrySupplier.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.voip; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Supplier; public class LockBasedSingleEntrySupplier implements Supplier { private byte[] buffer; private boolean dataAvailable; private final Lock lock = new ReentrantLock(); private final Condition dataPresent = lock.newCondition(); private final Condition spaceAvailable = lock.newCondition(); @Override public byte[] get() { lock.lock(); try { while (!dataAvailable) { dataPresent.await(); } byte[] result = buffer; buffer = null; dataAvailable = false; spaceAvailable.signal(); return result; } catch (InterruptedException _) { Thread.currentThread().interrupt(); return new byte[0]; } finally { lock.unlock(); } } public void put(byte[] data) { lock.lock(); try { while (dataAvailable) { spaceAvailable.await(); } buffer = data; dataAvailable = true; dataPresent.signal(); } catch (InterruptedException _) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/voip/VoipRsService.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.voip; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.LocationService; import io.xeres.app.service.MessageService; import io.xeres.app.service.audio.AudioService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.RsServiceRegistry; import io.xeres.app.xrs.service.voip.item.VoipDataItem; import io.xeres.app.xrs.service.voip.item.VoipPingItem; import io.xeres.app.xrs.service.voip.item.VoipProtocolItem; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.MessageType; import io.xeres.common.message.voip.VoipAction; import io.xeres.common.message.voip.VoipMessage; import io.xeres.common.protocol.xrs.RsServiceType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.xiph.speex.SpeexDecoder; import org.xiph.speex.SpeexEncoder; import java.io.StreamCorruptedException; import java.util.Arrays; import static io.xeres.app.xrs.service.voip.item.VoipProtocolItem.Protocol.*; import static io.xeres.common.message.MessagePath.voipPrivateDestination; import static io.xeres.common.protocol.xrs.RsServiceType.VOIP; @Component public class VoipRsService extends RsService { private static final Logger log = LoggerFactory.getLogger(VoipRsService.class); public enum MediaType { NONE(0), VIDEO(1), AUDIO(2); private final int type; MediaType(int type) { this.type = type; } public int getType() { return type; } public static MediaType ofType(int type) { return Arrays.stream(values()) .filter(v -> v.type == type) .findFirst() .orElse(NONE); } } private enum Status { OFF, CALLING, CALLED, ONGOING } private final PeerConnectionManager peerConnectionManager; private final AudioService audioService; private final MessageService messageService; private final LocationService locationService; private SpeexEncoder speexEncoder; private SpeexDecoder speexDecoder; private final LockBasedSingleEntrySupplier audioSupplier = new LockBasedSingleEntrySupplier(); private LocationIdentifier remoteLocationIdentifier; private Status status = Status.OFF; VoipRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, AudioService audioService, MessageService messageService, LocationService locationService) { super(rsServiceRegistry); this.peerConnectionManager = peerConnectionManager; this.audioService = audioService; this.messageService = messageService; this.locationService = locationService; } @Override public RsServiceType getServiceType() { return VOIP; } @Override public void handleItem(PeerConnection sender, Item item) { switch (item) { case VoipProtocolItem voipProtocolItem -> handleProtocolItem(sender, voipProtocolItem); case VoipDataItem voipDataItem -> handleDataItem(sender, voipDataItem); case VoipPingItem _ -> { } // We just ignore those. We already have enough pinging systems (rtt, heartbeat, ...) default -> log.debug("Unhandled item {}", item); } } public void call(LocationIdentifier locationIdentifier) { var location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow(); log.debug("Calling {}...", location); status = Status.CALLING; remoteLocationIdentifier = locationIdentifier; var item = new VoipProtocolItem(RING); peerConnectionManager.writeItem(location, item, this); } public void accept(LocationIdentifier locationIdentifier) { var location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow(); log.debug("Accepting call from {}", location); remoteLocationIdentifier = locationIdentifier; status = Status.ONGOING; var item = new VoipProtocolItem(ACKNOWLEDGE); peerConnectionManager.writeItem(location, item, this); openChannel(location); } public void hangup(LocationIdentifier locationIdentifier) { var location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow(); log.debug("Hanging up on {}", location); status = Status.OFF; remoteLocationIdentifier = null; var item = new VoipProtocolItem(CLOSE); peerConnectionManager.writeItem(location, item, this); closeChannel(); } private void handleProtocolItem(PeerConnection sender, VoipProtocolItem item) { log.debug("Got protocol item {}, status: {}", item, status); switch (item.getProtocol()) { case RING -> { if (remoteLocationIdentifier == null && status == Status.OFF) { log.debug("Got incoming call from {}", sender); remoteLocationIdentifier = sender.getLocation().getLocationIdentifier(); status = Status.CALLED; messageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.RING)); } else { log.debug("Got incoming call from {}, but we're already in call, dropping...", sender); messageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.CLOSE)); // XXX: not sure if this is understood by RS, check } } case ACKNOWLEDGE -> { if (sender.getLocation().getLocationIdentifier().equals(remoteLocationIdentifier) && status == Status.CALLING) { log.debug("Call acknowledged by {}", sender); status = Status.ONGOING; messageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.ACKNOWLEDGE)); openChannel(sender.getLocation()); } } case CLOSE -> { if (sender.getLocation().getLocationIdentifier().equals(remoteLocationIdentifier) && (status == Status.ONGOING || status == Status.CALLED || status == Status.CALLING)) { log.debug("Call closed by {}", sender); remoteLocationIdentifier = null; status = Status.OFF; messageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.CLOSE)); closeChannel(); } } default -> log.debug("Unhandled protocol {}", item); } } private void handleDataItem(PeerConnection sender, VoipDataItem item) { if (status == Status.ONGOING && sender.getLocation().getLocationIdentifier().equals(remoteLocationIdentifier)) { audioSupplier.put(decodeData(item.getData())); } else { log.debug("Ignoring data item {} because current peer is {}", item, remoteLocationIdentifier); } } private void openChannel(Location target) { speexEncoder = new SpeexEncoder(); speexEncoder.init(audioService.getSpeexEncoderMode(), 9, audioService.getAudioSampleRate(), audioService.getAudioSampleChannels()); speexEncoder.getEncoder().setVbr(true); speexEncoder.getEncoder().setVbrQuality(9.0f); speexEncoder.getEncoder().setComplexity(4); speexEncoder.getEncoder().setDtx(true); speexDecoder = new SpeexDecoder(); speexDecoder.init(audioService.getSpeexEncoderMode(), audioService.getAudioSampleRate(), audioService.getAudioSampleChannels(), true); audioService.startPlayingAndRecording(speexEncoder.getFrameSize(), data -> peerConnectionManager.writeItem(target, new VoipDataItem(MediaType.AUDIO, encodeData(data)), this), audioSupplier); } private void closeChannel() { audioService.stopRecordingAndPlaying(); speexEncoder = null; speexDecoder = null; } private byte[] encodeData(byte[] input) { if (speexEncoder.processData(input, 0, input.length)) { var encodedData = new byte[speexEncoder.getProcessedDataByteSize()]; speexEncoder.getProcessedData(encodedData, 0); return encodedData; } log.error("Speex encoding failed"); return new byte[0]; } private byte[] decodeData(byte[] input) { try { speexDecoder.processData(input, 0, input.length); var decodedData = new byte[speexDecoder.getProcessedDataByteSize()]; speexDecoder.getProcessedData(decodedData, 0); return decodedData; } catch (StreamCorruptedException e) { log.error("Speex decoding failed: {}", e.getMessage()); return new byte[0]; } } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipDataItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.voip.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.voip.VoipRsService.MediaType; import io.xeres.common.protocol.xrs.RsServiceType; public class VoipDataItem extends Item { @RsSerialized private int flags; @RsSerialized private byte[] data; @SuppressWarnings("unused") public VoipDataItem() { } public VoipDataItem(MediaType mediaType, byte[] data) { flags = mediaType.getType(); this.data = data; } @Override public int getServiceType() { return RsServiceType.VOIP.getType(); } @Override public int getSubType() { return 7; } @Override public int getPriority() { return ItemPriority.REALTIME.getPriority(); } public MediaType getFlags() { return MediaType.ofType(flags); } public byte[] getData() { return data; } @Override public VoipDataItem clone() { return (VoipDataItem) super.clone(); } @Override public String toString() { return "VoipDataItem{" + "flags=" + flags + ", data size=" + data.length + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipPingItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.voip.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; public class VoipPingItem extends Item { @RsSerialized private int sequenceNumber; @RsSerialized private long timestamp; @Override public int getServiceType() { return RsServiceType.VOIP.getType(); } @Override public int getSubType() { return 1; } @Override public int getPriority() { return ItemPriority.REALTIME.getPriority(); } @Override public VoipPingItem clone() { return (VoipPingItem) super.clone(); } @Override public String toString() { return "VoipPingItem{" + "sequenceNumber=" + sequenceNumber + ", timestamp=" + timestamp + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipPongItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.voip.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.common.protocol.xrs.RsServiceType; public class VoipPongItem extends Item { @RsSerialized private int sequenceNumber; @RsSerialized private long pingTimestamp; @RsSerialized private long pongTimestamp; @Override public int getServiceType() { return RsServiceType.VOIP.getType(); } @Override public int getSubType() { return 2; } @Override public int getPriority() { return ItemPriority.REALTIME.getPriority(); } @Override public VoipPongItem clone() { return (VoipPongItem) super.clone(); } @Override public String toString() { return "VoipPongItem{" + "sequenceNumber=" + sequenceNumber + ", pingTimestamp=" + pingTimestamp + ", pongTimestamp=" + pongTimestamp + '}'; } } ================================================ FILE: app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipProtocolItem.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.voip.item; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.ItemPriority; import io.xeres.app.xrs.serialization.RsSerialized; import io.xeres.app.xrs.service.voip.VoipRsService; import io.xeres.common.protocol.xrs.RsServiceType; public class VoipProtocolItem extends Item { public enum Protocol { /// Not used. NONE, /// Call/Ring. RING, /// Pickup/Acknowledge the call. ACKNOWLEDGE, /// Hangup/Close the call. CLOSE, /// Ask for the bandwidth. BANDWIDTH } @RsSerialized private Protocol protocol; @RsSerialized private int flags; @SuppressWarnings("unused") public VoipProtocolItem() { } public VoipProtocolItem(Protocol protocol) { flags = VoipRsService.MediaType.AUDIO.getType(); this.protocol = protocol; } @Override public int getServiceType() { return RsServiceType.VOIP.getType(); } @Override public int getSubType() { return 3; } @Override public int getPriority() { return ItemPriority.REALTIME.getPriority(); } public Protocol getProtocol() { return protocol; } @Override public VoipProtocolItem clone() { return (VoipProtocolItem) super.clone(); } @Override public String toString() { return "VoipProtocolItem{" + "protocol=" + protocol + ", flags=" + flags + '}'; } } ================================================ FILE: app/src/main/javadoc/overview.html ================================================ This is the server part of Xeres. It can be run in a standalone way and be accessed from a remote UI client. ================================================ FILE: app/src/main/resources/LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: app/src/main/resources/META-INF/additional-spring-configuration-metadata.json ================================================ { "properties": [ { "name": "xrs.service.rtt.enabled", "type": "java.lang.Boolean", "description": "Enable the RTT service, used to calculate Round Trip Time between peers." }, { "name": "xrs.service.sliceprobe.enabled", "type": "java.lang.Boolean", "description": "Enable the slice probe service, used to advertise we support incoming packet slicing." }, { "name": "xrs.service.serviceinfo.enabled", "type": "java.lang.Boolean", "description": "Enable the serviceinfo service, used to advertise which services we support." }, { "name": "xrs.service.discovery.enabled", "type": "java.lang.Boolean", "description": "Enable the discovery service, used to exchange keys and contacts between locations." }, { "name": "xrs.service.heartbeat.enabled", "type": "java.lang.Boolean", "description": "Enable the heartbeat service, used to ping a peer to know if it's up but kind of useless as it overlaps with sliceprobe and rtt." }, { "name": "xrs.service.chat.enabled", "type": "java.lang.Boolean", "description": "Enable the chat service, used for distributed chat, private chat, distant chat, ..." }, { "name": "xrs.service.status.enabled", "type": "java.lang.Boolean", "description": "Enable the status service, used to tell about status (away, busy, online, etc...)." }, { "name": "xrs.service.identity.enabled", "type": "java.lang.Boolean", "description": "Enable the identity service, used to exchange GXS identities." }, { "name": "xrs.service.turtle.enabled", "type": "java.lang.Boolean", "description": "Enable the turtle service, used for file searching, tunnel building and file transfers." }, { "name": "xrs.service.forum.enabled", "type": "java.lang.Boolean", "description": "Enable the forum service." }, { "name" : "xrs.service.filetransfer.enabled", "type" : "java.lang.Boolean", "description" : "Enable the file transfer service." }, { "name" : "xrs.service.gxstunnel.enabled", "type" : "java.lang.Boolean", "description" : "Enable the gxs tunnel service, used to create distant chats." }, { "name" : "xrs.service.bandwidth.enabled", "type" : "java.lang.Boolean", "description" : "Enable the bandwidth control service, used to advertise our bandwidth." }, { "name" : "xrs.service.voip.enabled", "type" : "java.lang.Boolean", "description" : "Enable the VoIP service, used to voice chat with direct peers." }, { "name" : "xrs.service.board.enabled", "type" : "java.lang.Boolean", "description" : "Enable the boards service." }, { "name" : "xrs.service.channel.enabled", "type" : "java.lang.Boolean", "description" : "Enable the channel service." } ] } ================================================ FILE: app/src/main/resources/application-cloud.properties ================================================ # # Copyright (c) 2019-2023 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # This profile is active when deployed on a cloud environment ================================================ FILE: app/src/main/resources/application-dev.properties ================================================ # # Copyright (c) 2019-2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # Debug levels ## Default logging.level.io.xeres=DEBUG ## Broadcast discovery logging.level.io.xeres.app.net.bdisc=INFO ## Serializer # Enable for serialization debugging #logging.level.io.xeres.app.xrs.serialization.*=TRACE # Set to TRACE for item serialized content logging.level.io.xeres.app.xrs.item=INFO ## Packet content # Outgoing (set to TRACE for packet content) logging.level.io.xeres.app.net.peer.PeerConnectionManager=INFO # Incoming (set to DEBUG for incoming item debug, including error stack traces, set to TRACE for packet content) logging.level.io.xeres.app.net.peer.pipeline.PeerHandler=INFO ## WebSocket logging.level.org.springframework.web.socket.config.WebSocketMessageBrokerStats=WARN #logging.level.org.springframework.web.socket.*=DEBUG ## JPA #spring.jpa.show-sql=true #spring.jpa.properties.hibernate.format_sql=true #spring.jpa.properties.hibernate.generate_statistics=true #logging.level.org.hibernate.SQL=DEBUG #logging.level.org.hibernate=DEBUG #logging.level.org.springframework.orm.jpa=TRACE #logging.level.org.springframework.transaction=TRACE ## WebClient #logging.level.org.springframework.web.reactive=DEBUG #logging.level.reactor.netty=DEBUG #logging.level.org.springframework.http.client.reactive=DEBUG #logging.level.reactor.netty.http.client=TRACE ## Sent chat content (set chat service to DEBUG to see the received unparsed text) #logging.level.io.xeres.app.api.controller.chat.ChatMessageController=TRACE ## File sharing logging.level.io.xeres.app.service.file.FileService=INFO ## OnDemand loader logging.level.io.xeres.ui.support.loader=INFO ## Services ## Chat logging.level.io.xeres.app.xrs.service.chat=INFO ## Discovery logging.level.io.xeres.app.xrs.service.discovery=INFO ## heartbeat logging.level.io.xeres.app.xrs.service.heartbeat=INFO ## rtt logging.level.io.xeres.app.xrs.service.rtt=INFO ## serviceinfo logging.level.io.xeres.app.xrs.service.serviceinfo=INFO ## sliceprobe logging.level.io.xeres.app.xrs.service.sliceprobe=INFO ## status logging.level.io.xeres.app.xrs.service.status=INFO ## Gxs logging.level.io.xeres.app.xrs.service.gxs=INFO ## GxsID logging.level.io.xeres.app.xrs.service.identity=INFO ## Forums logging.level.io.xeres.app.xrs.service.forum=INFO ## Channels logging.level.io.xeres.app.xrs.service.channel=INFO ## Boards logging.level.io.xeres.app.xrs.service.board=INFO ## Turtle logging.level.io.xeres.app.xrs.service.turtle=INFO ## GxsTunnels logging.level.io.xeres.app.xrs.service.gxstunnel=DEBUG ## Bandwidth logging.level.io.xeres.app.xrs.service.bandwidth=INFO ### Other settings ## Actuator info.java.vm.vendor=${java.vm.vendor} info.java.version=${java.version} management.endpoint.shutdown.access=unrestricted management.endpoints.web.exposure.include=* management.endpoints.web.base-path=/api/v1/actuator management.info.java.enabled=true management.info.os.enabled=true springdoc.show-actuator=true management.endpoint.env.show-values=always management.endpoint.configprops.show-values=always ## Netty spring.netty.leak-detection=paranoid ## H2 spring.h2.console.enabled=true management.endpoints.jmx.exposure.include=* ## Useful for debugging POST errors #logging.level.org.springframework.web=DEBUG #logging.level.org.springframework.http=DEBUG #logging.level.org.springframework.web.servlet.DispatcherServlet=TRACE ================================================ FILE: app/src/main/resources/application.properties ================================================ # # Copyright (c) 2019-2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # spring.datasource.driver-class-name=org.h2.Driver spring.jpa.open-in-view=true ## Server port, address and SSL mode # This cannot be changed here because some components use the property before Spring processes them, # so basically the 3 next properties are effectively useless. # To set the server port, use the command argument: --control-port= # To remove HTTPS use --no-https server.port=6232 server.address=127.0.0.1 server.ssl.enabled=true # The name is set by SelfCertificateConfiguration server.ssl.key-store=file:dummy.pfx server.ssl.key-store-type=PKCS12 # Password is unimportant, this is a self certificate server.ssl.key-store-password=topsecretstuff server.ssl.key-alias=xeres ## UI options xrs.ui.client.colored-emojis=true xrs.ui.client.rs-emojis-aliases=true # Image cache size (in KB) xrs.ui.client.image-cache-size=16384 ## Database # Cache size (in KB) xrs.db.cache-size=16384 # Maximum compact time on shutdown (in ms) xrs.db.max-compact-time=1000 ## Network # Use the new packet slicing system (not implemented yet, receiving always works) xrs.network.packet-slicing=false # Use the new packet grouping mechanism (not implemented yet, receiving always works) xrs.network.packet-grouping=false ## RsServices xrs.service.rtt.enabled=true xrs.service.sliceprobe.enabled=true xrs.service.serviceinfo.enabled=true xrs.service.discovery.enabled=true xrs.service.heartbeat.enabled=true xrs.service.chat.enabled=true xrs.service.status.enabled=true xrs.service.identity.enabled=true xrs.service.turtle.enabled=true xrs.service.forum.enabled=true xrs.service.filetransfer.enabled=true xrs.service.gxstunnel.enabled=true xrs.service.bandwidth.enabled=true xrs.service.voip.enabled=true xrs.service.board.enabled=true xrs.service.channel.enabled=true ## Swagger UI springdoc.swagger-ui.tags-sorter=alpha # Temporarily remove tomcat's thread warning output, see https://github.com/zapek/Xeres/issues/64 logging.level.org.apache.catalina.loader=ERROR # Remove the ExceptionWebSocketHandlerDecorator which complains on shutdown logging.level.org.springframework.web.socket.handler.ExceptionWebSocketHandlerDecorator=ERROR # Graceful shutdown. This is useful as it will put a warning if there are still active connections server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=10s spring.threads.virtual.enabled=true # Make it work in IntelliJ CE, VSCode and Windows Terminal spring.output.ansi.enabled=always # Allow uploading bigger files spring.servlet.multipart.max-file-size=10MB # The log format for file logs (more compact) logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %-5p - [%15.15t|%15.15c]: %m%n%wEx # Enable Problem support (RFC 7807) spring.mvc.problemdetails.enabled=true spring.jmx.enabled=true management.endpoints.jmx.exposure.include=NetworkProperties # We don't want that because it would make a client in a different locale than the server fail spring.jackson.datatype.enum.read-enums-using-to-string=false spring.jackson.datatype.enum.write-enums-using-to-string=false # See for example CreateForumMessageRequest where parentId and originalId are optional spring.jackson.deserialization.fail-on-null-for-primitives=false ================================================ FILE: app/src/main/resources/banner.txt ================================================ ${AnsiColor.GREEN}__ __${AnsiColor.DEFAULT} ${AnsiColor.GREEN}\ \ / /${AnsiColor.DEFAULT} ${AnsiColor.GREEN} \ V /${AnsiColor.DEFAULT} ___ _ __ ___ ___ ${AnsiColor.GREEN} / \ ${AnsiColor.DEFAULT}/ _ \ '__/ _ \/ __| ${AnsiColor.GREEN}/ /^\ \ ${AnsiColor.DEFAULT} __/ | | __/\__ \ ${AnsiColor.GREEN}\/ \/${AnsiColor.DEFAULT}\___|_| \___||___/ :: ${AnsiColor.YELLOW}Uncensorable Friend-to-Friend${AnsiColor.DEFAULT} :: :: https://xeres.io :: ================================================ FILE: app/src/main/resources/bdboot.txt ================================================ 87.98.162.88 6881 67.215.246.10 6881 185.157.221.247 25401 85.195.217.254 25402 37.187.117.123 25402 ================================================ FILE: app/src/main/resources/db/migration/V00_0_10_202407122208__AlterFileDownloadCompleted.sql ================================================ -- -- Allow to mark file downloads as completed -- ALTER TABLE file_download ADD COLUMN completed BOOLEAN NOT NULL DEFAULT FALSE AFTER chunk_map; ================================================ FILE: app/src/main/resources/db/migration/V00_0_11_202408021538__AddEncryptedHashes.sql ================================================ -- -- Add encrypted hashes to files -- ALTER TABLE file ADD COLUMN encrypted_hash BINARY(20) AFTER hash; ================================================ FILE: app/src/main/resources/db/migration/V00_0_12_202408021849__AddEncryptedHashIndex.sql ================================================ -- -- Add index for encrypted hashes -- CREATE INDEX idx_encrypted_hash ON file(encrypted_hash); ================================================ FILE: app/src/main/resources/db/migration/V00_0_13_202408121618__AddLocationToFileDownload.sql ================================================ -- -- Add location to file downloads -- ALTER TABLE file_download ADD COLUMN location_id BIGINT DEFAULT NULL AFTER size; ================================================ FILE: app/src/main/resources/db/migration/V00_0_14_202408221303__AddAvailabilityToLocation.sql ================================================ -- -- Add availability field to locations -- ALTER TABLE location ADD COLUMN availability ENUM ('available', 'busy', 'away') DEFAULT 'available' AFTER net_mode; ================================================ FILE: app/src/main/resources/db/migration/V00_0_15_202409220053__AddChatBacklog.sql ================================================ -- -- Add chat backlog -- CREATE TABLE chat_backlog ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, location_id BIGINT NOT NULL, created TIMESTAMP(9) NOT NULL, own BOOLEAN NOT NULL, message VARCHAR(199000) ); CREATE INDEX idx_location_created ON chat_backlog (location_id, created); CREATE TABLE chat_room_backlog ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, room_id BIGINT NOT NULL, created TIMESTAMP(9) NOT NULL, gxs_id BINARY(16) DEFAULT NULL, nickname VARCHAR(512) NOT NULL, message VARCHAR(199000) ); CREATE INDEX idx_room_created ON chat_room_backlog (room_id, created); ================================================ FILE: app/src/main/resources/db/migration/V00_0_16_202410061715__AddProfileValidation.sql ================================================ -- -- Add profile validation to identities -- ALTER TABLE identity_group ADD COLUMN profile_id BIGINT DEFAULT NULL AFTER id; ALTER TABLE identity_group ADD COLUMN next_validation TIMESTAMP(9) DEFAULT NULL AFTER profile_signature; UPDATE identity_group SET next_validation = PARSEDATETIME('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') WHERE id != 1 AND profile_signature IS NOT NULL ================================================ FILE: app/src/main/resources/db/migration/V00_0_17_202410112205__AddProfileCreation.sql ================================================ -- -- Add profile creation time -- ALTER TABLE profile ADD COLUMN created TIMESTAMP(9) DEFAULT NULL AFTER pgp_identifier; ================================================ FILE: app/src/main/resources/db/migration/V00_0_18_202410201950__AddLocationVersion.sql ================================================ -- -- Add location version -- ALTER TABLE location ADD COLUMN version VARCHAR(64) DEFAULT NULL AFTER last_connected; ================================================ FILE: app/src/main/resources/db/migration/V00_0_19_202411171309__AddExtendedFingerprint.sql ================================================ -- -- Extend fingerprints to 32 bytes -- ALTER TABLE profile ALTER COLUMN pgp_fingerprint VARBINARY(32); ================================================ FILE: app/src/main/resources/db/migration/V00_0_1_202001232214__InitDb.sql ================================================ -- -- Database creation -- -- Avoid touching this file if unnecessary (even comments) as this will trigger -- a flyway migration. Migrations will be consistently used and this file won't -- be touched anymore when we reach 1.0.0 -- -- See https://h2database.com/html/datatypes.html for the data types -- CREATE TABLE profile ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(64) NOT NULL, pgp_identifier BIGINT NOT NULL UNIQUE, pgp_fingerprint BINARY(20) NOT NULL, pgp_public_key_data VARBINARY(16384), accepted BOOLEAN NOT NULL DEFAULT false, trust ENUM ('unknown', 'never', 'marginal', 'full', 'ultimate') DEFAULT 'unknown' ); CREATE TABLE location ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, profile_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, location_identifier BINARY(16) NOT NULL UNIQUE, connected BOOLEAN NOT NULL DEFAULT false, discoverable BOOLEAN NOT NULL DEFAULT true, dht BOOLEAN NOT NULL DEFAULT true, net_mode ENUM ('unknown', 'udp', 'upnp', 'ext', 'hidden', 'unreachable') DEFAULT 'unknown', last_connected TIMESTAMP, CONSTRAINT fk_location_profile FOREIGN KEY (profile_id) REFERENCES profile (id) ); CREATE TABLE connection ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, location_id BIGINT NOT NULL, type ENUM ('invalid', 'ipv4', 'ipv6', 'tor', 'hostname', 'i2p'), address VARCHAR(128) NOT NULL, last_connected TIMESTAMP, external BOOLEAN NOT NULL, CONSTRAINT fk_connection_location FOREIGN KEY (location_id) REFERENCES location (id) ); CREATE TABLE settings ( lock TINYINT NOT NULL DEFAULT 1, pgp_private_key_data VARBINARY(16384) DEFAULT NULL, location_private_key_data VARBINARY(16384) DEFAULT NULL, location_public_key_data VARBINARY(16384) DEFAULT NULL, location_certificate VARBINARY(16384) DEFAULT NULL, local_port INT NOT NULL DEFAULT 0, tor_socks_host VARCHAR(253) DEFAULT NULL, tor_socks_port INT NOT NULL DEFAULT 0, i2p_socks_host VARCHAR(253) DEFAULT NULL, i2p_socks_port INT NOT NULL DEFAULT 0, upnp_enabled BOOLEAN NOT NULL DEFAULT TRUE, broadcast_discovery_enabled BOOLEAN NOT NULL DEFAULT TRUE, dht_enabled BOOLEAN NOT NULL DEFAULT TRUE, auto_start_enabled BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT pk_t1 PRIMARY KEY (lock), CONSTRAINT ck_t1_locked CHECK (lock = 1) ); INSERT INTO settings (lock) VALUES (1); CREATE TABLE gxs_client_update ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, location_id BIGINT NOT NULL, service_type INT NOT NULL, last_synced TIMESTAMP, CONSTRAINT fk_gxs_client_update_location FOREIGN KEY (location_id) REFERENCES location (id) ); CREATE INDEX idx_location_service ON gxs_client_update (location_id, service_type); CREATE TABLE gxs_client_update_messages ( gxs_client_update_id BIGINT NOT NULL, identifier BINARY(16) NOT NULL, -- normal name would be 'gxs_id' but hibernate doesn't let us use @AttributeOverride for an embeddable key and basic type (it wants the value as an embeddable type too then) updated TIMESTAMP NOT NULL ); CREATE TABLE gxs_service_setting ( id INT PRIMARY KEY NOT NULL, last_updated TIMESTAMP ); CREATE TABLE chat_room ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, room_id BIGINT NOT NULL, identity_group_id BIGINT NOT NULL, name VARCHAR(256) NOT NULL, topic VARCHAR(256) NOT NULL, flags INT NOT NULL DEFAULT 0, subscribed BOOLEAN NOT NULL DEFAULT true, joined BOOLEAN NOT NULL DEFAULT false ); CREATE TABLE gxs_group ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, gxs_id BINARY(16) NOT NULL UNIQUE, original_gxs_id BINARY(16), name VARCHAR(512) NOT NULL, diffusion_flags INT NOT NULL DEFAULT 0, signature_flags INT NOT NULL DEFAULT 0, published TIMESTAMP, author BINARY(16), circle_id BINARY(16), circle_type ENUM ('unknown', 'public', 'external', 'your_friends_only', 'local', 'external_self', 'your_eyes_only') DEFAULT 'unknown', authentication_flags INT NOT NULL DEFAULT 0, parent_id BINARY(16), popularity INT NOT NULL DEFAULT 0, visible_message_count INT NOT NULL DEFAULT 0, last_posted TIMESTAMP, status INT NOT NULL DEFAULT 0, service_string VARCHAR(512), originator BINARY(16), internal_circle BINARY(16), subscribed BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE gxs_group_private_keys ( gxs_group_id BIGINT NOT NULL, key_id BINARY(16) NOT NULL, flags INT NOT NULL DEFAULT 0, valid_from TIMESTAMP NOT NULL, valid_to TIMESTAMP, data VARBINARY(16384) ); CREATE TABLE gxs_group_public_keys ( gxs_group_id BIGINT NOT NULL, key_id BINARY(16) NOT NULL, flags INT NOT NULL DEFAULT 0, valid_from TIMESTAMP NOT NULL, valid_to TIMESTAMP, data VARBINARY(16384) ); CREATE TABLE gxs_group_signatures ( gxs_group_id BIGINT NOT NULL, type ENUM ('author', 'publish', 'admin'), gxs_id BINARY(16) NOT NULL, data VARBINARY(512) ); CREATE TABLE identity_group ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, profile_hash BINARY(20), profile_signature VARBINARY(2048), image VARBINARY(131072) DEFAULT NULL, type ENUM ('other', 'own', 'friend', 'banned') DEFAULT 'other' ); CREATE INDEX idx_type ON identity_group (type); CREATE TABLE forum_group ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, description VARCHAR(4096) ); CREATE TABLE forum_group_admins ( forum_group_id BIGINT NOT NULL, admin BINARY(16) ); CREATE TABLE forum_group_pinned_posts ( forum_group_id BIGINT NOT NULL, pinned_post BINARY(20) ); CREATE TABLE gxs_message ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, gxs_id BINARY(16) NOT NULL, message_id BINARY(20) NOT NULL, thread_id BINARY(20), parent_id BINARY(20), original_message_id BINARY(20), author_id BINARY(16), name VARCHAR(512) NOT NULL, published TIMESTAMP, flags INT NOT NULL DEFAULT 0, status INT NOT NULL DEFAULT 0, child TIMESTAMP, service_string VARCHAR(512) ); CREATE INDEX idx_gxs_id ON gxs_message (gxs_id); CREATE INDEX idx_message_id ON gxs_message (message_id); CREATE TABLE gxs_message_signatures ( gxs_message_id BIGINT NOT NULL, type ENUM ('author', 'publish', 'admin'), gxs_id BINARY(16) NOT NULL, signatures_order INT NOT NULL DEFAULT 0, data VARBINARY(512) ); CREATE TABLE forum_message ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, content VARCHAR(199000), read BOOLEAN NOT NULL DEFAULT FALSE ); ================================================ FILE: app/src/main/resources/db/migration/V00_0_20_202411212150__AlterShareLastScanned.sql ================================================ -- -- Make sure we don't have null values for last scanned because the criteria API doesn't support nullsFirst -- UPDATE share SET last_scanned = parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') WHERE last_scanned IS NULL; ALTER TABLE share ALTER COLUMN last_scanned TIMESTAMP(9) NOT NULL DEFAULT parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss'); ================================================ FILE: app/src/main/resources/db/migration/V00_0_21_202412142109__AddRemoteOptions.sql ================================================ -- -- Add remote options -- ALTER TABLE settings ADD COLUMN remote_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER remote_password; ALTER TABLE settings ADD COLUMN upnp_remote_enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER remote_enabled; ================================================ FILE: app/src/main/resources/db/migration/V00_0_22_202412211327__AddRemotePort.sql ================================================ -- -- Add remote port -- ALTER TABLE settings ADD COLUMN remote_port INTEGER NOT NULL DEFAULT 0 AFTER remote_enabled; ================================================ FILE: app/src/main/resources/db/migration/V00_0_23_202412242306__AddChatRoomLocations.sql ================================================ -- -- Add participating locations to chat rooms -- CREATE TABLE chat_room_locations ( chat_room_id BIGINT NOT NULL, locations_id BIGINT NOT NULL ); ================================================ FILE: app/src/main/resources/db/migration/V00_0_24_202502252128__AddDistantChatBacklog.sql ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ -- -- Add distant chat backlog -- CREATE TABLE distant_chat_backlog ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, identity_id BIGINT NOT NULL, created TIMESTAMP(9) NOT NULL, own BOOLEAN NOT NULL, message VARCHAR(199000) ); CREATE INDEX idx_identity_created ON distant_chat_backlog (identity_id, created); ================================================ FILE: app/src/main/resources/db/migration/V00_0_25_202504051643__AcceptNullNamedLocations.sql ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ -- -- Accept locations with null names (means the name will be updated by discovery) -- ALTER TABLE location ALTER COLUMN name SET NULL; ================================================ FILE: app/src/main/resources/db/migration/V00_0_26_202504152033__AdjustBacklogMessageSizes.sql ================================================ -- -- Adjust message backlog sizes -- ALTER TABLE chat_backlog ALTER COLUMN message VARCHAR(300000); ALTER TABLE distant_chat_backlog ALTER COLUMN message VARCHAR(300000); ALTER TABLE chat_room_backlog ALTER COLUMN message VARCHAR(40000); ================================================ FILE: app/src/main/resources/db/migration/V00_0_27_202511240013__AddBoards.sql ================================================ -- -- Add Boards -- CREATE TABLE board_group ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, description VARCHAR(4096), image VARBINARY(199000) DEFAULT NULL ); CREATE TABLE board_message ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, content VARCHAR(8192), image VARBINARY(199000) DEFAULT NULL, link VARCHAR(2048), read BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE comment_message ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, comment VARCHAR(4096) ); CREATE TABLE vote_message ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, type ENUM ('none', 'down', 'up') ); ================================================ FILE: app/src/main/resources/db/migration/V00_0_28_202511281815__AddChannels.sql ================================================ -- -- Add Channels -- CREATE TABLE channel_group ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, description VARCHAR(4096), image VARBINARY(199000) DEFAULT NULL ); CREATE TABLE channel_message ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, content VARCHAR(8192), title VARCHAR(1024), comment VARCHAR(8192), image VARBINARY(199000) DEFAULT NULL, read BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE channel_message_files ( channel_message_id BIGINT NOT NULL, size BIGINT NOT NULL, hash BINARY(20) NOT NULL, name VARCHAR(1024), path VARCHAR(2048), age INT NOT NULL ); CREATE INDEX idx_channel_message_id ON channel_message_files (channel_message_id); -- Add indexes to speed up support linked tables CREATE INDEX idx_gxs_client_update_messages_gxs_client_update_id ON gxs_client_update_messages (gxs_client_update_id); CREATE INDEX idx_gxs_group_private_keys_gxs_group_id ON gxs_group_private_keys (gxs_group_id); CREATE INDEX idx_gxs_group_public_keys_gxs_group_id ON gxs_group_public_keys (gxs_group_id); CREATE INDEX idx_gxs_group_signatures_gxs_group_id ON gxs_group_signatures (gxs_group_id); CREATE INDEX idx_gxs_message_signatures_gxs_message_id ON gxs_message_signatures (gxs_message_id); CREATE INDEX idx_forum_group_admins_forum_group_id ON forum_group_admins (forum_group_id); CREATE INDEX idx_forum_group_pinned_posts_forum_group_id ON forum_group_pinned_posts (forum_group_id); ================================================ FILE: app/src/main/resources/db/migration/V00_0_29_202512212323__FixGxsSizeLimits.sql ================================================ -- -- Change the GxS limits to make sure they fit. -- It's 199000 per message, but we never know which field will -- have that limit. -- ALTER TABLE forum_group ALTER COLUMN description VARCHAR(199000); ALTER TABLE channel_group ALTER COLUMN description VARCHAR(199000); ALTER TABLE channel_message ALTER COLUMN content VARCHAR(199000); ALTER TABLE channel_message ALTER COLUMN title VARCHAR(199000); ALTER TABLE channel_message ALTER COLUMN comment VARCHAR(199000); ALTER TABLE board_group ALTER COLUMN description VARCHAR(199000); ALTER TABLE board_message ALTER COLUMN content VARCHAR(199000); ALTER TABLE board_message ALTER COLUMN link VARCHAR(199000); ALTER TABLE comment_message ALTER COLUMN comment VARCHAR(199000); ALTER TABLE board_message ADD COLUMN image_width INT NOT NULL DEFAULT 0 AFTER image; ALTER TABLE board_message ADD COLUMN image_height INT NOT NULL DEFAULT 0 AFTER image; ALTER TABLE channel_message ADD COLUMN image_width INT NOT NULL DEFAULT 0 AFTER image; ALTER TABLE channel_message ADD COLUMN image_height INT NOT NULL DEFAULT 0 AFTER image; ================================================ FILE: app/src/main/resources/db/migration/V00_0_2_202312151830__AddIncomingDirectory.sql ================================================ -- -- Add incoming directory to settings -- ALTER TABLE settings ADD COLUMN incoming_directory VARCHAR(1024) DEFAULT NULL AFTER auto_start_enabled; ================================================ FILE: app/src/main/resources/db/migration/V00_0_30_202602161830__ImproveGxsGroupsAndMessage.sql ================================================ -- -- Remove the useless fields in GxsGroupItem and GxsMessageItem. -- Add a field to handle multi versioning. -- Fix identity service string. -- Speed up forum, board and channel counting -- ALTER TABLE gxs_group DROP COLUMN status, service_string, original_gxs_id; ALTER TABLE gxs_message DROP COLUMN status, child, service_string; ALTER TABLE gxs_message ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE AFTER flags; CREATE INDEX idx_message_hidden ON gxs_message (hidden); ALTER TABLE identity_group ADD COLUMN overall_score INT NOT NULL DEFAULT 5 AFTER type; ALTER TABLE identity_group ADD COLUMN identity_score INT NOT NULL DEFAULT 5 AFTER overall_score; ALTER TABLE identity_group ADD COLUMN own_opinion INT NOT NULL DEFAULT 0 AFTER identity_score; ALTER TABLE identity_group ADD COLUMN peer_opinion INT NOT NULL DEFAULT 0 AFTER own_opinion; ALTER TABLE identity_group ADD COLUMN validation_attempt INT NOT NULL DEFAULT 0 AFTER peer_opinion; ALTER TABLE identity_group ADD COLUMN last_validation TIMESTAMP(9) AFTER validation_attempt; ALTER TABLE identity_group ADD COLUMN last_usage TIMESTAMP(9) AFTER last_validation; CREATE INDEX idx_forum_message_read ON forum_message (read); CREATE INDEX idx_board_message_read ON board_message (read); CREATE INDEX idx_channel_message_read ON channel_message (read); ================================================ FILE: app/src/main/resources/db/migration/V00_0_31_202602121929__AddLastActivity.sql ================================================ -- -- Add last activity column to know -- when remote groups were updated by friends. -- ALTER TABLE gxs_group ALTER COLUMN last_posted RENAME TO last_updated; ALTER TABLE gxs_group ADD COLUMN last_activity TIMESTAMP(9) NOT NULL DEFAULT parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') AFTER visible_message_count; ALTER TABLE gxs_group ADD COLUMN last_statistics TIMESTAMP(9) NOT NULL DEFAULT parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') AFTER last_activity; CREATE INDEX idx_gxs_group_last_statistics ON gxs_group (last_statistics); ================================================ FILE: app/src/main/resources/db/migration/V00_0_32_202603092327__AddIndices.sql ================================================ -- -- Indices to speed up lookup -- CREATE INDEX idx_message_published ON gxs_message (published); CREATE INDEX idx_location_last_connected ON location (last_connected); CREATE INDEX idx_group_last_statistics ON gxs_group (last_statistics); CREATE INDEX idx_identity_next_validation ON identity_group (next_validation); ================================================ FILE: app/src/main/resources/db/migration/V00_0_33_202604260021__FixVotes.sql ================================================ -- -- Migrate vote data because of missing converter -- UPDATE vote_message SET type = 'up' WHERE type = 'down'; UPDATE vote_message SET type = 'down' WHERE type = 'none'; ================================================ FILE: app/src/main/resources/db/migration/V00_0_3_202401151840__AddSharesAndFiles.sql ================================================ -- -- Add shares and files -- CREATE TABLE file ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id BIGINT DEFAULT NULL, name VARCHAR(255) NOT NULL, type ENUM ('any', 'audio', 'archive', 'cdimage', 'document', 'picture', 'program', 'video', 'directory') DEFAULT 'any', hash BINARY(20), modified TIMESTAMP, CONSTRAINT fk_file_parent FOREIGN KEY (parent_id) REFERENCES file (id) ); CREATE INDEX idx_parent_name ON file (parent_id, name); CREATE INDEX idx_hash ON file (hash); CREATE TABLE share ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, file_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL UNIQUE, searchable BOOLEAN NOT NULL DEFAULT false, browsable ENUM ('unknown', 'never', 'marginal', 'full', 'ultimate') DEFAULT 'unknown', last_scanned TIMESTAMP, CONSTRAINT fk_share_file FOREIGN KEY (file_id) REFERENCES file (id) ); ================================================ FILE: app/src/main/resources/db/migration/V00_0_4_202402211850__AlterTimestampPrecision.sql ================================================ -- -- Change precision from default microseconds to nanoseconds for all timestamps -- so that comparison problems don't arise. -- ALTER TABLE file ALTER COLUMN modified TIMESTAMP(9); ALTER TABLE share ALTER COLUMN last_scanned TIMESTAMP(9); ALTER TABLE location ALTER COLUMN last_connected TIMESTAMP(9); ALTER TABLE connection ALTER COLUMN last_connected TIMESTAMP(9); ALTER TABLE gxs_client_update ALTER COLUMN last_synced TIMESTAMP(9); ALTER TABLE gxs_client_update_messages ALTER COLUMN updated TIMESTAMP(9); ALTER TABLE gxs_service_setting ALTER COLUMN last_updated TIMESTAMP(9); ALTER TABLE gxs_group ALTER COLUMN published TIMESTAMP(9); ALTER TABLE gxs_group ALTER COLUMN last_posted TIMESTAMP(9); ALTER TABLE gxs_group_private_keys ALTER COLUMN valid_from TIMESTAMP(9); ALTER TABLE gxs_group_private_keys ALTER COLUMN valid_to TIMESTAMP(9); ALTER TABLE gxs_group_public_keys ALTER COLUMN valid_from TIMESTAMP(9); ALTER TABLE gxs_group_public_keys ALTER COLUMN valid_to TIMESTAMP(9); ALTER TABLE gxs_message ALTER COLUMN published TIMESTAMP(9); ALTER TABLE gxs_message ALTER COLUMN child TIMESTAMP(9); ================================================ FILE: app/src/main/resources/db/migration/V00_0_5_202405122038__AddSizeToFiles.sql ================================================ -- -- Add size to files -- ALTER TABLE file ADD COLUMN size BIGINT NOT NULL DEFAULT 0 AFTER name; ================================================ FILE: app/src/main/resources/db/migration/V00_0_6_202405242209__AddNewFileEnumTypes.sql ================================================ -- -- Add new enum types -- ALTER TABLE file ALTER COLUMN type ENUM ('any', 'audio', 'archive', 'document', 'picture', 'program', 'video', 'subtitles', 'collection', 'directory') DEFAULT 'any'; ================================================ FILE: app/src/main/resources/db/migration/V00_0_7_202406181840__AddFileDownload.sql ================================================ -- -- Add file downloads -- CREATE TABLE file_download ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(255) NOT NULL, hash BINARY(20) NOT NULL, size BIGINT NOT NULL, chunk_map VARBINARY(1000000000) ); ================================================ FILE: app/src/main/resources/db/migration/V00_0_8_202406191850__AddRemotePassword.sql ================================================ -- -- Add remote password -- ALTER TABLE settings ADD COLUMN remote_password VARCHAR(64) DEFAULT NULL AFTER incoming_directory; ================================================ FILE: app/src/main/resources/db/migration/V00_0_9_202406201855__AddSettingsVersion.sql ================================================ -- -- Add settings version -- ALTER TABLE settings ADD COLUMN version INT NOT NULL DEFAULT 0 AFTER lock; ================================================ FILE: app/src/main/resources/public/index.html ================================================ Xeres Web Interface The Web Interface is not finished yet, for now you can go to:

================================================ FILE: app/src/main/resources/public.asc ================================================ -----BEGIN PGP PUBLIC KEY BLOCK----- mQINBF48a4MBEADCTAIJ8ITwNt+FTKRCbZD63MXGpHBz2abt0Kgli+pT+ioyQJTr rFGDwdvtsEo3l5cYxjBHXS7k9O3ArqE/a6dkTIeiaCKmSwzT3LbxjeUEsWc/Thki bt7b05PSu7u+kkeHLDdApTJ+uXLhBIalys7BtFN1SOUuO3WgFELWVhiQz4mxjyBL z02OVNSUwS28bYxsuUl4t9ef1dHuvxnqOVx6kS0RudvVbdJIM7ws+vUPbMGIf5pr 8zHorGdp2Qrcdk6eMwLOYhU19DII7AZEQXKOwfejQ/jyq6F5KooLHEp/q+QOR3+P /VGI8tTUpj2sXkh5O9zOdbibaqb6Ey4zQRiZ7bWy2YpEMWsTTWqu7uKfxmthrQ0u kSPGkxc9WeM4aJRgJjxyqweSCESD8gFbfzM+6Coh7na/OI4YPXkOeyABfDw9r6t1 DM7/Y2vO8Am+0aSFglMoj8zwGl90BaeY+OMMX0BnqNNbEwBUNAF/14dDYy+QqIDo lpXoyEZxLdzV1cXXDT08e2mdcYxKjdBhzbNm2c/JRs/bTBC3JnUDvyXc/hTxQTQW lEGlNo0NH3vMoyo6lQM/FlIpoFMVcHk6705cD6URsZJoCWF1YxAPk6oavVXz2wZI ++h8bv5PtZ5shIeoZut0onTjE1uoLIdrHbOPTeTItwIOjNuPBRKdXbTo8QARAQAB tBtEYXZpZCBHZXJiZXIgPGRnQHphcGVrLmNvbT6JAk4EEwEIADgWIQT6CJHMxxYT 4NZ8AIsNFj13BGkFKgUCXjxrgwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK CRANFj13BGkFKpWbEACbmoTrFlDUm3KLYU1LtUzwE3A8RmPcuYFkhlioyITDmp4e rzDkJnV9jKBJ5eA3XedSKyBailqh8T3OJvl370EiyEa37vmX/RDz+K6qgs6QSNPt o3uEbevhSmMZoV/zYYok0v3Vhn7HeilErCc2F2rfJO1KvUlvO9bFIP63L5xsCPbo c8fuE6f5O6hXMbLBpsIbbtOq8QcDRviZFPE8KpxPo05hgM8wKH9u/tE9x+T3JMxV BiJXzrfYwHBeONV0A2yphStS1aP4fPVZB+0vqU+KA/exZH+3BxKe8dilBB0AmSpp qfmJSu+PFypVEs0ah9NPSvd23d2LgbQHlRPBN8rmh8Z1Ej1umSjc4vSJdh67Y6Ou GVQvxPBsOe13swIs2OwUPmoY+Fl+b401KqQtg6unjkN4odl3KpQwu8uT3WeImPI6 Lf0TEW+GMxp7i2vCJfGSGYuNITz/SSSZTJnZKYE9QquEMMnvhcUYoGB2nBfP+WYT V8HgzhKuC2EDEBS4coEqBEOuc+Xdbn6AKyzThFfoMf9U8F6+SyykPV6WwOZwwSZQ I6Y9MkqMKMFEFphCCBIqltS+Vr/F2tU7T62G4l5g2f/u0P9YxQVEonGwQXHCP0Os QrKZ5K+uSephwkVAfwof/m5bXtPOFy9+hHIel91aqaKGM8CG8rCGnpHlwGQsGLkC DQRePGuDARAA0L+pMk5k2eLST6aEVgkZJ5G3fz4wzsSk6qHnzQiLpQrW17cDGGjw sCcGgc4pgR59QrHG262JxMnjJMH5/EtFh+fSQ4qxC8uB7iM3+yx3xxrfGavXHMrb nv1RfN+wFXTWDV1rNK+62ycNEDKHG74lD9v8GR6xuXQfpkd7/0ZiTP2/fosVPAM4 h8RqLtfdy+i5Ds4oGpqVFao3PhukS+8UjbYwMdgRq5GJprZJiQ4i2cbEVzvxEEV2 w5idRCnjjbxuLf6oJ78hdMztGGNZZUFlSIDwBw45beQfhngUA/00KCyrtOt+avzq vVXLdl9J3z58NreHAtOFW7CfNVHNsTLx1LcQIKvh5nRgyAoYvY9tptDv5Uj3Wiow g0PT69w9h7qv4RzJcTqESh+4PcRSIrNGaL7grO1pBnlVETMkJbv+UO3V3FIyCHW0 7VXrvj1wqRZRKTgADkjyj/umHIws9jh+eP4XXmU3TYpCu8l+AuMHVpVKpQcQoju3 Flm9G2UYaaY0QbeCPKS1Abq7W0DoqUrs3kQTxXOLEZlpRK8hLT7/aYlJok4a6Wuw yIf0N24gPc8/WZ3nttiUu4LdSlquQNhjfufdPWBSYvRgCN7cdPtqzbsDP3J9cf1Q 0XE6AxNj5DPCdZvNMxXg2z/y7L5psEPou8wXgGx1c/3kTK5srYM5KA0AEQEAAYkC NgQYAQgAIBYhBPoIkczHFhPg1nwAiw0WPXcEaQUqBQJePGuDAhsMAAoJEA0WPXcE aQUqE6MP/3dScLhPjp4lpzyIlyZOvrMZH1a0uvmVevTGGQcHZn11pPG1Pu4dzFBA T2sx/sOF+rlK+Gz3oz+HCt4EMgCZapV7bT/IndG9YSeR+arHMnLTjyxUvqmU/IvK 9lYPVGW9TmXZjXlSs5O/Gmg1UxkIV4m/wlsbKII5pNXw+uGzBeXa9ED0tTYcnEJ1 zjmzqbImUGw2wBYQMW/tvQc8Y5EmOUVW19Rk8BdsR6eV7u/GOLLwjs/Wz4X6z/oP iJczBlNQmHeGaSYWV3RouzehC3vjWhxTZmK51QMQ/OpHS1vYg4pgtEatTthxOj0u wtYeuhyIQe0J2xnfUxPVeRdxydoEs+7z3M+OEvAOXnUBkxPuzyF5D/YPDJ17LHD9 l1C/N6QzS532iWGoAIrOYZNeOt9BpY9A9xcXtZDVwzHfoN9wKWFhncIO71fQ2LH8 KVSp1n32ShECZ+vP604BiMEnuPXCo14ygqn8QWWJ7+pUH6FRjPXBWEok5sxcJkKq EoWPB3oGs4sXVyeBTsVDT1pCvP83s+OhTrAdpUxRse28ygWIfpBjlHknIq4iQ/HI yx34o11YhSfZSeyfSM5G3zOSNVrCKuw8QKKXZIOAxBgmc45u49l2E5tdOkHoHvgt pr/5UGrmPwHjpq00dH7OIcUKnTbj/Kj6l8AM2CQznY9DBYdp0c93 =RMg2 -----END PGP PUBLIC KEY BLOCK----- ================================================ FILE: app/src/test/java/io/xeres/app/ApiTest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app; import io.xeres.common.location.Availability; import io.xeres.common.rest.chat.ChatRoomVisibility; import io.xeres.common.rest.chat.CreateChatRoomRequest; import io.xeres.common.rest.config.OwnIdentityRequest; import io.xeres.common.rest.config.OwnLocationRequest; import io.xeres.common.rest.config.OwnProfileRequest; import io.xeres.common.rest.forum.CreateForumMessageRequest; import io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest; import io.xeres.common.rest.profile.RsIdRequest; import io.xeres.testutils.ResourceUtils; import io.xeres.ui.support.util.ClientUtils; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicLong; import static io.xeres.app.ApiTest.DATADIR_PATH; import static io.xeres.common.rest.PathConfig.*; import static org.springframework.boot.test.context.SpringBootTest.UseMainMethod.ALWAYS; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; @SpringBootTest(args = {"--no-gui", "--no-https", "--no-control-password", "--fast-shutdown", "--data-dir=" + DATADIR_PATH}, useMainMethod = ALWAYS, webEnvironment = RANDOM_PORT) // Do not add --server-only, or it'll break PeerConnectionJob @AutoConfigureWebTestClient @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class ApiTest { static final String DATADIR_PATH = "./data-apitest"; private static final String PROFILE_NAME = "foobar"; private static final String LOCATION_NAME = "earth"; private static final String IDENTITY_NAME = "foobar"; @LocalServerPort private int port; @Autowired private WebTestClient webTestClient; // We need to clean on startup and cleanup because it's tricky to do it on // shutdown (some files are used still, like the memory mapped bloom filter, and it doesn't // seem possible to close it without some nasty hacks) @BeforeAll static void setup() { deleteApiDir(); } @AfterAll static void cleanup() { deleteApiDir(); } @Test @Order(1) void createOwnProfile() { var profileRequest = new OwnProfileRequest(PROFILE_NAME); webTestClient.post() .uri(CONFIG_PATH + "/profile") .bodyValue(profileRequest) .exchange() .expectStatus().isCreated() .expectHeader().location(getServerUri() + PROFILES_PATH + "/1") .expectBody().isEmpty(); } @Test @Order(2) void createOwnLocation() { var locationRequest = new OwnLocationRequest(LOCATION_NAME); webTestClient.post() .uri(CONFIG_PATH + "/location") .bodyValue(locationRequest) .exchange() .expectStatus().isCreated() .expectHeader().location(getServerUri() + LOCATIONS_PATH + "/1") .expectBody().isEmpty(); } @Test @Order(3) void createOwnIdentity() { var identityRequest = new OwnIdentityRequest(IDENTITY_NAME, false); webTestClient.post() .uri(CONFIG_PATH + "/identity") .bodyValue(identityRequest) .exchange() .expectStatus().isCreated() .expectHeader().location(getServerUri() + IDENTITIES_PATH + "/1") .expectBody().isEmpty(); } @Test @Order(4) void importFriend() { var rsId = "ABBzjqGSBk4/IOdmQ4zJMFvVAQdOZW1lc2lzAxQG1LRG0gnnUvpxGjl5KyDKZX4nBpENBNJmb28uYmFyLmNvbZIGAwIBVQTSkwYyAajABNICFGlwdjQ6Ly84NS4xLjIuNDoxMjM0BAOiD+U="; var rsIdRequest = new RsIdRequest(rsId); webTestClient.post() .uri(PROFILES_PATH) .bodyValue(rsIdRequest) .exchange() .expectStatus().isCreated() .expectHeader().location(getServerUri() + PROFILES_PATH + "/2") .expectBody().isEmpty(); } @Test @Order(5) void checkFriend() { webTestClient.get() .uri(uriBuilder -> uriBuilder .path(PROFILES_PATH) .queryParam("name", "Nemesis") .build()) .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.[0].name").isEqualTo("Nemesis"); } @Test @Order(6) void changeAvailability() { webTestClient.put() .uri(CONFIG_PATH + "/location/availability") .bodyValue(Availability.BUSY) .exchange() .expectStatus().isOk() .expectBody().isEmpty(); } @Test @Order(7) void getSettings() { webTestClient.get() .uri(SETTINGS_PATH) .exchange() .expectStatus().isOk(); } @Test @Order(8) void createForum() { var request = new CreateOrUpdateForumGroupRequest("Test", "Just some test forum"); webTestClient.post() .uri(FORUMS_PATH + "/groups") .bodyValue(request) .exchange() .expectStatus().isCreated() .expectBody().isEmpty(); } @Test @Order(9) void checkForumsAndMessages() { // Check if the forum was created var forumGroupId = new AtomicLong(); webTestClient.get() .uri(FORUMS_PATH + "/groups") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.[0].name").isEqualTo("Test") .jsonPath("$.[0].description").isEqualTo("Just some test forum") .jsonPath("$.[0].id").value(o -> forumGroupId.set((Integer) o)); // Create a message var createForumMessageRequest = new CreateForumMessageRequest(forumGroupId.get(), "First message", "This is the first message ever", 0L, 0L); var forumMessageLocation = webTestClient.post() .uri(FORUMS_PATH + "/messages") .bodyValue(createForumMessageRequest) .exchange() .expectStatus().isCreated() .expectBody().isEmpty() .getResponseHeaders().getLocation(); var path = forumMessageLocation.getPath(); var forumMessageId = new AtomicLong(); // Check the message webTestClient.get() .uri(FORUMS_PATH + "/messages/" + path.substring(path.lastIndexOf('/') + 1)) .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.name").isEqualTo("First message") .jsonPath("$.content").isEqualTo("This is the first message ever") .jsonPath("$.id").value(o -> forumMessageId.set((Integer) o)); // Edit the message (by posting a new one with original id set to the old one) var createForEditMessageRequest = new CreateForumMessageRequest(forumGroupId.get(), "First message (edited)", "This is the first message ever (edited)", 0L, forumMessageId.get()); var editedMessageLocation = webTestClient.post() .uri(FORUMS_PATH + "/messages") .bodyValue(createForEditMessageRequest) .exchange() .expectStatus().isCreated() .expectBody().isEmpty() .getResponseHeaders().getLocation(); assert editedMessageLocation != null; path = editedMessageLocation.getPath(); // Check edited message webTestClient.get() .uri(FORUMS_PATH + "/messages/" + path.substring(path.lastIndexOf('/') + 1)) .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.name").isEqualTo("First message (edited)") .jsonPath("$.content").isEqualTo("This is the first message ever (edited)"); } @Test @Order(10) void createChatRoom() { var request = new CreateChatRoomRequest("Test", "Anything, really", ChatRoomVisibility.PUBLIC, true); webTestClient.post() .uri(CHAT_PATH + "/rooms") .bodyValue(request) .exchange() .expectStatus().isCreated() .expectBody().isEmpty(); } @Test @Order(11) void createBoard() { var builder = ClientUtils.createGroupBuilder("Test", "A cool board", ResourceUtils.getResourceAsFile("/image/abitbol.png")); webTestClient.post() .uri(BOARDS_PATH + "/groups") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchange() .expectStatus().isCreated() .expectBody().isEmpty(); } @Test @Order(12) void createChannel() { var builder = ClientUtils.createGroupBuilder("Test", "A cool channel", ResourceUtils.getResourceAsFile("/image/leguman.jpg")); webTestClient.post() .uri(CHANNELS_PATH + "/groups") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchange() .expectStatus().isCreated() .expectBody().isEmpty(); } private String getServerUri() { return "http://localhost:" + port; } private static void deleteApiDir() { Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { deleteRecursively(Path.of(DATADIR_PATH)); } catch (IOException e) { // Don't throw an exception because it's expected this might fail (file still in use, mostly) System.out.println(e.getMessage()); } })); } private static void deleteRecursively(Path path) throws IOException { final List exceptions = new ArrayList<>(); Files.walkFileTree(path, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { try { Files.deleteIfExists(file); } catch (IOException e) { exceptions.add(e); } return FileVisitResult.CONTINUE; } @Override public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { if (exc != null) { throw exc; } try { Files.delete(dir); } catch (IOException e) { exceptions.add(e); } return FileVisitResult.CONTINUE; } }); // If any exceptions occurred, throw a combined exception if (!exceptions.isEmpty()) { var wrapper = new IOException("Errors recursively deleting " + path); for (IOException exception : exceptions) { wrapper.addSuppressed(exception); } throw wrapper; } } } ================================================ FILE: app/src/test/java/io/xeres/app/AppCodingRulesTest.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app; import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.domain.JavaModifier; import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import io.xeres.app.application.environment.CommandArgument; import io.xeres.app.service.UiBridgeService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import jakarta.persistence.Entity; import org.slf4j.Logger; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; import static com.tngtech.archunit.library.GeneralCodingRules.*; @SuppressWarnings("unused") @AnalyzeClasses(packagesOf = XeresApplication.class, importOptions = ImportOption.DoNotIncludeTests.class) class AppCodingRulesTest { @ArchTest private final ArchRule noAccessToStandardStreams = noClasses() .should(ACCESS_STANDARD_STREAMS) .andShould() .notBe(CommandArgument.class) .because("We use loggers"); @ArchTest private final ArchRule noJavaUtilLogging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING; @ArchTest private final ArchRule loggersShouldBeFinalAndStatic = fields().that().haveRawType(Logger.class) .should().bePrivate().orShould().beProtected() .andShould().beStatic().orShould().beProtected() .andShould().beFinal() .because("we agreed on this convention"); @ArchTest private final ArchRule noFieldInjection = NO_CLASSES_SHOULD_USE_FIELD_INJECTION .because("Constructor injection allow detection of cyclic dependencies"); @ArchTest private final ArchRule rsServiceNaming = classes() .that().areAssignableTo(RsService.class) .should().haveSimpleNameEndingWith("RsService"); /** * Items should have a public no-arg constructor and have an empty clone method * that returns their own type. */ @ArchTest private final ArchRule rsItem = classes() .that().areAssignableTo(Item.class) .and().doNotBelongToAnyOf(Item.class) .should(new ArchCondition<>("have a public constructor without parameters") { @Override public void check(JavaClass javaClass, ConditionEvents events) { boolean satisfied = javaClass.getConstructors().stream() .anyMatch(constructor -> constructor.getModifiers().contains(JavaModifier.PUBLIC) && constructor.getParameters().isEmpty() ); String message = javaClass.getDescription() + (satisfied ? " has" : " does not have") + " a public constructor without parameters"; events.add(new SimpleConditionEvent(javaClass, satisfied, message)); } }) .andShould(new ArchCondition<>("have a clone() method that returns their class") { @Override public void check(JavaClass javaClass, ConditionEvents events) { boolean satisfied = javaClass.getMethods().stream() .anyMatch(method -> method.getName().equals("clone") && method.getParameters().isEmpty() && method.getReturnType().equals(javaClass) && method.getModifiers().contains(JavaModifier.PUBLIC)); String message = javaClass.getDescription() + (satisfied ? " has" : " does not have") + " a clone() method returning its own type"; events.add(new SimpleConditionEvent(javaClass, satisfied, message)); } }) .andShould(new ArchCondition<>("have a toString() method that returns a meaningful description") { @Override public void check(JavaClass javaClass, ConditionEvents events) { boolean satisfied = javaClass.getMethods().stream() .anyMatch(method -> method.getName().equals("toString") && method.getParameters().isEmpty() && method.getModifiers().contains(JavaModifier.PUBLIC) && method.getReturnType().getName().equals("java.lang.String") && method.getOwner().equals(javaClass) ); String message = javaClass.getDescription() + (satisfied ? " has" : " does not have") + " a toString() method returning a meaningful description"; events.add(new SimpleConditionEvent(javaClass, satisfied, message)); } }); /** * JPA entities should have a public or protected no-arg constructor. */ @ArchTest private final ArchRule jpaEntitiesEmptyConstructor = classes() .that().areAnnotatedWith(Entity.class) .should(new ArchCondition<>("have a public constructor without parameters") { @Override public void check(JavaClass javaClass, ConditionEvents events) { boolean satisfied = javaClass.getConstructors().stream() .anyMatch(constructor -> (constructor.getModifiers().contains(JavaModifier.PUBLIC) || constructor.getModifiers().contains(JavaModifier.PROTECTED)) && constructor.getParameters().isEmpty() ); String message = javaClass.getDescription() + (satisfied ? " has" : " does not have") + " a public constructor without parameters"; events.add(new SimpleConditionEvent(javaClass, satisfied, message)); } }); /** * The following rule helps avoid dependencies from app to the UI. Everything should be done with * server notifications, message queues and, if strictly necessary, UiBridgeService. */ @ArchTest private final ArchRule noUiAccess = noClasses() .that().resideInAPackage("..app..") .and().doNotBelongToAnyOf(XeresApplication.class, UiBridgeService.class) .should().accessClassesThat().resideInAPackage("..ui.."); @ArchTest private final ArchRule utilityClass = classes() .that().haveSimpleNameEndingWith("Utils") .should(new ArchCondition<>("have a private constructor without parameters") { @Override public void check(JavaClass javaClass, ConditionEvents events) { boolean satisfied = javaClass.getConstructors().stream() .anyMatch(constructor -> constructor.getModifiers().contains(JavaModifier.PRIVATE) && constructor.getParameters().isEmpty() ); String message = javaClass.getDescription() + (satisfied ? " has" : " does not have") + " a private constructor without parameters"; events.add(new SimpleConditionEvent(javaClass, satisfied, message)); } } ) .andShould().haveModifier(JavaModifier.FINAL); @ArchTest private final ArchRule gxsIdFieldNaming = fields().that().haveRawType(GxsId.class) .should().haveNameEndingWith("GxsId") .orShould().haveName("gxsId") .because("The name could be confused with database IDs"); @ArchTest private final ArchRule msgIdFieldNaming = fields().that().haveRawType(MsgId.class) .should().haveNameEndingWith("MsgId") .orShould().haveName("msgId") .because("The name could be confused with database IDs"); } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/AbstractControllerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import tools.jackson.databind.ObjectMapper; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; public abstract class AbstractControllerTest { @Autowired protected ObjectMapper objectMapper; @Autowired protected MockMvc mvc; protected MockHttpServletRequestBuilder getJson(String uri) { return get(uri, APPLICATION_JSON); } protected MockHttpServletRequestBuilder get(String uri, MediaType mediaType) { return MockMvcRequestBuilders.get(uri) .accept(mediaType); } protected MockHttpServletRequestBuilder postJson(String uri, Object body) { var json = objectMapper.writeValueAsString(body); return post(uri) .contentType(APPLICATION_JSON) .accept(APPLICATION_JSON) .content(json); } protected MockHttpServletRequestBuilder putJson(String uri, Object body) { var json = objectMapper.writeValueAsString(body); return put(uri) .contentType(APPLICATION_JSON) .accept(APPLICATION_JSON) .content(json); } protected MockHttpServletRequestBuilder patchJson(String uri, Object body) { var json = objectMapper.writeValueAsString(body); return patch(uri) .contentType("application/json-patch+json") .accept(APPLICATION_JSON) .content(json); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/PathConfigTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller; import io.xeres.common.rest.PathConfig; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class PathConfigTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(PathConfig.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/board/BoardControllerTest.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.board; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.gxs.BoardGroupItemFakes; import io.xeres.app.database.model.gxs.BoardMessageItemFakes; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.service.BoardMessageService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.board.BoardRsService; import io.xeres.app.xrs.service.board.item.BoardGroupItem; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.rest.board.UpdateBoardMessageReadRequest; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.Collections; import java.util.List; import java.util.Optional; import static io.xeres.common.rest.PathConfig.BOARDS_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(BoardController.class) @AutoConfigureMockMvc(addFilters = false) class BoardControllerTest extends AbstractControllerTest { private static final String BASE_URL = BOARDS_PATH; @MockitoBean private BoardRsService boardRsService; @MockitoBean private IdentityService identityService; @MockitoBean private BoardMessageService boardMessageService; @MockitoBean private UnHtmlService unHtmlService; @Test void GetBoardGroups_Success() throws Exception { var boardGroups = List.of(BoardGroupItemFakes.createBoardGroupItem(), BoardGroupItemFakes.createBoardGroupItem()); when(boardRsService.findAllGroups()).thenReturn(boardGroups); mvc.perform(getJson(BASE_URL + "/groups")) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(boardGroups.get(0).getId()), Long.class)) .andExpect(jsonPath("$.[0].name", is(boardGroups.get(0).getName()))) .andExpect(jsonPath("$.[1].id").value(is(boardGroups.get(1).getId()), Long.class)); verify(boardRsService).findAllGroups(); } @Test void CreateBoardGroup_Success() throws Exception { var ownIdentity = IdentityFakes.createOwn(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); when(boardRsService.createBoardGroup(eq(ownIdentity.getGxsId()), eq("foo"), eq("the best"), any())).thenReturn(1L); mvc.perform(multipart(BASE_URL + "/groups") .param("name", "foo") .param("description", "the best")) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + BOARDS_PATH + "/groups/" + 1L)); verify(boardRsService).createBoardGroup(eq(ownIdentity.getGxsId()), anyString(), anyString(), any()); } @Test void UpdateBoardGroup_Success() throws Exception { mvc.perform(multipart(HttpMethod.PUT, BASE_URL + "/groups/1") .param("name", "foo") .param("description", "the best")) .andExpect(status().isNoContent()); verify(boardRsService).updateBoardGroup(1L, "foo", "the best", null, false); } @Test void UpdateBoardGroup_WithUpdateImageFlag_Success() throws Exception { mvc.perform(multipart(HttpMethod.PUT, BASE_URL + "/groups/1") .param("name", "foo") .param("description", "the best") .param("updateImage", "true")) .andExpect(status().isNoContent()); verify(boardRsService).updateBoardGroup(1L, "foo", "the best", null, true); } @Test void GetBoardByGroupId_Success() throws Exception { long groupId = 1L; var boardGroupItem = new BoardGroupItem(null, "foobar"); when(boardRsService.findById(groupId)).thenReturn(Optional.of(boardGroupItem)); mvc.perform(getJson(BASE_URL + "/groups/" + groupId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is(boardGroupItem.getId()), Long.class)); } @Test void UpdateBoardMessageReadFlag_Success() throws Exception { var request = new UpdateBoardMessageReadRequest(1L, true); mvc.perform(patchJson(BASE_URL + "/messages", request)) .andExpect(status().isOk()); verify(boardRsService).setMessageReadState(1L, true); } @Test void GetBoardUnreadCount_Success() throws Exception { long groupId = 1L; int unreadCount = 5; when(boardRsService.getUnreadCount(groupId)).thenReturn(unreadCount); mvc.perform(getJson(BASE_URL + "/groups/" + groupId + "/unread-count")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().string(String.valueOf(unreadCount))); verify(boardRsService).getUnreadCount(groupId); } @Test void SubscribeToBoardGroup_Success() throws Exception { long groupId = 1L; mvc.perform(put(BASE_URL + "/groups/" + groupId + "/subscription")) .andExpect(status().isNoContent()); verify(boardRsService).subscribeToBoardGroup(groupId); } @Test void SetAllGroupMessagesReadState_Success() throws Exception { long groupId = 1L; mvc.perform(put(BASE_URL + "/groups/" + groupId + "/read?read=true")) .andExpect(status().isNoContent()); verify(boardRsService).setAllGroupMessagesReadState(groupId, true); } @Test void UnsubscribeFromBoardGroup_Success() throws Exception { long groupId = 1L; mvc.perform(delete(BASE_URL + "/groups/" + groupId + "/subscription")) .andExpect(status().isNoContent()); verify(boardRsService).unsubscribeFromBoardGroup(groupId); } @Test void GetBoardMessages_Success() throws Exception { long groupId = 1L; Page boardMessages = new PageImpl<>(List.of(BoardMessageItemFakes.createBoardMessageItem(), BoardMessageItemFakes.createBoardMessageItem())); when(boardRsService.findAllMessages(eq(groupId), any(Pageable.class))).thenReturn(boardMessages); when(boardMessageService.getAuthorsMapFromMessages(boardMessages)).thenReturn(Collections.emptyMap()); when(boardMessageService.getMessagesMapFromSummaries(groupId, boardMessages)).thenReturn(Collections.emptyMap()); mvc.perform(getJson(BASE_URL + "/groups/" + groupId + "/messages")) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.size()").value(is(boardMessages.getTotalElements()), Long.class)); verify(boardRsService).findAllMessages(eq(groupId), any(Pageable.class)); verify(boardMessageService).getAuthorsMapFromMessages(boardMessages); verify(boardMessageService).getMessagesMapFromSummaries(groupId, boardMessages); } @Test void GetBoardMessage_Success() throws Exception { long id = 1L; BoardMessageItem boardMessage = BoardMessageItemFakes.createBoardMessageItem(); when(boardRsService.findMessageById(id)).thenReturn(Optional.of(boardMessage)); when(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty()); when(boardRsService.findAllMessagesIncludingOlds(any(GxsId.class), anySet())).thenReturn(List.of()); mvc.perform(getJson(BASE_URL + "/messages/" + id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is((int) boardMessage.getId()))); verify(boardRsService).findMessageById(id); verify(identityService).findByGxsId(null); verify(boardRsService).findAllMessagesIncludingOlds(any(GxsId.class), anySet()); } @Test void CreateBoardMessage_Success() throws Exception { var ownIdentity = IdentityFakes.createOwn(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); when(boardRsService.createBoardMessage( eq(ownIdentity), eq(1L), eq("Test Title"), eq("Test Content"), eq("https://zapek.com"), any() )).thenReturn(1L); mvc.perform(multipart(BASE_URL + "/messages") .param("boardId", "1") .param("title", "Test Title") .param("content", "Test Content") .param("link", "https://zapek.com")) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + BOARDS_PATH + "/messages/" + 1L)); verify(boardRsService).createBoardMessage( eq(ownIdentity), anyLong(), anyString(), anyString(), anyString(), any() ); } @Test void DownloadBoardGroupImage_Success() throws Exception { long groupId = 1L; byte[] pngImage = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d}; var boardGroupItem = new BoardGroupItem(null, "foobar"); boardGroupItem.setImage(pngImage); when(boardRsService.findById(groupId)).thenReturn(Optional.of(boardGroupItem)); mvc.perform(get(BOARDS_PATH + "/groups/" + groupId + "/image", MediaType.IMAGE_PNG)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.IMAGE_PNG)); } @Test void DownloadBoardGroupImage_Empty_Returns204() throws Exception { long groupId = 1L; var boardGroupItem = new BoardGroupItem(null, "foobar"); when(boardRsService.findById(groupId)).thenReturn(Optional.of(boardGroupItem)); mvc.perform(get(BOARDS_PATH + "/groups/" + groupId + "/image", MediaType.IMAGE_PNG)) .andExpect(status().isNoContent()); } @Test void DownloadBoardGroupImage_NotFound() throws Exception { long groupId = 1L; when(boardRsService.findById(groupId)).thenReturn(Optional.empty()); mvc.perform(get(BOARDS_PATH + "/groups/" + groupId + "/image", MediaType.IMAGE_PNG)) .andExpect(status().isNotFound()); } @Test void DownloadBoardMessageImage_Success() throws Exception { long messageId = 1L; byte[] jpegImage = new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff, (byte) 0xe0, 0x00, 0x00, 0x00, 0x00}; var boardMessageItem = BoardMessageItemFakes.createBoardMessageItem(); boardMessageItem.setImage(jpegImage); when(boardRsService.findMessageById(messageId)).thenReturn(Optional.of(boardMessageItem)); mvc.perform(get(BOARDS_PATH + "/messages/" + messageId + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.IMAGE_JPEG)); } @Test void DownloadBoardMessageImage_Empty_Returns204() throws Exception { long messageId = 1L; var boardMessageItem = BoardMessageItemFakes.createBoardMessageItem(); when(boardRsService.findMessageById(messageId)).thenReturn(Optional.of(boardMessageItem)); mvc.perform(get(BOARDS_PATH + "/messages/" + messageId + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isNoContent()); } @Test void DownloadBoardMessageImage_NotFound() throws Exception { long messageId = 1L; when(boardRsService.findMessageById(messageId)).thenReturn(Optional.empty()); mvc.perform(get(BOARDS_PATH + "/messages/" + messageId + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isNotFound()); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/channel/ChannelControllerTest.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.channel; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.gxs.ChannelGroupItemFakes; import io.xeres.app.database.model.gxs.ChannelMessageItemFakes; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.service.ChannelMessageService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.channel.ChannelRsService; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.rest.channel.UpdateChannelMessageReadRequest; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.Collections; import java.util.List; import java.util.Optional; import static io.xeres.common.rest.PathConfig.CHANNELS_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ChannelController.class) @AutoConfigureMockMvc(addFilters = false) class ChannelControllerTest extends AbstractControllerTest { private static final String BASE_URL = CHANNELS_PATH; @MockitoBean private ChannelRsService channelRsService; @MockitoBean private IdentityService identityService; @MockitoBean private ChannelMessageService channelMessageService; @MockitoBean private UnHtmlService unHtmlService; @Test void GetChannelGroups_Success() throws Exception { var channelGroups = List.of(ChannelGroupItemFakes.createChannelGroupItem(), ChannelGroupItemFakes.createChannelGroupItem()); when(channelRsService.findAllGroups()).thenReturn(channelGroups); mvc.perform(getJson(BASE_URL + "/groups")) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(channelGroups.get(0).getId()), Long.class)) .andExpect(jsonPath("$.[0].name", is(channelGroups.get(0).getName()))) .andExpect(jsonPath("$.[1].id").value(is(channelGroups.get(1).getId()), Long.class)); verify(channelRsService).findAllGroups(); } @Test void CreateChannelGroup_Success() throws Exception { var ownIdentity = IdentityFakes.createOwn(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); when(channelRsService.createChannelGroup(eq(ownIdentity.getGxsId()), eq("foo"), eq("the best"), any())).thenReturn(1L); mvc.perform(multipart(BASE_URL + "/groups") .param("name", "foo") .param("description", "the best")) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + CHANNELS_PATH + "/groups/" + 1L)); verify(channelRsService).createChannelGroup(eq(ownIdentity.getGxsId()), anyString(), anyString(), any()); } @Test void UpdateChannelGroup_Success() throws Exception { mvc.perform(multipart(HttpMethod.PUT, BASE_URL + "/groups/1") .param("name", "foo") .param("description", "the best")) .andExpect(status().isNoContent()); verify(channelRsService).updateChannelGroup(1L, "foo", "the best", null, false); } @Test void UpdateChannelGroup_WithUpdateImageFlag_Success() throws Exception { mvc.perform(multipart(HttpMethod.PUT, BASE_URL + "/groups/1") .param("name", "foo") .param("description", "the best") .param("updateImage", "true")) .andExpect(status().isNoContent()); verify(channelRsService).updateChannelGroup(1L, "foo", "the best", null, true); } @Test void GetChannelGroupById_Success() throws Exception { long groupId = 1L; var channelGroupItem = ChannelGroupItemFakes.createChannelGroupItem(); when(channelRsService.findById(groupId)).thenReturn(Optional.of(channelGroupItem)); mvc.perform(getJson(BASE_URL + "/groups/" + groupId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is(channelGroupItem.getId()), Long.class)); } @Test void UpdateChannelMessageReadFlag_Success() throws Exception { var request = new UpdateChannelMessageReadRequest(1L, true); mvc.perform(patchJson(BASE_URL + "/messages", request)) .andExpect(status().isOk()); verify(channelRsService).setMessageReadState(1L, true); } @Test void GetChannelUnreadCount_Success() throws Exception { long groupId = 1L; int unreadCount = 5; when(channelRsService.getUnreadCount(groupId)).thenReturn(unreadCount); mvc.perform(getJson(BASE_URL + "/groups/" + groupId + "/unread-count")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().string(String.valueOf(unreadCount))); verify(channelRsService).getUnreadCount(groupId); } @Test void SubscribeToChannelGroup_Success() throws Exception { long groupId = 1L; mvc.perform(put(BASE_URL + "/groups/" + groupId + "/subscription")) .andExpect(status().isNoContent()); verify(channelRsService).subscribeToChannelGroup(groupId); } @Test void SetAllGroupMessagesReadState_Success() throws Exception { long groupId = 1L; mvc.perform(put(BASE_URL + "/groups/" + groupId + "/read?read=true")) .andExpect(status().isNoContent()); verify(channelRsService).setAllGroupMessagesReadState(groupId, true); } @Test void UnsubscribeFromChannelGroup_Success() throws Exception { long groupId = 1L; mvc.perform(delete(BASE_URL + "/groups/" + groupId + "/subscription")) .andExpect(status().isNoContent()); verify(channelRsService).unsubscribeFromChannelGroup(groupId); } @Test void GetChannelMessages_Success() throws Exception { long groupId = 1L; Page channelMessages = new PageImpl<>(List.of(ChannelMessageItemFakes.createChannelMessageItem(), ChannelMessageItemFakes.createChannelMessageItem())); when(channelRsService.findAllMessages(eq(groupId), any(Pageable.class))).thenReturn(channelMessages); when(channelMessageService.getAuthorsMapFromMessages(channelMessages)).thenReturn(Collections.emptyMap()); when(channelMessageService.getMessagesMapFromSummaries(groupId, channelMessages)).thenReturn(Collections.emptyMap()); mvc.perform(getJson(BASE_URL + "/groups/" + groupId + "/messages")) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.size()").value(is(channelMessages.getTotalElements()), Long.class)); verify(channelRsService).findAllMessages(eq(groupId), any(Pageable.class)); verify(channelMessageService).getAuthorsMapFromMessages(channelMessages); verify(channelMessageService).getMessagesMapFromSummaries(groupId, channelMessages); } @Test void GetChannelMessage_Success() throws Exception { long id = 1L; ChannelMessageItem channelMessage = ChannelMessageItemFakes.createChannelMessageItem(); when(channelRsService.findMessageById(id)).thenReturn(Optional.of(channelMessage)); when(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty()); when(channelRsService.findAllMessagesIncludingOlds(any(GxsId.class), anySet())).thenReturn(List.of()); mvc.perform(getJson(BASE_URL + "/messages/" + id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is((int) channelMessage.getId()))); verify(channelRsService).findMessageById(id); verify(identityService).findByGxsId(null); verify(channelRsService).findAllMessagesIncludingOlds(any(GxsId.class), anySet()); } @Test void CreateChannelMessage_Success() throws Exception { var ownIdentity = IdentityFakes.createOwn(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); when(channelRsService.createChannelMessage( eq(ownIdentity), eq(1L), eq("Test Title"), eq("Test Content"), any(), any(), eq(0L) )).thenReturn(1L); mvc.perform(multipart(BASE_URL + "/messages") .param("channelId", "1") .param("title", "Test Title") .param("content", "Test Content")) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + CHANNELS_PATH + "/messages/" + 1L)); verify(channelRsService).createChannelMessage( eq(ownIdentity), anyLong(), anyString(), anyString(), any(), any(), anyLong() ); } @Test void CreateChannelMessage_WithOptionalFields_Success() throws Exception { var ownIdentity = IdentityFakes.createOwn(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); when(channelRsService.createChannelMessage( eq(ownIdentity), eq(1L), eq("Test Title"), eq("Test Content"), any(), any(), eq(5L) )).thenReturn(1L); mvc.perform(multipart(BASE_URL + "/messages") .param("channelId", "1") .param("title", "Test Title") .param("content", "Test Content") .param("originalId", "5")) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + CHANNELS_PATH + "/messages/" + 1L)); verify(channelRsService).createChannelMessage( eq(ownIdentity), anyLong(), anyString(), anyString(), any(), any(), anyLong() ); } @Test void DownloadChannelGroupImage_Success() throws Exception { long groupId = 1L; byte[] pngImage = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d}; var channelGroupItem = ChannelGroupItemFakes.createChannelGroupItem(); channelGroupItem.setImage(pngImage); when(channelRsService.findById(groupId)).thenReturn(Optional.of(channelGroupItem)); mvc.perform(get(CHANNELS_PATH + "/groups/" + groupId + "/image", MediaType.IMAGE_PNG)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.IMAGE_PNG)); } @Test void DownloadChannelGroupImage_Empty_Returns204() throws Exception { long groupId = 1L; var channelGroupItem = ChannelGroupItemFakes.createChannelGroupItem(); when(channelRsService.findById(groupId)).thenReturn(Optional.of(channelGroupItem)); mvc.perform(get(CHANNELS_PATH + "/groups/" + groupId + "/image", MediaType.IMAGE_PNG)) .andExpect(status().isNoContent()); } @Test void DownloadChannelGroupImage_NotFound() throws Exception { long groupId = 1L; when(channelRsService.findById(groupId)).thenReturn(Optional.empty()); mvc.perform(get(CHANNELS_PATH + "/groups/" + groupId + "/image", MediaType.IMAGE_PNG)) .andExpect(status().isNotFound()); } @Test void DownloadChannelMessageImage_Success() throws Exception { long messageId = 1L; byte[] jpegImage = new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff, (byte) 0xe0, 0x00, 0x00, 0x00, 0x00}; var channelMessageItem = ChannelMessageItemFakes.createChannelMessageItem(); channelMessageItem.setImage(jpegImage); when(channelRsService.findMessageById(messageId)).thenReturn(Optional.of(channelMessageItem)); mvc.perform(get(CHANNELS_PATH + "/messages/" + messageId + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.IMAGE_JPEG)); } @Test void DownloadChannelMessageImage_Empty_Returns204() throws Exception { long messageId = 1L; var channelMessageItem = ChannelMessageItemFakes.createChannelMessageItem(); when(channelRsService.findMessageById(messageId)).thenReturn(Optional.of(channelMessageItem)); mvc.perform(get(CHANNELS_PATH + "/messages/" + messageId + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isNoContent()); } @Test void DownloadChannelMessageImage_NotFound() throws Exception { long messageId = 1L; when(channelRsService.findMessageById(messageId)).thenReturn(Optional.empty()); mvc.perform(get(CHANNELS_PATH + "/messages/" + messageId + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isNotFound()); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/chat/ChatControllerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.chat; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.chat.ChatBacklog; import io.xeres.app.database.model.chat.ChatRoomBacklog; import io.xeres.app.database.model.chat.ChatRoomFakes; import io.xeres.app.database.model.chat.DistantChatBacklog; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.service.IdentityService; import io.xeres.app.service.LocationService; import io.xeres.app.xrs.service.chat.ChatBacklogService; import io.xeres.app.xrs.service.chat.ChatRsService; import io.xeres.app.xrs.service.chat.RoomFlags; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.chat.ChatRoomContext; import io.xeres.common.message.chat.ChatRoomInfo; import io.xeres.common.message.chat.ChatRoomLists; import io.xeres.common.message.chat.ChatRoomUser; import io.xeres.common.rest.chat.ChatRoomVisibility; import io.xeres.common.rest.chat.CreateChatRoomRequest; import io.xeres.common.rest.chat.InviteToChatRoomRequest; import org.bouncycastle.util.encoders.Base64; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.EnumSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import static io.xeres.common.rest.PathConfig.CHAT_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ChatController.class) @AutoConfigureMockMvc(addFilters = false) class ChatControllerTest extends AbstractControllerTest { private static final String BASE_URL = CHAT_PATH; @MockitoBean private ChatRsService chatRsService; @MockitoBean private ChatBacklogService chatBacklogService; @MockitoBean private LocationService locationService; @MockitoBean private IdentityService identityService; @Test void CreateChatRoom_Public_Success() throws Exception { var chatRoomRequest = new CreateChatRoomRequest("The Elephant Room", "Nothing to see here", ChatRoomVisibility.PUBLIC, false); when(chatRsService.createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.of(RoomFlags.PUBLIC), false)).thenReturn(1L); mvc.perform(postJson(BASE_URL + "/rooms", chatRoomRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + CHAT_PATH + "/rooms/" + 1L)); verify(chatRsService).createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.of(RoomFlags.PUBLIC), false); } @Test void CreateChatRoom_Private_Success() throws Exception { var chatRoomRequest = new CreateChatRoomRequest("The Elephant Room", "Nothing to see here", ChatRoomVisibility.PRIVATE, false); when(chatRsService.createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.noneOf(RoomFlags.class), false)).thenReturn(1L); mvc.perform(postJson(BASE_URL + "/rooms", chatRoomRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + CHAT_PATH + "/rooms/" + 1L)); verify(chatRsService).createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.noneOf(RoomFlags.class), false); } @Test void InviteToChatRoom_Success() throws Exception { var chatRoomId = 1L; var locations = Set.of(LocationFakes.createLocation().getLocationIdentifier(), LocationFakes.createLocation().getLocationIdentifier()); var inviteRequest = new InviteToChatRoomRequest(chatRoomId, locations.stream() .map(LocationIdentifier::toString) .collect(Collectors.toSet())); mvc.perform(postJson(BASE_URL + "/rooms/invite", inviteRequest)) .andExpect(status().isOk()); verify(chatRsService).inviteLocationsToChatRoom(chatRoomId, locations); } @Test void SubscribeToChatRoom_Success() throws Exception { var id = 1L; mvc.perform(put(BASE_URL + "/rooms/" + id + "/subscription")) .andExpect(status().isNoContent()); verify(chatRsService).joinChatRoom(id); } @Test void UnsubscribeFromChatRoom_Success() throws Exception { var id = 1L; mvc.perform(delete(BASE_URL + "/rooms/" + id + "/subscription")) .andExpect(status().isNoContent()); verify(chatRsService).leaveChatRoom(id); } @Test void GetChatRoomContext_Success() throws Exception { var subscribedChatRoom = new ChatRoomInfo("SubscribedRoom"); var availableChatRoom = new ChatRoomInfo("AvailableRoom"); var chatRoomLists = new ChatRoomLists(); chatRoomLists.addSubscribed(subscribedChatRoom); chatRoomLists.addAvailable(availableChatRoom); var ownIdentity = IdentityFakes.createOwn(); var chatRoomUser = new ChatRoomUser(ownIdentity.getName(), ownIdentity.getGxsId(), ownIdentity.getId()); when(chatRsService.getChatRoomContext()).thenReturn(new ChatRoomContext(chatRoomLists, chatRoomUser)); mvc.perform(getJson(BASE_URL + "/rooms")) .andExpect(status().isOk()) .andExpect(jsonPath("$.chatRooms.subscribed[0].name", is(subscribedChatRoom.getName()))) .andExpect(jsonPath("$.chatRooms.available[0].name", is(availableChatRoom.getName()))) .andExpect(jsonPath("$.identity.nickname", is(ownIdentity.getName()))) .andExpect(jsonPath("$.identity.gxsId.bytes", is(Base64.toBase64String(ownIdentity.getGxsId().getBytes())))); verify(chatRsService).getChatRoomContext(); } @Test void GetChatMessages_Default_Success() throws Exception { var creation = Instant.now(); var location = LocationFakes.createLocation(); var chatBacklog = new ChatBacklog(location, false, "hey"); chatBacklog.setCreated(creation); when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); when(chatBacklogService.getMessages(eq(location), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog)); mvc.perform(getJson(BASE_URL + "/chats/" + location.getId() + "/messages")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].created", is(creation.toString()))) .andExpect(jsonPath("$[0].own", is(false))) .andExpect(jsonPath("$[0].message", is("hey"))); verify(chatBacklogService).getMessages(eq(location), any(Instant.class), eq(20)); } @Test void GetChatMessages_WithParameters_Success() throws Exception { var creation = Instant.now(); var location = LocationFakes.createLocation(); var chatBacklog = new ChatBacklog(location, false, "hey"); chatBacklog.setCreated(creation); when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); when(chatBacklogService.getMessages(eq(location), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog)); mvc.perform(getJson(BASE_URL + "/chats/" + location.getId() + "/messages?maxLines=30&from=2024-12-23T22:13")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].created", is(creation.toString()))) .andExpect(jsonPath("$[0].own", is(false))) .andExpect(jsonPath("$[0].message", is("hey"))); verify(chatBacklogService).getMessages(location, LocalDateTime.parse("2024-12-23T22:13").toInstant(ZoneOffset.UTC), 30); } @Test void GetChatRoomMessages_Default_Success() throws Exception { var creation = Instant.now(); var chatRoom = ChatRoomFakes.createChatRoomEntity(); var ownIdentity = IdentityFakes.createOwn(); var chatRoomBacklog = new ChatRoomBacklog(chatRoom, ownIdentity.getGxsId(), "Foobar", "blabla"); chatRoomBacklog.setCreated(creation); when(chatBacklogService.getChatRoomMessages(eq(chatRoom.getRoomId()), any(Instant.class), anyInt())).thenReturn(List.of(chatRoomBacklog)); mvc.perform(getJson(BASE_URL + "/rooms/" + chatRoom.getRoomId() + "/messages")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].created", is(creation.toString()))) .andExpect(jsonPath("$[0].gxsId.bytes", is(Base64.toBase64String(ownIdentity.getGxsId().getBytes())))) .andExpect(jsonPath("$[0].nickname", is("Foobar"))) .andExpect(jsonPath("$[0].message", is("blabla"))); verify(chatBacklogService).getChatRoomMessages(eq(chatRoom.getRoomId()), any(Instant.class), eq(50)); } @Test void GetChatRoomMessages_WithParameters_Success() throws Exception { var creation = Instant.now(); var chatRoom = ChatRoomFakes.createChatRoomEntity(); var ownIdentity = IdentityFakes.createOwn(); var chatRoomBacklog = new ChatRoomBacklog(chatRoom, ownIdentity.getGxsId(), "Foobar", "blabla"); chatRoomBacklog.setCreated(creation); when(chatBacklogService.getChatRoomMessages(eq(chatRoom.getRoomId()), any(Instant.class), anyInt())).thenReturn(List.of(chatRoomBacklog)); mvc.perform(getJson(BASE_URL + "/rooms/" + chatRoom.getRoomId() + "/messages?maxLines=80&from=2024-12-24T01:27")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].created", is(creation.toString()))) .andExpect(jsonPath("$[0].gxsId.bytes", is(Base64.toBase64String(ownIdentity.getGxsId().getBytes())))) .andExpect(jsonPath("$[0].nickname", is("Foobar"))) .andExpect(jsonPath("$[0].message", is("blabla"))); verify(chatBacklogService).getChatRoomMessages(chatRoom.getRoomId(), LocalDateTime.parse("2024-12-24T01:27").toInstant(ZoneOffset.UTC), 80); } @Test void DeleteChatMessages_Success() throws Exception { var location = LocationFakes.createLocation(); when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); mvc.perform(delete(BASE_URL + "/chats/" + location.getId() + "/messages")) .andExpect(status().isNoContent()); verify(chatBacklogService).deleteMessages(location); } @Test void DeleteChatMessages_NotFound() throws Exception { var locationId = 123L; when(locationService.findLocationById(locationId)).thenReturn(Optional.empty()); mvc.perform(delete(BASE_URL + "/chats/" + locationId + "/messages")) .andExpect(status().isNotFound()); } @Test void DeleteChatRoomMessages_Success() throws Exception { var chatRoomId = 1L; mvc.perform(delete(BASE_URL + "/rooms/" + chatRoomId + "/messages")) .andExpect(status().isNoContent()); verify(chatBacklogService).deleteChatRoomMessages(chatRoomId); } @Test void CreateDistantChat_Success() throws Exception { var identity = IdentityFakes.createOwn(); var request = new io.xeres.common.rest.chat.DistantChatRequest(identity.getId()); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); var location = LocationFakes.createLocation(); when(chatRsService.createDistantChat(identity)).thenReturn(location); mvc.perform(postJson(BASE_URL + "/distant-chats", request)) .andExpect(status().isOk()); verify(chatRsService).createDistantChat(identity); } @Test void CreateDistantChat_AlreadyExists() throws Exception { var identity = IdentityFakes.createOwn(); var request = new io.xeres.common.rest.chat.DistantChatRequest(identity.getId()); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); when(chatRsService.createDistantChat(identity)).thenReturn(null); mvc.perform(postJson(BASE_URL + "/distant-chats", request)) .andExpect(status().isConflict()); verify(chatRsService).createDistantChat(identity); } @Test void CreateDistantChat_IdentityNotFound() throws Exception { var identityId = 42L; var request = new io.xeres.common.rest.chat.DistantChatRequest(identityId); when(identityService.findById(identityId)).thenReturn(Optional.empty()); mvc.perform(postJson(BASE_URL + "/distant-chats", request)) .andExpect(status().isNotFound()); } @Test void CloseDistantChat_Success() throws Exception { var identity = IdentityFakes.createOwn(); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); when(chatRsService.closeDistantChat(identity)).thenReturn(true); mvc.perform(delete(BASE_URL + "/distant-chats/" + identity.getId())) .andExpect(status().isNoContent()); verify(chatRsService).closeDistantChat(identity); } @Test void CloseDistantChat_NotFound() throws Exception { var identity = IdentityFakes.createOwn(); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); when(chatRsService.closeDistantChat(identity)).thenReturn(false); mvc.perform(delete(BASE_URL + "/distant-chats/" + identity.getId())) .andExpect(status().isNotFound()); verify(chatRsService).closeDistantChat(identity); } @Test void CloseDistantChat_IdentityNotFound() throws Exception { var identityId = 99L; when(identityService.findById(identityId)).thenReturn(Optional.empty()); mvc.perform(delete(BASE_URL + "/distant-chats/" + identityId)) .andExpect(status().isNotFound()); verify(identityService).findById(identityId); } @Test void GetDistantChatMessages_Success() throws Exception { var identity = IdentityFakes.createOwn(); var creation = Instant.now(); var chatBacklog = new DistantChatBacklog(identity, false, "hello"); chatBacklog.setCreated(creation); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); when(chatBacklogService.getDistantMessages(eq(identity), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog)); mvc.perform(getJson(BASE_URL + "/distant-chats/" + identity.getId() + "/messages")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].created", is(creation.toString()))) .andExpect(jsonPath("$[0].message", is("hello"))); verify(identityService).findById(identity.getId()); verify(chatBacklogService).getDistantMessages(eq(identity), any(Instant.class), anyInt()); } @Test void GetDistantChatMessages_Parameters_Success() throws Exception { var identity = IdentityFakes.createOwn(); var creation = Instant.now(); var from = creation.minus(1, ChronoUnit.DAYS); var chatBacklog = new DistantChatBacklog(identity, false, "hello"); chatBacklog.setCreated(creation); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); when(chatBacklogService.getDistantMessages(eq(identity), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog)); mvc.perform(getJson(BASE_URL + "/distant-chats/" + identity.getId() + "/messages?from=" + from.toString() + "&maxLines=5")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].created", is(creation.toString()))) .andExpect(jsonPath("$[0].message", is("hello"))); verify(identityService).findById(identity.getId()); verify(chatBacklogService).getDistantMessages(identity, from, 5); } @Test void GetDistantChatMessages_IdentityNotFound() throws Exception { var identityId = 77L; when(identityService.findById(identityId)).thenReturn(Optional.empty()); mvc.perform(getJson(BASE_URL + "/distant-chats/" + identityId + "/messages")) .andExpect(status().isNotFound()); verify(identityService).findById(identityId); } @Test void DeleteDistantChatMessages_Success() throws Exception { var identity = IdentityFakes.createOwn(); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); mvc.perform(delete(BASE_URL + "/distant-chats/" + identity.getId() + "/messages")) .andExpect(status().isNoContent()); verify(chatBacklogService).deleteDistantMessages(identity); } @Test void DeleteDistantChatMessages_IdentityNotFound() throws Exception { var identityId = 88L; when(identityService.findById(identityId)).thenReturn(Optional.empty()); mvc.perform(delete(BASE_URL + "/distant-chats/" + identityId + "/messages")) .andExpect(status().isNotFound()); verify(identityService).findById(identityId); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/chat/ChatMessageControllerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.chat; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.service.MessageService; import io.xeres.app.xrs.service.chat.ChatRsService; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.MessagePath; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.ChatMessage; import io.xeres.common.message.chat.ChatRoomMessage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class ChatMessageControllerTest { @Mock private ChatRsService chatRsService; @Mock private MessageService messageService; @InjectMocks private ChatMessageController controller; @Test void processPrivateChatMessage_sendsPrivateMessageAndNotifiesConsumers() { var location = LocationFakes.createLocation(); String dest = location.getLocationIdentifier().toString(); var msg = new ChatMessage("hello"); controller.processPrivateChatMessageFromProducer(dest, MessageType.CHAT_PRIVATE_MESSAGE, msg); verify(chatRsService).sendPrivateMessage(LocationIdentifier.fromString(dest), "hello"); verify(messageService).sendToConsumers(MessagePath.chatPrivateDestination(), MessageType.CHAT_PRIVATE_MESSAGE, LocationIdentifier.fromString(dest), msg); assertTrue(msg.isOwn()); } @Test void processDistantChatMessage_sendsPrivateMessageAndNotifiesConsumers() { var location = LocationFakes.createLocation(); String dest = location.getLocationIdentifier().toString(); var msg = new ChatMessage("hiya"); controller.processDistantChatMessageFromProducer(dest, MessageType.CHAT_PRIVATE_MESSAGE, msg); verify(chatRsService).sendPrivateMessage(GxsId.fromString(dest), "hiya"); verify(messageService).sendToConsumers(MessagePath.chatDistantDestination(), MessageType.CHAT_PRIVATE_MESSAGE, GxsId.fromString(dest), msg); assertTrue(msg.isOwn()); } @Test void processChatRoomMessage_sendsRoomMessageAndNotifiesConsumers() { String dest = "42"; var crm = new ChatRoomMessage(null, null, "roommsg"); controller.processChatRoomMessageFromProducer(dest, MessageType.CHAT_ROOM_MESSAGE, crm); verify(chatRsService).sendChatRoomMessage(42L, "roommsg"); verify(messageService).sendToConsumers(MessagePath.chatRoomDestination(), MessageType.CHAT_ROOM_MESSAGE, 42L, crm); } @Test void processBroadcastMessage_sendsBroadcast() { var msg = new ChatMessage("broadcast"); controller.processBroadcastMessageFromProducer(MessageType.CHAT_BROADCAST_MESSAGE, msg); verify(chatRsService).sendBroadcastMessage("broadcast"); } @Test void handleException_returnsMessage() { var ex = new RuntimeException("boom"); var result = controller.handleException(ex); assertEquals("boom", result); } @Test void processingUnexpectedMessageType_throwsIllegalStateException() { String dest = "00000000000000000000000000000000"; var msg = new ChatMessage("oops"); assertThrows(IllegalStateException.class, () -> controller.processPrivateChatMessageFromProducer(dest, MessageType.CHAT_BROADCAST_MESSAGE, msg)); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/config/ConfigControllerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.config; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.*; import io.xeres.app.service.backup.BackupService; import io.xeres.app.xrs.service.identity.IdentityRsService; import io.xeres.app.xrs.service.status.StatusRsService; import io.xeres.common.location.Availability; import io.xeres.common.rest.config.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.Optional; import java.util.Set; import static io.xeres.common.rest.PathConfig.*; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ConfigController.class) @AutoConfigureMockMvc(addFilters = false) class ConfigControllerTest extends AbstractControllerTest { private static final String BASE_URL = CONFIG_PATH; @MockitoBean private ProfileService profileService; @MockitoBean private LocationService locationService; @MockitoBean private IdentityRsService identityRsService; @MockitoBean private CapabilityService capabilityService; @MockitoBean private BackupService backupService; @MockitoBean private NetworkService networkService; @MockitoBean private StatusRsService statusRsService; @Test void CreateProfile_Success() throws Exception { var profileRequest = new OwnProfileRequest("test node"); when(profileService.generateProfileKeys(profileRequest.name())).thenReturn(ResourceCreationState.CREATED); mvc.perform(postJson(BASE_URL + "/profile", profileRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + PROFILES_PATH + "/" + 1L)); verify(profileService).generateProfileKeys(profileRequest.name()); } @Test void CreateProfile_Failure() throws Exception { var ownProfileRequest = new OwnProfileRequest("test node"); when(profileService.generateProfileKeys(ownProfileRequest.name())).thenReturn(ResourceCreationState.FAILED); mvc.perform(postJson(BASE_URL + "/profile", ownProfileRequest)) .andExpect(status().isInternalServerError()); verify(profileService).generateProfileKeys(ownProfileRequest.name()); } @Test void CreateProfile_AlreadyExists_Failure() throws Exception { var profileRequest = new OwnProfileRequest("test node"); when(profileService.generateProfileKeys(profileRequest.name())).thenReturn(ResourceCreationState.ALREADY_EXISTS); mvc.perform(postJson(BASE_URL + "/profile", profileRequest)) .andExpect(status().isOk()); verify(profileService).generateProfileKeys(profileRequest.name()); } @ParameterizedTest @NullAndEmptySource @ValueSource(strings = { "This name is way too long and there's no chance it ever gets created as a profile" }) void CreateProfile_BadName_Failure(String name) throws Exception { var ownProfileRequest = new OwnProfileRequest(name); mvc.perform(postJson(BASE_URL + "/profile", ownProfileRequest)) .andExpect(status().isBadRequest()); verifyNoInteractions(profileService); } @Test void CreateLocation_Success() throws Exception { var ownLocationRequest = new OwnLocationRequest("test location"); mvc.perform(postJson(BASE_URL + "/location", ownLocationRequest)) .andExpect(status().isCreated()); verify(locationService).generateOwnLocation(anyString()); } @Test void CreateLocation_AlreadyExists_Success() throws Exception { var ownLocationRequest = new OwnLocationRequest("test location"); when(locationService.generateOwnLocation(anyString())).thenReturn(ResourceCreationState.ALREADY_EXISTS); mvc.perform(postJson(BASE_URL + "/location", ownLocationRequest)) .andExpect(status().isOk()); verify(locationService).generateOwnLocation(anyString()); } @Test void CreateLocation_Failure() throws Exception { var ownLocationRequest = new OwnLocationRequest("test location"); when(locationService.generateOwnLocation(anyString())).thenReturn(ResourceCreationState.FAILED); mvc.perform(postJson(BASE_URL + "/location", ownLocationRequest)) .andExpect(status().isInternalServerError()); } @ParameterizedTest @NullAndEmptySource @ValueSource(strings = { "This name is way too long and there's no chance it ever gets created as a location" }) void CreateLocation_BadName_Failure(String name) throws Exception { var ownLocationRequest = new OwnLocationRequest(name); mvc.perform(postJson(BASE_URL + "/location", ownLocationRequest)) .andExpect(status().isBadRequest()); verifyNoInteractions(locationService); } @Test void GetExternalIpAddress_Success() throws Exception { var ip = "1.1.1.1"; var port = 6667; var location = Location.createLocation("test"); var connection = Connection.from(PeerAddress.from(ip, port)); location.addConnection(connection); when(locationService.findOwnLocation()).thenReturn(Optional.of(location)); mvc.perform(getJson(BASE_URL + "/external-ip")) .andExpect(status().isOk()) .andExpect(jsonPath("$.ip", is(ip))) .andExpect(jsonPath("$.port", is(port))); } @Test void GetExternalIpAddress_NoLocationOrIpAddress_Success() throws Exception { when(locationService.findOwnLocation()).thenReturn(Optional.empty()); mvc.perform(getJson(BASE_URL + "/external-ip")) .andExpect(status().isNotFound()); } @Test void GetInternalIpAddress_Success() throws Exception { var ip = "192.168.1.25"; var port = 1234; var location = Location.createLocation("test"); var connection = Connection.from(PeerAddress.from(ip, port)); location.addConnection(connection); when(networkService.getLocalIpAddress()).thenReturn(ip); when(networkService.getPort()).thenReturn(port); mvc.perform(getJson(BASE_URL + "/internal-ip")) .andExpect(status().isOk()) .andExpect(jsonPath("$.ip", is(ip))) .andExpect(jsonPath("$.port", is(port))); } @Test void GetInternalIpAddress_NoLocationOrIpAddress_Success() throws Exception { when(locationService.findOwnLocation()).thenReturn(Optional.empty()); mvc.perform(getJson(BASE_URL + "/internalIp")) .andExpect(status().isNotFound()); } @Test void GetHostname_Success() throws Exception { var hostname = "foo.bar.com"; when(locationService.getHostname()).thenReturn(hostname); mvc.perform(getJson(BASE_URL + "/hostname")) .andExpect(status().isOk()) .andExpect(jsonPath("$.hostname", is(hostname))); } @Test void GetUsername_Success() throws Exception { var username = "foobar"; when(locationService.getUsername()).thenReturn(username); mvc.perform(getJson(BASE_URL + "/username")) .andExpect(status().isOk()) .andExpect(jsonPath("$.username", is(username))); } @Test void CreateIdentity_Signed_Success() throws Exception { var identity = IdentityFakes.createOwn(); var identityRequest = new OwnIdentityRequest(identity.getName(), false); when(identityRsService.generateOwnIdentity(identityRequest.name(), true)).thenReturn(ResourceCreationState.CREATED); mvc.perform(postJson(BASE_URL + "/identity", identityRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + IDENTITIES_PATH + "/" + identity.getId())); verify(identityRsService).generateOwnIdentity(identityRequest.name(), true); } @Test void CreateIdentity_Anonymous_Success() throws Exception { var identity = IdentityFakes.createOwn(); var identityRequest = new OwnIdentityRequest(identity.getName(), true); when(identityRsService.generateOwnIdentity(identityRequest.name(), false)).thenReturn(ResourceCreationState.CREATED); mvc.perform(postJson(BASE_URL + "/identity", identityRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + IDENTITIES_PATH + "/" + identity.getId())); verify(identityRsService).generateOwnIdentity(identityRequest.name(), false); } @Test void GetCapabilities_Success() throws Exception { var capability = "autostart"; when(capabilityService.getCapabilities()).thenReturn(Set.of(capability)); mvc.perform(getJson(BASE_URL + "/capabilities")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0]", is(capability))); verify(capabilityService).getCapabilities(); } @Test void CreateIdentity_Failure() throws Exception { var identityRequest = new OwnIdentityRequest("test identity", false); when(identityRsService.generateOwnIdentity(identityRequest.name(), true)).thenReturn(ResourceCreationState.FAILED); mvc.perform(postJson(BASE_URL + "/identity", identityRequest)) .andExpect(status().isInternalServerError()); verify(identityRsService).generateOwnIdentity(identityRequest.name(), true); } @Test void CreateIdentity_AlreadyExists() throws Exception { var identityRequest = new OwnIdentityRequest("test identity", false); when(identityRsService.generateOwnIdentity(identityRequest.name(), true)).thenReturn(ResourceCreationState.ALREADY_EXISTS); mvc.perform(postJson(BASE_URL + "/identity", identityRequest)) .andExpect(status().isOk()); verify(identityRsService).generateOwnIdentity(identityRequest.name(), true); } @Test void ChangeAvailability_Success() throws Exception { var availability = Availability.AVAILABLE; when(locationService.hasOwnLocation()).thenReturn(true); mvc.perform(putJson(BASE_URL + "/location/availability", availability)) .andExpect(status().isOk()); verify(statusRsService).changeAvailability(availability); } @Test void ChangeAvailability_NoLocation_Failure() throws Exception { var availability = Availability.AVAILABLE; when(locationService.hasOwnLocation()).thenReturn(false); mvc.perform(putJson(BASE_URL + "/location/availability", availability)) .andExpect(status().isBadRequest()); verifyNoInteractions(statusRsService); } @Test void GetExport_Success() throws Exception { var backupData = "backup data".getBytes(); when(backupService.backup()).thenReturn(backupData); mvc.perform(get(BASE_URL + "/export", MediaType.APPLICATION_XML)) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"xeres_backup.xml\"")) .andExpect(content().bytes(backupData)); verify(backupService).backup(); } @Test void ImportBackup_Success() throws Exception { var file = new MockMultipartFile("file", "xeres_backup.xml", MediaType.APPLICATION_XML_VALUE, "backup data".getBytes()); mvc.perform(multipart(BASE_URL + "/import") .file(file)) .andExpect(status().isOk()); verify(backupService).restore(any()); verify(networkService).checkReadiness(); } @Test void ImportProfileFromRs_Success() throws Exception { var file = new MockMultipartFile("file", "retroshare_secret_keyring.gpg", MediaType.APPLICATION_OCTET_STREAM_VALUE, "data".getBytes()); var locationName = "test location"; var password = "secret"; mvc.perform(multipart(BASE_URL + "/import-profile-from-rs") .file(file) .param("locationName", locationName) .param("password", password)) .andExpect(status().isOk()); verify(backupService).importProfileFromRs(file, locationName, password); verify(networkService).checkReadiness(); } @Test void ImportFriendsFromRs_Success() throws Exception { var file = new MockMultipartFile("file", "friends.xml", MediaType.APPLICATION_XML_VALUE, "data".getBytes()); when(backupService.importFriendsFromRs(file)).thenReturn(new ImportRsFriendsResponse(1, 0)); mvc.perform(multipart(BASE_URL + "/import-friends-from-rs") .file(file)) .andExpect(status().isOk()); verify(backupService).importFriendsFromRs(file); } @Test void ImportFriendsFromRs_Errors() throws Exception { var file = new MockMultipartFile("file", "friends.xml", MediaType.APPLICATION_XML_VALUE, "data".getBytes()); when(backupService.importFriendsFromRs(file)).thenReturn(new ImportRsFriendsResponse(1, 1)); mvc.perform(multipart(BASE_URL + "/import-friends-from-rs") .file(file)) .andExpect(status().is(HttpStatus.MULTI_STATUS.value())); verify(backupService).importFriendsFromRs(file); } @Test void VerifyUpdate_Success() throws Exception { var request = new VerifyUpdateRequest("Xeres.msi", "signature".getBytes()); when(backupService.verifyUpdate(any(), eq(request.signature()))).thenReturn(true); mvc.perform(postJson(BASE_URL + "/verify-update", request)) .andExpect(status().isOk()) .andExpect(content().string("true")); verify(backupService).verifyUpdate(any(), eq(request.signature())); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/connection/ConnectionControllerTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.connection; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.job.PeerConnectionJob; import io.xeres.app.service.LocationService; import io.xeres.common.rest.connection.ConnectionRequest; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; import java.util.Optional; import static io.xeres.common.rest.PathConfig.CONNECTIONS_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(ConnectionController.class) @AutoConfigureMockMvc(addFilters = false) class ConnectionControllerTest extends AbstractControllerTest { private static final String BASE_URL = CONNECTIONS_PATH; @MockitoBean private LocationService locationService; @MockitoBean private PeerConnectionJob peerConnectionJob; @Test void GetConnectedProfiles_Success() throws Exception { var location = LocationFakes.createLocation(); var locations = List.of(LocationFakes.createOwnLocation(), location); when(locationService.getConnectedLocations()).thenReturn(locations); mvc.perform(getJson(BASE_URL + "/profiles")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(is(1))) .andExpect(jsonPath("$.[0].id").value(is(location.getProfile().getId()), Long.class)); verify(locationService).getConnectedLocations(); } @Test void AttemptToConnect_Success() throws Exception { var location = LocationFakes.createLocation(); when(locationService.findLocationByLocationIdentifier(location.getLocationIdentifier())).thenReturn(Optional.of(location)); mvc.perform(putJson(BASE_URL + "/connect", new ConnectionRequest(location.getLocationIdentifier().toString(), -1))) .andExpect(status().isOk()); verify(locationService).findLocationByLocationIdentifier(location.getLocationIdentifier()); verify(peerConnectionJob).connectImmediately(location, -1); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/contact/ContactControllerTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.contact; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.service.ContactService; import io.xeres.common.location.Availability; import io.xeres.common.rest.contact.Contact; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; import static io.xeres.common.rest.PathConfig.CONTACT_PATH; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(ContactController.class) @AutoConfigureMockMvc(addFilters = false) class ContactControllerTest extends AbstractControllerTest { private static final String BASE_URL = CONTACT_PATH; @MockitoBean private ContactService contactService; @Test void GetContacts_Success() throws Exception { var contacts = List.of( new Contact("foo", 1L, 1L, Availability.AVAILABLE, true), new Contact("bar", 2L, 2L, Availability.BUSY, true) ); when(contactService.getContacts()).thenReturn(contacts); mvc.perform(getJson(BASE_URL)) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].name").value("foo")); verify(contactService).getContacts(); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/file/FileControllerTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.file; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.xrs.service.filetransfer.FileTransferRsService; import io.xeres.common.id.Sha1Sum; import io.xeres.common.rest.file.FileDownloadRequest; import io.xeres.common.rest.file.FileProgress; import io.xeres.common.rest.file.FileSearchRequest; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; import static io.xeres.common.rest.PathConfig.FILES_PATH; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(FileController.class) @AutoConfigureMockMvc(addFilters = false) class FileControllerTest extends AbstractControllerTest { private static final String BASE_URL = FILES_PATH; @MockitoBean private FileTransferRsService fileTransferRsService; @Test void Search_Success() throws Exception { var searchName = "cool stuff"; var searchRequest = new FileSearchRequest(searchName); when(fileTransferRsService.turtleSearch(searchName)).thenReturn(1); mvc.perform(postJson(BASE_URL + "/search", searchRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id", is(1))); verify(fileTransferRsService).turtleSearch(searchName); } @Test void Download_Success() throws Exception { var downloadId = 123L; var request = new FileDownloadRequest("test.txt", "0123456789abcdef0123456789abcdef01234567", 1024L, LocationFakes.createLocation().getLocationIdentifier()); when(fileTransferRsService.download(eq(request.name()), any(Sha1Sum.class), eq(request.size()), eq(request.locationIdentifier()))).thenReturn(downloadId); mvc.perform(postJson(BASE_URL + "/download", request)) .andExpect(status().isOk()) .andExpect(jsonPath("$", is((int) downloadId))); } @Test void Download_InvalidHash_Failure() throws Exception { var request = new FileDownloadRequest("test.txt", "invalid_hash", 1024L, LocationFakes.createLocation().getLocationIdentifier()); mvc.perform(postJson(BASE_URL + "/download", request)) .andExpect(status().isBadRequest()); } @Test void GetDownloads_Success() throws Exception { var progress = new FileProgress(1L, "test.txt", 2048L, 8192L, "0123456789abcdef0123456789abcdef01234567", false); when(fileTransferRsService.getDownloadStatistics()).thenReturn(List.of(progress)); mvc.perform(getJson(BASE_URL + "/downloads")) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].id", is(1))) .andExpect(jsonPath("$[0].name", is("test.txt"))) .andExpect(jsonPath("$[0].currentSize", is(2048))) .andExpect(jsonPath("$[0].totalSize", is(8192))) .andExpect(jsonPath("$[0].hash", is("0123456789abcdef0123456789abcdef01234567"))) .andExpect(jsonPath("$[0].completed", is(false))); verify(fileTransferRsService).getDownloadStatistics(); } @Test void GetUploads_Success() throws Exception { var progress = new FileProgress(1L, "test.txt", 2048L, 8192L, "0123456789abcdef0123456789abcdef01234567", false); when(fileTransferRsService.getUploadStatistics()).thenReturn(List.of(progress)); mvc.perform(getJson(BASE_URL + "/uploads")) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].id", is(1))) .andExpect(jsonPath("$[0].name", is("test.txt"))) .andExpect(jsonPath("$[0].currentSize", is(2048))) .andExpect(jsonPath("$[0].totalSize", is(8192))) .andExpect(jsonPath("$[0].hash", is("0123456789abcdef0123456789abcdef01234567"))) .andExpect(jsonPath("$[0].completed", is(false))); verify(fileTransferRsService).getUploadStatistics(); } @Test void RemoveDownload_Success() throws Exception { var downloadId = 123L; mvc.perform(delete(BASE_URL + "/downloads/" + downloadId)) .andExpect(status().isNoContent()); verify(fileTransferRsService).removeDownload(downloadId); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/forum/ForumControllerTest.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.forum; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.forum.ForumMessageItemSummary; import io.xeres.app.database.model.gxs.ForumGroupItemFakes; import io.xeres.app.database.model.gxs.ForumMessageItemFakes; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.service.ForumMessageService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.xrs.service.forum.ForumRsService; import io.xeres.app.xrs.service.forum.item.ForumGroupItem; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.app.xrs.service.identity.IdentityRsService; import io.xeres.common.id.GxsId; import io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest; import io.xeres.common.rest.forum.UpdateForumMessageReadRequest; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; import java.util.Map; import java.util.Optional; import static io.xeres.common.rest.PathConfig.FORUMS_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ForumController.class) @AutoConfigureMockMvc(addFilters = false) class ForumControllerTest extends AbstractControllerTest { private static final String BASE_URL = FORUMS_PATH; @MockitoBean private ForumRsService forumRsService; @MockitoBean private IdentityRsService identityRsService; @MockitoBean private IdentityService identityService; @MockitoBean private ForumMessageService forumMessageService; @MockitoBean private UnHtmlService unHtmlService; @Test void GetForumsGroups_Success() throws Exception { var forumGroups = List.of(ForumGroupItemFakes.createForumGroupItem(), ForumGroupItemFakes.createForumGroupItem()); when(forumRsService.findAllGroups()).thenReturn(forumGroups); mvc.perform(getJson(BASE_URL + "/groups")) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(forumGroups.get(0).getId()), Long.class)) .andExpect(jsonPath("$.[0].name", is(forumGroups.get(0).getName()))) .andExpect(jsonPath("$.[1].id").value(is(forumGroups.get(1).getId()), Long.class)); verify(forumRsService).findAllGroups(); } @Test void CreateForumGroup_Success() throws Exception { var ownIdentity = IdentityFakes.createOwn(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); when(forumRsService.createForumGroup(ownIdentity.getGxsId(), "foo", "the best")).thenReturn(1L); var request = new CreateOrUpdateForumGroupRequest("foo", "the best"); mvc.perform(postJson(BASE_URL + "/groups", request)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + FORUMS_PATH + "/groups/" + 1L)); verify(forumRsService).createForumGroup(eq(ownIdentity.getGxsId()), anyString(), anyString()); } @Test void UpdateForumGroup_Success() throws Exception { var request = new CreateOrUpdateForumGroupRequest("foo", "the best"); mvc.perform(putJson(BASE_URL + "/groups/1", request)) .andExpect(status().isNoContent()); verify(forumRsService).updateForumGroup(1L, "foo", "the best"); } @Test void GetForumByGroupId_Success() throws Exception { long groupId = 1L; var forumGroupItem = new ForumGroupItem(null, "foobar"); when(forumRsService.findById(groupId)).thenReturn(Optional.of(forumGroupItem)); mvc.perform(getJson(BASE_URL + "/groups/" + groupId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is(forumGroupItem.getId()), Long.class)); } @Test void UpdateMessagesReadFlag_Success() throws Exception { var request = new UpdateForumMessageReadRequest(1L, true); mvc.perform(patchJson(BASE_URL + "/messages", request)) .andExpect(status().isOk()); verify(forumRsService).setMessageReadState(1L, true); } @Test void GetForumUnreadCount_Success() throws Exception { long groupId = 1L; int unreadCount = 5; when(forumRsService.getUnreadCount(groupId)).thenReturn(unreadCount); mvc.perform(getJson(BASE_URL + "/groups/" + groupId + "/unread-count")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().string(String.valueOf(unreadCount))); verify(forumRsService).getUnreadCount(groupId); } @Test void SubscribeToForumGroup_Success() throws Exception { long groupId = 1L; mvc.perform(put(BASE_URL + "/groups/" + groupId + "/subscription")) .andExpect(status().isNoContent()); verify(forumRsService).subscribeToForumGroup(groupId); } @Test void MarkAllMessagesAsRead_Success() throws Exception { long groupId = 1L; mvc.perform(put(BASE_URL + "/groups/" + groupId + "/read?read=true")) .andExpect(status().isNoContent()); verify(forumRsService).setAllGroupMessagesReadState(groupId, true); } @Test void UnsubscribeFromForumGroup_Success() throws Exception { long groupId = 1L; mvc.perform(delete(BASE_URL + "/groups/" + groupId + "/subscription")) .andExpect(status().isNoContent()); verify(forumRsService).unsubscribeFromForumGroup(groupId); } @Test void GetForumMessages_Success() throws Exception { long groupId = 1L; Page forumMessages = new PageImpl<>(List.of(ForumMessageItemFakes.createForumMessageItemSummary(), ForumMessageItemFakes.createForumMessageItemSummary())); when(forumRsService.findAllMessagesSummary(eq(groupId), any())).thenReturn(forumMessages); when(forumMessageService.getAuthorsMapFromSummaries(forumMessages)).thenReturn(Map.of()); when(forumMessageService.getMessagesMapFromSummaries(groupId, forumMessages)).thenReturn(Map.of()); mvc.perform(getJson(BASE_URL + "/groups/" + groupId + "/messages")) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.size()").value(is(forumMessages.getTotalElements()), Long.class)); verify(forumRsService).findAllMessagesSummary(eq(groupId), any()); verify(forumMessageService).getAuthorsMapFromSummaries(forumMessages); verify(forumMessageService).getMessagesMapFromSummaries(groupId, forumMessages); } @Test void GetForumMessage_Success() throws Exception { long id = 1L; ForumMessageItem forumMessage = ForumMessageItemFakes.createForumMessageItem(); when(forumRsService.findMessageById(id)).thenReturn(forumMessage); when(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty()); when(forumRsService.findAllMessages(any(GxsId.class), anySet())).thenReturn(List.of()); mvc.perform(getJson(BASE_URL + "/messages/" + id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is((int) forumMessage.getId()))); verify(forumRsService).findMessageById(id); verify(identityService).findByGxsId(null); verify(forumRsService).findAllMessagesIncludingOlds(any(GxsId.class), anySet()); } @Test void CreateForumMessage_Success() throws Exception { var ownIdentity = IdentityFakes.createOwn(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); when(forumRsService.createForumMessage( eq(ownIdentity), anyLong(), anyString(), anyString(), anyLong(), anyLong() )).thenReturn(1L); String requestBody = "{\"forumId\":1,\"title\":\"Test Title\",\"content\":\"Test Content\"}"; mvc.perform(post(BASE_URL + "/messages") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + FORUMS_PATH + "/messages/" + 1L)); verify(forumRsService).createForumMessage( eq(ownIdentity), anyLong(), anyString(), anyString(), anyLong(), anyLong() ); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/geoip/GeoIpControllerTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.geoip; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.service.GeoIpService; import io.xeres.common.geoip.Country; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.Locale; import static io.xeres.common.rest.PathConfig.GEOIP_PATH; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(GeoIpController.class) @AutoConfigureMockMvc(addFilters = false) class GeoIpControllerTest extends AbstractControllerTest { private static final String BASE_URL = GEOIP_PATH; @MockitoBean private GeoIpService geoIpService; @Test void GetIsoCountry_Success() throws Exception { var address = "1.1.1.1"; when(geoIpService.getCountry(address)).thenReturn(Country.CH); mvc.perform(getJson(BASE_URL + "/" + address)) .andExpect(status().isOk()) .andExpect(jsonPath("$.isoCountry").value(Country.CH.name().toLowerCase(Locale.ROOT))); verify(geoIpService).getCountry(address); } @Test void GetIsoCountry_Failure() throws Exception { var address = "1.1.1.1"; when(geoIpService.getCountry(address)).thenReturn(null); mvc.perform(getJson(BASE_URL + "/" + address)) .andExpect(status().isNotFound()); verify(geoIpService).getCountry(address); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/identity/IdentityControllerTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.identity; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.gxs.IdentityGroupItemFakes; import io.xeres.app.service.IdentityService; import io.xeres.app.service.identicon.IdenticonService; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.xrs.service.identity.IdentityRsService; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(IdentityController.class) @AutoConfigureMockMvc(addFilters = false) class IdentityControllerTest extends AbstractControllerTest { private static final String BASE_URL = IDENTITIES_PATH; @MockitoBean private IdentityService identityService; @MockitoBean private IdentityRsService identityRsService; @MockitoBean private ContactNotificationService contactNotificationService; @MockitoBean private IdenticonService identiconService; @Test void FindIdentityById_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identityService.findById(identity.getId())).thenReturn(Optional.of(identity)); mvc.perform(getJson(BASE_URL + "/" + identity.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is(identity.getId()), Long.class)); verify(identityService).findById(identity.getId()); } @Test void FindIdentityById_NotFound_Failure() throws Exception { var id = 1L; when(identityService.findById(id)).thenThrow(new NoSuchElementException()); mvc.perform(getJson(BASE_URL + "/" + id)) .andExpect(status().isNotFound()); verify(identityService).findById(id); } @Test void DownloadIdentityImage_Empty_Success() throws Exception { var id = 1L; var identity = IdentityGroupItemFakes.createIdentityGroupItem(); when(identityService.findById(id)).thenReturn(Optional.of(identity)); when(identiconService.getIdenticon(any())).thenReturn(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream("/image/leguman.jpg")).readAllBytes()); mvc.perform(get(BASE_URL + "/" + id + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isOk()) .andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE)); verify(identityService).findById(id); } @Test void DownloadIdentityImage_Success() throws Exception { var id = 1L; var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setImage(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream("/image/leguman.jpg")).readAllBytes()); when(identityService.findById(id)).thenReturn(Optional.of(identity)); mvc.perform(get(BASE_URL + "/" + id + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isOk()) .andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE)); verify(identityService).findById(id); } @Test void UploadIdentityImage_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identityRsService.saveOwnIdentityImage(eq(identity.getId()), any())).thenReturn(identity); mvc.perform(post(BASE_URL + "/" + identity.getId() + "/image") .contentType(MediaType.MULTIPART_FORM_DATA) .accept(MediaType.APPLICATION_JSON) .content("")) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + IDENTITIES_PATH + "/" + identity.getId() + "/image")); verify(identityRsService).saveOwnIdentityImage(eq(identity.getId()), any()); } @Test void DeleteIdentityImage_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identityRsService.deleteOwnIdentityImage(identity.getId())).thenReturn(identity); mvc.perform(delete(BASE_URL + "/" + identity.getId() + "/image")) .andExpect(status().isNoContent()); verify(identityRsService).deleteOwnIdentityImage(identity.getId()); } @Test void FindIdentities_ByName_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identityService.findAllByName(identity.getName())).thenReturn(List.of(identity)); mvc.perform(getJson(BASE_URL + "?name=" + identity.getName())) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(identity.getId()), Long.class)); verify(identityService).findAllByName(identity.getName()); } @Test void FindIdentities_ByGxsId_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identityService.findByGxsId(identity.getGxsId())).thenReturn(Optional.of(identity)); mvc.perform(getJson(BASE_URL + "?gxsId=" + identity.getGxsId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(identity.getId()), Long.class)); verify(identityService).findByGxsId(identity.getGxsId()); } @Test void FindIdentities_ByType_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identityService.findAllByType(identity.getType())).thenReturn(List.of(identity)); mvc.perform(getJson(BASE_URL + "?type=" + identity.getType())) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(identity.getId()), Long.class)); verify(identityService).findAllByType(identity.getType()); } @Test void FindIdentities_All_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identityService.getAll()).thenReturn(List.of(identity)); mvc.perform(getJson(BASE_URL)) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(identity.getId()), Long.class)); verify(identityService).getAll(); } @Test void DownloadImageByGxsId_Found_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); identity.setImage(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream("/image/leguman.jpg")).readAllBytes()); when(identityService.findByGxsId(identity.getGxsId())).thenReturn(Optional.of(identity)); mvc.perform(get(BASE_URL + "/image", MediaType.IMAGE_JPEG) .param("gxsId", identity.getGxsId().toString()) .param("find", "true")) .andExpect(status().isOk()) .andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE)); verify(identityService).findByGxsId(identity.getGxsId()); } @Test void DownloadImageByGxsId_Identicon_Success() throws Exception { var identity = IdentityGroupItemFakes.createIdentityGroupItem(); identity.setId(1L); when(identiconService.getIdenticon(any())).thenReturn(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream("/image/leguman.jpg")).readAllBytes()); mvc.perform(get(BASE_URL + "/image", MediaType.IMAGE_JPEG) .param("gxsId", identity.getGxsId().toString())) .andExpect(status().isOk()) .andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE)); verify(identiconService).getIdenticon(any()); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/location/LocationControllerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.location; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.api.converter.BufferedImageConverter; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.service.LocationService; import io.xeres.app.service.QrCodeService; import io.xeres.common.rsid.Type; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import javax.imageio.ImageIO; import java.io.ByteArrayInputStream; import java.util.Objects; import java.util.Optional; import static io.xeres.common.rest.PathConfig.LOCATIONS_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(LocationController.class) @AutoConfigureMockMvc(addFilters = false) @Import(BufferedImageConverter.class) // @Components aren't imported by default by @WebMvcTest class LocationControllerTest extends AbstractControllerTest { private static final String BASE_URL = LOCATIONS_PATH; @MockitoBean private LocationService locationService; @MockitoBean private QrCodeService qrCodeService; @Test void FindLocationById_Success() throws Exception { var location = LocationFakes.createLocation(); when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); mvc.perform(getJson(BASE_URL + "/" + location.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is(location.getId()), Long.class)); verify(locationService).findLocationById(location.getId()); } @Test void GetRSIdOfLocation_Success() throws Exception { var location = LocationFakes.createLocation(); when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); mvc.perform(getJson(BASE_URL + "/" + location.getId() + "/rs-id")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name", is(location.getProfile().getName()))) .andExpect(jsonPath("$.location", is(location.getName()))) .andExpect(jsonPath("$.rsId", is(location.getRsId(Type.ANY).getArmored()))); verify(locationService).findLocationById(location.getId()); } @Test void GetRSIdOfLocation_QrCode_Success() throws Exception { var location = LocationFakes.createLocation(); var rsId = location.getRsId(Type.SHORT_INVITE).getArmored(); when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); when(qrCodeService.generateQrCode(rsId)).thenReturn(ImageIO.read(new ByteArrayInputStream(Objects.requireNonNull(LocationControllerTest.class.getResourceAsStream("/image/abitbol.png")).readAllBytes()))); mvc.perform(get(BASE_URL + "/" + location.getId() + "/rs-id/qr-code", MediaType.IMAGE_PNG)) .andExpect(status().isOk()) .andExpect(header().string(CONTENT_TYPE, "image/png")); verify(locationService).findLocationById(location.getId()); verify(qrCodeService).generateQrCode(rsId); } @Test void IsServiceSupported_ReturnsOk() throws Exception { var location = LocationFakes.createLocation(); int serviceId = 42; when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); when(locationService.isServiceSupported(location, serviceId)).thenReturn(true); mvc.perform(getJson(BASE_URL + "/" + location.getId() + "/service/" + serviceId)) .andExpect(status().isOk()); verify(locationService).findLocationById(location.getId()); verify(locationService).isServiceSupported(location, serviceId); } @Test void IsServiceSupported_ReturnsNotFound() throws Exception { var location = LocationFakes.createLocation(); int serviceId = 99; when(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location)); when(locationService.isServiceSupported(location, serviceId)).thenReturn(false); mvc.perform(getJson(BASE_URL + "/" + location.getId() + "/service/" + serviceId)) .andExpect(status().isNotFound()); verify(locationService).findLocationById(location.getId()); verify(locationService).isServiceSupported(location, serviceId); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/notification/NotificationControllerTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.notification; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.service.notification.availability.AvailabilityNotificationService; import io.xeres.app.service.notification.board.BoardNotificationService; import io.xeres.app.service.notification.channel.ChannelNotificationService; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.app.service.notification.file.FileSearchNotificationService; import io.xeres.app.service.notification.file.FileTrendNotificationService; import io.xeres.app.service.notification.forum.ForumNotificationService; import io.xeres.app.service.notification.status.StatusNotificationService; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import static io.xeres.common.rest.PathConfig.NOTIFICATIONS_PATH; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(NotificationController.class) @AutoConfigureMockMvc(addFilters = false) class NotificationControllerTest extends AbstractControllerTest { private static final String BASE_URL = NOTIFICATIONS_PATH; @MockitoBean private StatusNotificationService statusNotificationService; @MockitoBean private ForumNotificationService forumNotificationService; @MockitoBean private FileNotificationService fileNotificationService; @MockitoBean private FileSearchNotificationService fileSearchNotificationService; @MockitoBean private ContactNotificationService contactNotificationService; @MockitoBean private AvailabilityNotificationService availabilityNotificationService; @MockitoBean private FileTrendNotificationService fileTrendNotificationService; @MockitoBean private BoardNotificationService boardNotificationService; @MockitoBean private ChannelNotificationService channelNotificationService; @Test void SetupStatusNotification_Success() throws Exception { var sseEmitter = new SseEmitter(); when(statusNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/status", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupForumNotification_Success() throws Exception { var sseEmitter = new SseEmitter(); when(forumNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/forum", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupBoardNotification_Success() throws Exception { var sseEmitter = new SseEmitter(); when(boardNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/board", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupChannelNotification_Success() throws Exception { var sseEmitter = new SseEmitter(); when(channelNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/channel", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupFileNotification_Successs() throws Exception { var sseEmitter = new SseEmitter(); when(fileNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/file", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupFileSearchNotification_Successs() throws Exception { var sseEmitter = new SseEmitter(); when(fileSearchNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/file-search", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupContactNotification_Successs() throws Exception { var sseEmitter = new SseEmitter(); when(contactNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/contact", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupAvailabilityNotification_Successs() throws Exception { var sseEmitter = new SseEmitter(); when(availabilityNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/availability", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } @Test void SetupFileTrendNotification_Success() throws Exception { var sseEmitter = new SseEmitter(); when(fileTrendNotificationService.addClient()).thenReturn(sseEmitter); mvc.perform(get(BASE_URL + "/file-trend", MediaType.TEXT_EVENT_STREAM)) .andExpect(status().isOk()); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/profile/ProfileControllerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.profile; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.crypto.rsid.RSId; import io.xeres.app.crypto.rsid.RSIdFakes; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.job.PeerConnectionJob; import io.xeres.app.service.ContactService; import io.xeres.app.service.IdentityService; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.identicon.IdenticonService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.common.id.Id; import io.xeres.common.location.Availability; import io.xeres.common.rest.contact.Contact; import io.xeres.common.rest.profile.ProfileKeyAttributes; import io.xeres.common.rest.profile.RsIdRequest; import org.bouncycastle.util.encoders.Base64; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.*; import static io.xeres.common.rest.PathConfig.PROFILES_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ProfileController.class) @AutoConfigureMockMvc(addFilters = false) class ProfileControllerTest extends AbstractControllerTest { private static final String BASE_URL = PROFILES_PATH; @SuppressWarnings("unused") @MockitoBean private PeerConnectionJob peerConnectionJob; @MockitoBean private ProfileService profileService; @MockitoBean private IdentityService identityService; @MockitoBean private IdenticonService identiconService; @MockitoBean private LocationService locationService; @MockitoBean private ContactService contactService; @SuppressWarnings("unused") @MockitoBean private StatusNotificationService statusNotificationService; @Test void FindProfileById_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); when(profileService.findProfileById(expected.getId())).thenReturn(Optional.of(expected)); mvc.perform(getJson(BASE_URL + "/" + expected.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(is(expected.getId()), Long.class)) .andExpect(jsonPath("$.name", is(expected.getName()))) .andExpect(jsonPath("$.pgpFingerprint", is(Base64.toBase64String(expected.getProfileFingerprint().getBytes())))) .andExpect(jsonPath("$.pgpPublicKeyData", is(Base64.toBase64String(expected.getPgpPublicKeyData())))) .andExpect(jsonPath("$.accepted").value(is(expected.isAccepted()), Boolean.class)) .andExpect(jsonPath("$.trust", is(expected.getTrust().name()))); verify(profileService).findProfileById(expected.getId()); } @Test void FindProfileById_NotFound() throws Exception { var id = 2L; when(profileService.findProfileById(id)).thenReturn(Optional.empty()); mvc.perform(getJson(BASE_URL + "/" + id)) .andExpect(status().isNotFound()); verify(profileService).findProfileById(id); } @Test void FindProfileByName_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); when(profileService.findProfilesByName(expected.getName())).thenReturn(List.of(expected)); mvc.perform(getJson(BASE_URL + "?name=" + expected.getName())) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(expected.getId()), Long.class)) .andExpect(jsonPath("$.[0].name", is(expected.getName()))); verify(profileService).findProfilesByName(expected.getName()); } @Test void FindProfileByName_NotFound() throws Exception { var name = "inexistant"; when(profileService.findProfilesByName(name)).thenReturn(Collections.emptyList()); mvc.perform(getJson(BASE_URL + "?name=" + name)) .andExpect(status().isOk()) .andExpect(content().string("[]")); verify(profileService).findProfilesByName(name); } @Test void FindProfileByLocationIdentifier_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); expected.addLocation(LocationFakes.createLocation("test", expected)); var locationIdentifier = expected.getLocations().getFirst().getLocationIdentifier(); when(profileService.findProfileByLocationIdentifier(locationIdentifier)).thenReturn(Optional.of(expected)); mvc.perform(getJson(BASE_URL + "?locationIdentifier=" + locationIdentifier)) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(expected.getId()), Long.class)) .andExpect(jsonPath("$.[0].name", is(expected.getName()))); verify(profileService).findProfileByLocationIdentifier(locationIdentifier); } @Test void FindProfiles_Success() throws Exception { var profile1 = ProfileFakes.createProfile("test1", 1); var profile2 = ProfileFakes.createProfile("test2", 2); var profiles = List.of(profile1, profile2); when(profileService.getAllProfiles()).thenReturn(profiles); mvc.perform(getJson(BASE_URL)) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].id").value(is(profiles.getFirst().getId()), Long.class)); verify(profileService).getAllProfiles(); } @Test void CreateProfile_ShortInvite_WithTrustAndConnectionIndex_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); expected.addLocation(LocationFakes.createLocation("test", expected)); var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL + "?trust=FULL&connectionIndex=1", profileRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + PROFILES_PATH + "/" + expected.getId())); verify(profileService).createOrUpdateProfile(any(Profile.class)); verify(peerConnectionJob).connectImmediately(expected.getLocations().getFirst(), 1); } @Test void CreateProfile_ShortInvite_WithTrustInMixedCaseAndConnectionIndex_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); expected.addLocation(LocationFakes.createLocation("test", expected)); var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL + "?trust=Full&connectionIndex=1", profileRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + PROFILES_PATH + "/" + expected.getId())); verify(profileService).createOrUpdateProfile(any(Profile.class)); verify(peerConnectionJob).connectImmediately(expected.getLocations().getFirst(), 1); } @Test void CreateProfile_ShortInvite_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL, profileRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + PROFILES_PATH + "/" + expected.getId())); verify(profileService).createOrUpdateProfile(any(Profile.class)); } @Test void CreateProfile_RsCertificate_Success() throws Exception { var expected = ProfileFakes.createProfile("Nemesis", 0x9F00B21277698D8DL, Id.toBytes("60049f670534eab17dda2e6d9f00b21277698d8d"), Id.toBytes("984d0461fd80400102008e20511e623f662693d054e1aeb26a007e17f745d4616a6a647d22313b67111ce5f45db22fb670bb5e05f4846ad6d686224acc22966f28e1a50d99d4afb295fb0011010001b4084e656d6573697320885c041001020006050261fd8040000a09109f00b21277698d8d97e401ff688d2b9b73551587858994309485909a36b5401518716698131e1811d8f8204348392c89e99fcb21651d7490e9877b80ced7e11aabbb7c0538853954d77d047b")); var profileRequest = new RsIdRequest(RSIdFakes.createRsCertificate(expected).getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); when(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL, profileRequest)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost" + PROFILES_PATH + "/" + expected.getId())); verify(profileService).createOrUpdateProfile(any(Profile.class)); } @Test void CreateProfile_MissingCertificate_BadRequest() throws Exception { @SuppressWarnings("DataFlowIssue") var profileRequest = new RsIdRequest(null); mvc.perform(postJson(BASE_URL, profileRequest)) .andExpect(status().isBadRequest()); } @Test void CreateProfile_BrokenCertificate_BadRequest() throws Exception { var profileRequest = new RsIdRequest("foo"); mvc.perform(postJson(BASE_URL, profileRequest)) .andExpect(status().isBadRequest()); } @Test void CreateProfile_IllegalTrust_BadRequest() throws Exception { var expected = ProfileFakes.createProfile("test", 1); var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL + "?trust=ULTIMATE", profileRequest)) .andExpect(status().isBadRequest()); } @Test void DeleteProfile_Success() throws Exception { long id = 2; mvc.perform(delete(BASE_URL + "/" + id)) .andExpect(status().isNoContent()); verify(profileService).deleteProfile(id); } @Test void DeleteProfile_NotFound() throws Exception { long id = 2; doThrow(NoSuchElementException.class).when(profileService).deleteProfile(id); mvc.perform(delete(BASE_URL + "/" + id)) .andExpect(status().isNotFound()); verify(profileService).deleteProfile(id); } @Test void DeleteProfile_Own_UnprocessableEntity() throws Exception { long id = 1; mvc.perform(delete(BASE_URL + "/" + id)) .andExpect(status().isUnprocessableContent()); } @Test void FindProfileKeyAttributes_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); var keyAttributes = mock(ProfileKeyAttributes.class); when(profileService.findProfileById(expected.getId())).thenReturn(Optional.of(expected)); when(profileService.findProfileKeyAttributes(expected.getId())).thenReturn(keyAttributes); mvc.perform(getJson(BASE_URL + "/" + expected.getId() + "/key-attributes")) .andExpect(status().isOk()); verify(profileService).findProfileKeyAttributes(expected.getId()); } @Test void FindContactsForProfile_Success() throws Exception { when(contactService.getContactsForProfileId(1L)).thenReturn(List.of(new Contact("foo", 1L, 1L, Availability.AVAILABLE, true))); mvc.perform(getJson(BASE_URL + "/1/contacts")) .andExpect(status().isOk()) .andExpect(jsonPath("$.[0].profileId").value(is(1L), Long.class)) .andExpect(jsonPath("$.[0].identityId").value(is(1L), Long.class)) .andExpect(jsonPath("$.[0].availability").value(is("AVAILABLE"), String.class)) .andExpect(jsonPath("$.[0].name", is("foo"))); verify(contactService).getContactsForProfileId(1L); } @Test void FindProfileKeyAttributes_NotFound() throws Exception { var id = 2L; when(profileService.findProfileKeyAttributes(id)).thenThrow(new NoSuchElementException()); mvc.perform(getJson(BASE_URL + "/" + id + "/key-attributes")) .andExpect(status().isNotFound()); } @Test void DownloadImage_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); when(profileService.findProfileById(expected.getId())).thenReturn(Optional.of(expected)); when(identiconService.getIdenticon(any())).thenReturn(Objects.requireNonNull(ProfileControllerTest.class.getResourceAsStream("/image/leguman.jpg")).readAllBytes()); mvc.perform(get(BASE_URL + "/" + expected.getId() + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.IMAGE_JPEG)); verify(identiconService).getIdenticon(any()); } @Test void DownloadImage_NotFound() throws Exception { var id = 2L; when(profileService.findProfileById(id)).thenReturn(Optional.empty()); mvc.perform(get(BASE_URL + "/" + id + "/image", MediaType.IMAGE_JPEG)) .andExpect(status().isNotFound()); } @Test void CheckProfileFromRsId_Success() throws Exception { var expected = ProfileFakes.createProfile("test", 1); var profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored()); when(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected); mvc.perform(postJson(BASE_URL + "/check", profileRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name", is(expected.getName()))); verify(profileService).getProfileFromRSId(any()); } @Test void CheckProfileFromRsId_Invalid() throws Exception { var profileRequest = new RsIdRequest("invalid id"); mvc.perform(postJson(BASE_URL + "/check", profileRequest)) .andExpect(status().isUnprocessableContent()); } @Test void CheckProfileFromRsId_TooShort_BadRequest() throws Exception { var profileRequest = new RsIdRequest("invalid"); mvc.perform(postJson(BASE_URL + "/check", profileRequest)) .andExpect(status().isBadRequest()); } @Test void SetTrust_Success() throws Exception { var profile = ProfileFakes.createProfile("test", 2); when(profileService.findProfileById(profile.getId())).thenReturn(Optional.of(profile)); mvc.perform(putJson(BASE_URL + "/" + profile.getId() + "/trust", "MARGINAL")) .andExpect(status().isNoContent()); verify(profileService).createOrUpdateProfile(profile); } @Test void SetTrust_OwnProfile_BadRequest() throws Exception { var profile = ProfileFakes.createOwnProfile(); when(profileService.findProfileById(profile.getId())).thenReturn(Optional.of(profile)); mvc.perform(putJson(BASE_URL + "/" + profile.getId() + "/trust", "MARGINAL")) .andExpect(status().isBadRequest()); } @Test void SetTrust_Ultimate_BadRequest() throws Exception { var profile = ProfileFakes.createProfile("test", 2); when(profileService.findProfileById(profile.getId())).thenReturn(Optional.of(profile)); mvc.perform(putJson(BASE_URL + "/" + profile.getId() + "/trust", "ULTIMATE")) .andExpect(status().isBadRequest()); } @Test void SetTrust_NotFound() throws Exception { var id = 2L; when(profileService.findProfileById(id)).thenReturn(Optional.empty()); mvc.perform(putJson(BASE_URL + "/" + id + "/trust", "MARGINAL")) .andExpect(status().isUnprocessableContent()); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/settings/SettingsControllerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.settings; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.settings.SettingsFakes; import io.xeres.app.database.model.settings.SettingsMapper; import io.xeres.app.service.SettingsService; import jakarta.json.Json; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import static io.xeres.common.rest.PathConfig.SETTINGS_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(SettingsController.class) @AutoConfigureMockMvc(addFilters = false) class SettingsControllerTest extends AbstractControllerTest { private static final String BASE_URL = SETTINGS_PATH; @MockitoBean private SettingsService settingsService; @Test void GetSettings_Success() throws Exception { var settings = SettingsFakes.createSettings(); when(settingsService.getSettings()).thenReturn(SettingsMapper.toDTO(settings)); mvc.perform(getJson(BASE_URL)) .andExpect(status().isOk()) .andExpect(jsonPath("$.dhtEnabled", is(settings.isDhtEnabled()))); } @Test void UpdateSettings_Success() throws Exception { var settings = SettingsFakes.createSettings(); when(settingsService.applyPatchToSettings(any())).thenReturn(settings); var patch = Json.createPatchBuilder().build(); mvc.perform(patchJson(BASE_URL, patch)) .andExpect(status().isOk()) .andExpect(jsonPath("$.dhtEnabled", is(settings.isDhtEnabled()))); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/share/ShareControllerTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.share; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.database.model.file.FileFakes; import io.xeres.app.database.model.share.Share; import io.xeres.app.service.file.FileService; import io.xeres.common.dto.share.ShareDTO; import io.xeres.common.pgp.Trust; import io.xeres.common.rest.share.TemporaryShareRequest; import io.xeres.common.rest.share.UpdateShareRequest; import io.xeres.testutils.Sha1SumFakes; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.nio.file.Path; import java.time.Instant; import java.util.List; import java.util.Map; import static io.xeres.common.rest.PathConfig.SHARES_PATH; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(ShareController.class) @AutoConfigureMockMvc(addFilters = false) class ShareControllerTest extends AbstractControllerTest { private static final String BASE_URL = SHARES_PATH; @MockitoBean private FileService fileService; @Test void GetShares_Success() throws Exception { var share = Share.createShare("foo", FileFakes.createFile("test"), true, Trust.FULL); share.setId(1L); when(fileService.getShares()).thenReturn(List.of(share)); when(fileService.getFilesMapFromShares(any())).thenReturn(Map.of(share.getId(), "foo/bar")); mvc.perform(getJson(BASE_URL)) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].id", is(1))) .andExpect(jsonPath("$[0].path", is("foo/bar"))); verify(fileService).getShares(); verify(fileService).getFilesMapFromShares(any()); } @Test void CreateAndUpdateShares_Success() throws Exception { var shareDTO = new ShareDTO(1L, "foo", "foo/bar", true, Trust.FULL, Instant.now()); var updateRequest = new UpdateShareRequest(List.of(shareDTO)); mvc.perform(postJson(BASE_URL, updateRequest)) .andExpect(status().isCreated()); verify(fileService).synchronize(any()); } @Test void ShareTemporarily_Success() throws Exception { var filePath = "/tmp/test.txt"; var temporaryShareRequest = new TemporaryShareRequest(filePath); var path = Path.of(filePath); var hash = Sha1SumFakes.createSha1Sum(); when(fileService.calculateTemporaryFileHash(path)).thenReturn(hash); mvc.perform(postJson(BASE_URL + "/temporary", temporaryShareRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.hash", is(hash.toString()))); verify(fileService).calculateTemporaryFileHash(path); } @Test void ShareTemporarily_HashCalculationFails() throws Exception { var filePath = "/tmp/test.txt"; var temporaryShareRequest = new TemporaryShareRequest(filePath); var path = Path.of(filePath); when(fileService.calculateTemporaryFileHash(path)).thenReturn(null); mvc.perform(postJson(BASE_URL + "/temporary", temporaryShareRequest)) .andExpect(status().isInternalServerError()); verify(fileService).calculateTemporaryFileHash(path); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/statistics/StatisticsControllerTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.statistics; import io.xeres.app.api.controller.AbstractControllerTest; import io.xeres.app.xrs.service.bandwidth.BandwidthRsService; import io.xeres.app.xrs.service.rtt.RttRsService; import io.xeres.app.xrs.service.turtle.TurtleRsService; import io.xeres.app.xrs.service.turtle.TurtleStatistics; import io.xeres.common.rest.statistics.DataCounterPeer; import io.xeres.common.rest.statistics.DataCounterStatisticsResponse; import io.xeres.common.rest.statistics.RttPeer; import io.xeres.common.rest.statistics.RttStatisticsResponse; import org.junit.jupiter.api.Test; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; import static io.xeres.common.rest.PathConfig.STATISTICS_PATH; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(StatisticsController.class) @AutoConfigureMockMvc(addFilters = false) class StatisticsControllerTest extends AbstractControllerTest { private static final String BASE_URL = STATISTICS_PATH; @MockitoBean private TurtleRsService turtleRsService; @MockitoBean private RttRsService rttRsService; @MockitoBean private BandwidthRsService bandwidthRsService; @Test void GetTurtleStatistics_Success() throws Exception { var stats = new TurtleStatistics(); stats.addToDataDownload(5); when(turtleRsService.getStatistics()).thenReturn(stats); mvc.perform(getJson(BASE_URL + "/turtle")) .andExpect(status().isOk()) .andExpect(jsonPath("$.dataDownload").value(is(5.0f), Float.class)); verify(turtleRsService).getStatistics(); } @Test void GetRttStatistics_Success() throws Exception { var rttPeer = new RttPeer(1L, "foo", 2); var stats = new RttStatisticsResponse(List.of(rttPeer)); when(rttRsService.getStatistics()).thenReturn(stats); mvc.perform(getJson(BASE_URL + "/rtt")) .andExpect(status().isOk()) .andExpect(jsonPath("$.peers.[0].id").value(is(1L), Long.class)) .andExpect(jsonPath("$.peers.[0].name").value(is("foo"), String.class)) .andExpect(jsonPath("$.peers.[0].mean").value(is(2L), Long.class)); verify(rttRsService).getStatistics(); } @Test void GetDataCounterStatistics_Success() throws Exception { var dataCounterPeer = new DataCounterPeer(1L, "foo", 2L, 3L); var stats = new DataCounterStatisticsResponse(List.of(dataCounterPeer)); when(bandwidthRsService.getDataCounterStatistics()).thenReturn(stats); mvc.perform(getJson(BASE_URL + "/data-counter")) .andExpect(status().isOk()) .andExpect(jsonPath("$.peers.[0].id").value(is(1L), Long.class)) .andExpect(jsonPath("$.peers.[0].name").value(is("foo"), String.class)) .andExpect(jsonPath("$.peers.[0].sent").value(is(2L), Long.class)) .andExpect(jsonPath("$.peers.[0].received").value(is(3L), Long.class)); verify(bandwidthRsService).getDataCounterStatistics(); } } ================================================ FILE: app/src/test/java/io/xeres/app/api/controller/voip/VoipMessageControllerTest.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.api.controller.voip; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.xrs.service.voip.VoipRsService; import io.xeres.common.message.voip.VoipAction; import io.xeres.common.message.voip.VoipMessage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class VoipMessageControllerTest { private static final String DESTINATION_ID = LocationFakes.createLocation().getLocationIdentifier().toString(); @Mock private VoipRsService voipRsService; @Test void processPrivateVoipMessageFromProducer_callsCallOnRing() { var controller = new VoipMessageController(voipRsService); var msg = new VoipMessage(VoipAction.RING); controller.processPrivateVoipMessageFromProducer(DESTINATION_ID, msg); verify(voipRsService, times(1)).call(any()); verifyNoMoreInteractions(voipRsService); } @Test void processPrivateVoipMessageFromProducer_callsAcceptOnAcknowledge() { var controller = new VoipMessageController(voipRsService); var msg = new VoipMessage(VoipAction.ACKNOWLEDGE); controller.processPrivateVoipMessageFromProducer(DESTINATION_ID, msg); verify(voipRsService, times(1)).accept(any()); verifyNoMoreInteractions(voipRsService); } @Test void processPrivateVoipMessageFromProducer_callsHangupOnClose() { var controller = new VoipMessageController(voipRsService); var msg = new VoipMessage(VoipAction.CLOSE); controller.processPrivateVoipMessageFromProducer(DESTINATION_ID, msg); verify(voipRsService, times(1)).hangup(any()); verifyNoMoreInteractions(voipRsService); } } ================================================ FILE: app/src/test/java/io/xeres/app/application/SingleInstanceRunTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class SingleInstanceRunTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(SingleInstanceRun.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/application/autostart/AutoStartTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.autostart; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class AutoStartTest { @Mock private AutoStarter autoStarter; @InjectMocks private AutoStart autoStart; @Test void Enable_Supported_Success() { when(autoStarter.isSupported()).thenReturn(true); autoStart.enable(); verify(autoStarter).enable(); } @Test void Enable_NotSupported_NoOp() { when(autoStarter.isSupported()).thenReturn(false); autoStart.enable(); verify(autoStarter, never()).enable(); } @Test void Disable_Supported_Success() { when(autoStarter.isSupported()).thenReturn(true); autoStart.disable(); verify(autoStarter).disable(); } @Test void Disable_NotSupported_NoOp() { when(autoStarter.isSupported()).thenReturn(false); autoStart.disable(); verify(autoStarter, never()).disable(); } @Test void IsEnabled_Supported_Success() { when(autoStarter.isSupported()).thenReturn(true); when(autoStarter.isEnabled()).thenReturn(true); var enabled = autoStart.isEnabled(); assertTrue(enabled); verify(autoStarter).isEnabled(); } @Test void IsEnabled_NotSupported_False() { when(autoStarter.isSupported()).thenReturn(false); var enabled = autoStart.isEnabled(); assertFalse(enabled); verify(autoStarter, never()).isEnabled(); } } ================================================ FILE: app/src/test/java/io/xeres/app/application/autostart/autostarter/AutoStarterGenericTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.autostart.autostarter; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; class AutoStarterGenericTest { private static AutoStarterGeneric autoStarterGeneric; @BeforeAll static void setup() { autoStarterGeneric = new AutoStarterGeneric(); } @Test void isSupported() { assertFalse(autoStarterGeneric.isSupported()); } @Test void isEnabled() { assertThrows(UnsupportedOperationException.class, () -> autoStarterGeneric.isEnabled()); } @Test void enable() { assertThrows(UnsupportedOperationException.class, () -> autoStarterGeneric.enable()); } @Test void disable() { assertThrows(UnsupportedOperationException.class, () -> autoStarterGeneric.disable()); } } ================================================ FILE: app/src/test/java/io/xeres/app/application/environment/DefaultPropertiesTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.application.environment; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class DefaultPropertiesTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(DefaultProperties.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/configuration/DataDirConfigurationTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.configuration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.env.Environment; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DataDirConfigurationTest { @Mock private Environment environment; @InjectMocks private DataDirConfiguration dataDirConfiguration; @Test void GetDataDir_DataSourceAlreadySet_Success() { when(environment.getProperty("spring.datasource.url")).thenReturn("something"); var dataDir = dataDirConfiguration.getDataDir(); assertNull(dataDir); } // Any tests that creates data dirs and so on don't work well with CI } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/aead/AEADTest.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.aead; import io.xeres.testutils.TestUtils; import org.apache.commons.lang3.RandomUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; class AEADTest { private static SecretKey key; @BeforeAll static void setup() { key = AEAD.generateKey(); } @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(AEAD.class); } @Test void EncryptChaCha20Poly1305_DecryptChaCha20Poly1305_Success() { var nonce = RandomUtils.insecure().randomBytes(12); var plainText = "hello world".getBytes(StandardCharsets.UTF_8); var aad = RandomUtils.insecure().randomBytes(16); var cipherText = AEAD.encryptChaCha20Poly1305(key, nonce, plainText, aad); var decryptedText = AEAD.decryptChaCha20Poly1305(key, nonce, cipherText, aad); assertArrayEquals(plainText, decryptedText); } @Test void EncryptChaCha20Poly1305_DecryptChaCha20Poly1305_BadNonce() { var nonce = RandomUtils.insecure().randomBytes(8); var plainText = "hello world".getBytes(StandardCharsets.UTF_8); var aad = RandomUtils.insecure().randomBytes(16); assertThrows(IllegalArgumentException.class, () -> AEAD.encryptChaCha20Poly1305(key, nonce, plainText, aad)); } @Test void EncryptChaCha20Aes256_DecryptChaCha20Aes256_Success() { var nonce = RandomUtils.insecure().randomBytes(12); var plainText = "hello world".getBytes(StandardCharsets.UTF_8); var aad = RandomUtils.insecure().randomBytes(16); var cipherText = AEAD.encryptChaCha20Sha256(key, nonce, plainText, aad); var decryptedText = AEAD.decryptChaCha20Sha256(key, nonce, cipherText, aad); assertArrayEquals(plainText, decryptedText); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/aes/AESTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.aes; import io.xeres.app.crypto.hash.sha1.Sha1MessageDigest; import io.xeres.testutils.TestUtils; import org.apache.commons.lang3.RandomUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; class AESTest { private static final byte[] aesKey = new byte[16]; private static byte[] iv; @BeforeAll static void setup() { Sha1MessageDigest digest = new Sha1MessageDigest(); digest.update(RandomUtils.insecure().randomBytes(16)); System.arraycopy(digest.getBytes(), 0, aesKey, 0, aesKey.length); iv = RandomUtils.insecure().randomBytes(8); } @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(AES.class); } @Test void Encrypt_AES_Success() { var plainText = "Hello cruel world".getBytes(StandardCharsets.UTF_8); var cipherText = AES.encrypt(aesKey, iv, plainText); var decryptedText = AES.decrypt(aesKey, iv, cipherText); assertArrayEquals(plainText, decryptedText); } @Test void Encrypt_AES_BadKey() { var plainText = "Hello cruel world".getBytes(StandardCharsets.UTF_8); assertThrows(IllegalArgumentException.class, () -> AES.encrypt(new byte[8], iv, plainText)); } @Test void Encrypt_AES_BadIv() { var plainText = "Hello cruel world".getBytes(StandardCharsets.UTF_8); assertThrows(IllegalArgumentException.class, () -> AES.encrypt(aesKey, new byte[4], plainText)); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/chatcipher/ChatChallengeTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.chatcipher; import io.xeres.app.crypto.hash.chat.ChatChallenge; import io.xeres.common.id.GxsId; import io.xeres.common.id.Id; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class ChatChallengeTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(ChatChallenge.class); } @Test void Code_Various_Success() { var gxsId = new GxsId(Id.toBytes("01dc22f128d9495541f780a254b89630")); var code = ChatChallenge.code(gxsId, Long.parseUnsignedLong("10949563242187165295"), Long.parseUnsignedLong("140257447151802099")); assertEquals(Long.parseUnsignedLong("1540395435043678632"), code); gxsId = new GxsId(Id.toBytes("01dc22f128d9495541f780a254b89630")); code = ChatChallenge.code(gxsId, Long.parseUnsignedLong("10949563242187165295"), Long.parseUnsignedLong("3128845210392038968")); assertEquals(Long.parseUnsignedLong("9133905927926710723"), code); gxsId = new GxsId(Id.toBytes("01dc22f128d9495541f780a254b89630")); code = ChatChallenge.code(gxsId, Long.parseUnsignedLong("10949563242187165295"), Long.parseUnsignedLong("15552989625937603562")); assertEquals(Long.parseUnsignedLong("2213486716447545487"), code); gxsId = new GxsId(Id.toBytes("01dc22f128d9495541f780a254b89630")); code = ChatChallenge.code(gxsId, Long.parseUnsignedLong("10949563242187165295"), Long.parseUnsignedLong("140257447151802099")); assertEquals(Long.parseUnsignedLong("1540395435043678632"), code); gxsId = new GxsId(Id.toBytes("01dc22f128d9495541f780a254b89630")); code = ChatChallenge.code(gxsId, Long.parseUnsignedLong("10949563242187165295"), Long.parseUnsignedLong("3128845210392038968")); assertEquals(Long.parseUnsignedLong("9133905927926710723"), code); gxsId = new GxsId(Id.toBytes("01dc22f128d9495541f780a254b89630")); code = ChatChallenge.code(gxsId, Long.parseUnsignedLong("10949563242187165295"), Long.parseUnsignedLong("15552989625937603562")); assertEquals(Long.parseUnsignedLong("2213486716447545487"), code); gxsId = new GxsId(Id.toBytes("01dc22f128d9495541f780a254b89630")); code = ChatChallenge.code(gxsId, Long.parseUnsignedLong("10949563242187165295"), Long.parseUnsignedLong("140257447151802099")); assertEquals(Long.parseUnsignedLong("1540395435043678632"), code); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/dh/DiffieHellmanTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.dh; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import javax.crypto.interfaces.DHPublicKey; import java.math.BigInteger; import java.security.KeyPair; import static io.xeres.app.crypto.dh.DiffieHellman.G; import static io.xeres.app.crypto.dh.DiffieHellman.P; import static org.junit.jupiter.api.Assertions.*; class DiffieHellmanTest { private static KeyPair keyPair; @BeforeAll static void setup() { keyPair = io.xeres.app.crypto.dh.DiffieHellman.generateKeys(); } @Test void utilityClassCheck() throws NoSuchMethodException { TestUtils.assertUtilityClass(DiffieHellman.class); } @Test void DiffieHellman_Validate() { assertTrue(isSafePrime(P)); assertTrue(isGeneratorValid(G)); } @Test void DiffieHellman_Generation_Success() { assertNotNull(keyPair); assertEquals("DH", keyPair.getPrivate().getAlgorithm()); assertEquals("DH", keyPair.getPublic().getAlgorithm()); } @Test void DiffieHellman_GetPublicKey() { var publicKeyNum = ((DHPublicKey) keyPair.getPublic()).getY(); assertEquals(((DHPublicKey) keyPair.getPublic()).getY(), ((DHPublicKey) DiffieHellman.getPublicKey(publicKeyNum)).getY()); } @Test void DiffieHellman_GenerateCommonSecret() { var receivedKeyPair = DiffieHellman.generateKeys(); var common = DiffieHellman.generateCommonSecretKey(keyPair.getPrivate(), receivedKeyPair.getPublic()); assertNotNull(common); } @Test void DiffieHellman_FullExchange() { var heike = DiffieHellman.generateKeys(); var juergen = DiffieHellman.generateKeys(); var heikeSecret = DiffieHellman.generateCommonSecretKey(heike.getPrivate(), juergen.getPublic()); var juergenSecret = DiffieHellman.generateCommonSecretKey(juergen.getPrivate(), heike.getPublic()); assertArrayEquals(heikeSecret, juergenSecret); } private static boolean isSafePrime(BigInteger p) { // Check if p is a safe prime (p = 2q + 1, where q is also prime) BigInteger q = p.subtract(BigInteger.ONE).divide(BigInteger.TWO); return p.isProbablePrime(10) && q.isProbablePrime(10); } private static boolean isGeneratorValid(BigInteger g) { // Usually 2 or 5. return g.equals(new BigInteger("2")) || g.equals(new BigInteger("5")); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/ec/Ed25519Test.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.ec; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.security.KeyPair; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; class Ed25519Test { private static KeyPair keyPair; @BeforeAll static void setup() { keyPair = Ed25519.generateKeys(255); } @Test void utilityClassCheck() throws NoSuchMethodException { TestUtils.assertUtilityClass(Ed25519.class); } @Test void Generation_Success() { assertNotNull(keyPair); assertEquals("EdDSA", keyPair.getPrivate().getAlgorithm()); assertEquals("EdDSA", keyPair.getPublic().getAlgorithm()); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/hmac/sha1/Sha1HMacTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hmac.sha1; import org.junit.jupiter.api.Test; import javax.crypto.spec.SecretKeySpec; import java.util.HexFormat; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; class Sha1HMacTest { @Test void RFC2202_Test_Case1() { var hexFormat = HexFormat.of(); var keySpec = new SecretKeySpec(hexFormat.parseHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"), "AES"); var hmac = new Sha1HMac(keySpec); assertNotNull(hmac); hmac.update("Hi There".getBytes()); assertArrayEquals(hexFormat.parseHex("b617318655057264e28bc0b6fb378c8ef146be00"), hmac.getBytes()); } @Test void RFC2202_Test_Case2() { var hexFormat = HexFormat.of(); var keySpec = new SecretKeySpec(hexFormat.parseHex("4a656665"), "AES"); var hmac = new Sha1HMac(keySpec); assertNotNull(hmac); hmac.update("what do ya want for nothing?".getBytes()); assertArrayEquals(hexFormat.parseHex("effcdf6ae5eb2fa2d27416d5f184df9c259a7c79"), hmac.getBytes()); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/hmac/sha256/Sha256HMacTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.hmac.sha256; import org.junit.jupiter.api.Test; import javax.crypto.spec.SecretKeySpec; import java.util.HexFormat; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; class Sha256HMacTest { @Test void RFC2202_Test_Case1() { var hexFormat = HexFormat.of(); var keySpec = new SecretKeySpec(hexFormat.parseHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"), "AES"); var hmac = new Sha256HMac(keySpec); assertNotNull(hmac); hmac.update("Hi There".getBytes()); assertArrayEquals(hexFormat.parseHex("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"), hmac.getBytes()); } @Test void RFC2202_Test_Case2() { var hexFormat = HexFormat.of(); var keySpec = new SecretKeySpec(hexFormat.parseHex("4a656665"), "AES"); var hmac = new Sha256HMac(keySpec); assertNotNull(hmac); hmac.update("what do ya want for nothing?".getBytes()); assertArrayEquals(hexFormat.parseHex("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"), hmac.getBytes()); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/pgp/PGPTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.pgp; import io.xeres.testutils.TestUtils; import org.bouncycastle.bcpg.ArmoredInputStream; import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.security.InvalidKeyException; import java.security.Security; import java.security.SignatureException; import static io.xeres.app.crypto.pgp.PGP.*; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.*; class PGPTest { private static final int KEY_SIZE = 512; private static PGPSecretKey pgpSecretKey; @BeforeAll static void setup() throws PGPException { Security.addProvider(new BouncyCastleProvider()); pgpSecretKey = generateSecretKey("test", null, KEY_SIZE); } @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(PGP.class); } /** * Generates a PGP secret key. */ @Test void GenerateSecretKey_Success() throws PGPException { assertNotNull(pgpSecretKey); assertTrue(pgpSecretKey.isMasterKey()); assertTrue(pgpSecretKey.isSigningKey()); assertFalse(pgpSecretKey.isPrivateKeyEmpty()); assertEquals(SymmetricKeyAlgorithmTags.AES_128, pgpSecretKey.getKeyEncryptionAlgorithm()); assertNotNull(pgpSecretKey.getPublicKey()); assertNotNull(pgpSecretKey.extractPrivateKey(new JcePBESecretKeyDecryptorBuilder().build("".toCharArray()))); } /** * Signs using a PGP secret key then verifies. */ @Test void Sign_Success() throws PGPException, IOException, SignatureException { var in = "The lazy dog jumps over the drunk fox".getBytes(); var out = new ByteArrayOutputStream(); sign(pgpSecretKey, new ByteArrayInputStream(in), out, Armor.NONE); verify(pgpSecretKey.getPublicKey(), out.toByteArray(), new ByteArrayInputStream(in)); } @Test void Sign_Armored_Success() throws PGPException, IOException, SignatureException { var in = "The lazy dog jumps over the drunk fox".getBytes(); var out = new ByteArrayOutputStream(); sign(pgpSecretKey, new ByteArrayInputStream(in), out, Armor.BASE64); verify(pgpSecretKey.getPublicKey(), new ArmoredInputStream(new ByteArrayInputStream(out.toByteArray())).readAllBytes(), new ByteArrayInputStream(in)); } /** * Signs using a PGP secret key then verifies with another. */ @Test void Sign_WrongKey_Failure() throws PGPException, IOException { var in = "The lazy dog jumps over the drunk fox".getBytes(); var pgpSecretKey2 = generateSecretKey("test2", null, KEY_SIZE); var out = new ByteArrayOutputStream(); sign(pgpSecretKey, new ByteArrayInputStream(in), out, Armor.NONE); assertThatThrownBy(() -> verify(pgpSecretKey2.getPublicKey(), out.toByteArray(), new ByteArrayInputStream(in))) .isInstanceOf(SignatureException.class); } @Test void GetSecretKey_Success() throws IOException { assertEquals(pgpSecretKey.getKeyID(), getPGPSecretKey(pgpSecretKey.getEncoded()).getKeyID()); } @Test void GetSecretKey_Corrupted_Failure() { assertThatThrownBy(() -> getPGPSecretKey(new byte[]{1, 2, 3})) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("corrupted"); } @Test void GetPublicKey_Success() throws IOException, InvalidKeyException { assertEquals(pgpSecretKey.getPublicKey().getKeyID(), getPGPPublicKey(pgpSecretKey.getPublicKey().getEncoded()).getKeyID()); } @Test void GetPublicKey_Corrupted_Failure() { assertThatThrownBy(() -> getPGPPublicKey(new byte[]{1, 2, 3})) .isInstanceOf(InvalidKeyException.class) .hasMessageContaining("corrupted"); } @Test void GetPublicKeyArmored_Success() throws IOException { var out = new ByteArrayOutputStream(); getPublicKeyArmored(pgpSecretKey.getPublicKey(), out); var output = out.toString(); assertTrue(output.contains("BEGIN PGP")); assertTrue(output.contains("END PGP")); } @Test void GetUpdateForSigning_Success() throws PGPException, IOException { var updateSigningKey = getUpdateSigningKey(); assertNotNull(updateSigningKey); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rsa/RSATest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsa; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.IOException; import java.io.Serial; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.spec.InvalidKeySpecException; import static org.junit.jupiter.api.Assertions.*; class RSATest { private static final int KEY_SIZE = 512; private static KeyPair keyPair; @BeforeAll static void setup() { keyPair = RSA.generateKeys(KEY_SIZE); } @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(RSA.class); } /** * Generates an RSA secret key. */ @Test void GenerateKeys_Success() { assertNotNull(keyPair); assertEquals("RSA", keyPair.getPrivate().getAlgorithm()); assertEquals("RSA", keyPair.getPublic().getAlgorithm()); } @Test void GetPrivateKey_Success() throws InvalidKeySpecException, NoSuchAlgorithmException { assertEquals(keyPair.getPrivate(), RSA.getPrivateKey(keyPair.getPrivate().getEncoded())); } @Test void GetPublicKey_Success() throws InvalidKeySpecException, NoSuchAlgorithmException { assertEquals(keyPair.getPublic(), RSA.getPublicKey(keyPair.getPublic().getEncoded())); } @Test void Sign_Success() { byte[] data = {1, 2, 3}; var signature = RSA.sign(keyPair.getPrivate(), data); assertNotNull(signature); var result = RSA.verify(keyPair.getPublic(), signature, data); assertTrue(result); } @Test void Sign_TemperedData_Failure() { byte[] data = {1, 2, 3}; var signature = RSA.sign(keyPair.getPrivate(), data); assertNotNull(signature); data[0] = 0; var result = RSA.verify(keyPair.getPublic(), signature, data); assertFalse(result); } @Test void Sign_InvalidKey_ThrowsException() { byte[] data = {1, 2, 3}; var privateKey = new PrivateKey() { @Serial private static final long serialVersionUID = -5166467762224595264L; @Override public String getAlgorithm() { return "RSA"; } @Override public String getFormat() { return "PKCS#8"; } @Override public byte[] getEncoded() { return new byte[0]; // Invalid key } }; assertThrows(IllegalArgumentException.class, () -> RSA.sign(privateKey, data)); } @Test void Convert_Private_Pkcs8_To_Pkcs1_And_Back_Success() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { var pkcs1 = RSA.getPrivateKeyAsPkcs1(keyPair.getPrivate()); var privateKey = RSA.getPrivateKeyFromPkcs1(pkcs1); assertArrayEquals(keyPair.getPrivate().getEncoded(), privateKey.getEncoded()); } @Test void Convert_Public_X509_To_Pkcs1_And_Back_Success() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { var pkcs1 = RSA.getPublicKeyAsPkcs1(keyPair.getPublic()); var publicKey = RSA.getPublicKeyFromPkcs1(pkcs1); assertArrayEquals(keyPair.getPublic().getEncoded(), publicKey.getEncoded()); } @Test void GetGxsId_Insecure() { // noinspection deprecation var gxsIdInsecure = RSA.getGxsIdInsecure(keyPair.getPublic()); assertNotNull(gxsIdInsecure); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rscrypto/RsCryptoTest.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rscrypto; import io.xeres.app.crypto.aead.AEAD; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertArrayEquals; class RsCryptoTest { private static SecretKey key; @BeforeAll static void setup() { key = AEAD.generateKey(); } @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(RsCrypto.class); } @Test void ChaCha20Sha256_Encrypt_Decrypt_Success() { var plainText = "hello, world".getBytes(StandardCharsets.UTF_8); var cipherText = RsCrypto.encryptAuthenticateData(key, plainText, RsCrypto.EncryptionFormat.CHACHA20_SHA256); var decryptedText = RsCrypto.decryptAuthenticateData(key, cipherText); assertArrayEquals(plainText, decryptedText); } @Test void ChaCha20Poly1305_Encrypt_Decrypt_Success() { var plainText = "bye, cruel world".getBytes(StandardCharsets.UTF_8); var cipherText = RsCrypto.encryptAuthenticateData(key, plainText, RsCrypto.EncryptionFormat.CHACHA20_POLY1305); var decryptedText = RsCrypto.decryptAuthenticateData(key, cipherText); assertArrayEquals(plainText, decryptedText); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rsid/RSCertificateTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import static io.xeres.common.rsid.Type.CERTIFICATE; import static org.junit.jupiter.api.Assertions.*; class RSCertificateTest { @Test void Build_Success() { var profile = ProfileFakes.createProfile("Nemesis", 0x9F00B21277698D8DL, Id.toBytes("60049f670534eab17dda2e6d9f00b21277698d8d"), Id.toBytes("984d0461fd80400102008e20511e623f662693d054e1aeb26a007e17f745d4616a6a647d22313b67111ce5f45db22fb670bb5e05f4846ad6d686224acc22966f28e1a50d99d4afb295fb0011010001b4084e656d6573697320885c041001020006050261fd8040000a09109f00b21277698d8d97e401ff688d2b9b73551587858994309485909a36b5401518716698131e1811d8f8204348392c89e99fcb21651d7490e9877b80ced7e11aabbb7c0538853954d77d047b")); var location = LocationFakes.createLocation("Home", profile, LocationIdentifier.fromString("738ea192064e3f20e766438cc9305bd5")); var rsId = new RSIdBuilder(CERTIFICATE) .setName(profile.getName().getBytes()) .setProfile(profile) .setLocationIdentifier(location.getLocationIdentifier()) .addLocator(Connection.from(PeerAddress.fromAddress("192.168.1.50:1234"))) .addLocator(Connection.from(PeerAddress.fromAddress("85.1.2.3:1234"))) .addLocator(Connection.from(PeerAddress.fromHostname("foo.bar.com"))) .addLocator(Connection.from(PeerAddress.fromAddress("85.1.2.4:1234"))) .build(); var armored = rsId.getArmored(); assertEquals(""" CQEGAbeYTQRh/YBAAQIAjiBRHmI/ZiaT0FThrrJqAH4X90XUYWpqZH0iMTtnERzl 9F2yL7Zwu14F9IRq1taGIkrMIpZvKOGlDZnUr7KV+wARAQABtAhOZW1lc2lzIIhc BBABAgAGBQJh/YBAAAoJEJ8AshJ3aY2Nl+QB/2iNK5tzVRWHhYmUMJSFkJo2tUAV GHFmmBMeGBHY+CBDSDksiemfyyFlHXSQ6Yd7gM7X4Rqru3wFOIU5VNd9BHsCBlUB AgME0gMGwKgBMgTSBA1mb28uYmFyLmNvbQTSBgdOZW1lc2lzBRBzjqGSBk4/IOdm Q4zJMFvVCgZVAQIEBNIHA90yoQ==""", armored); } @Test void Parse_Success() { var string = """ CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2 gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU 9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBa M+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P 6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCg NwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChH ZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE 3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD 2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz 2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIY HIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkE zS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfi F9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhv bWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEH AxnSjw=="""; var rsId = RSId.parse(string, CERTIFICATE); assertTrue(rsId.isPresent()); assertNotNull(rsId.get().getPgpPublicKey()); assertFalse(rsId.get().getInternalIp().isPresent()); // RS put 169.254.67.38 in my certificate... assertTrue(rsId.get().getExternalIp().isPresent()); assertNotNull(rsId.get().getLocationIdentifier()); } @ParameterizedTest @ValueSource(strings = { // Empty "", // Wrong certificate version "CQEFAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAxnSjw==", // No version "AcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAxnSjw==", // Wrong checksum "CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAxnSjg==", // Wrong checksum length "CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAhnSjw==", // Missing checksum "CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wE=", // Packet shorter than advertised length "CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHBBnSjw==", // Missing location id "CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIHA9kC3w==", // Missing name "CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQUQeqXQPR8TNK2Dtd76cNvvAQcDtYSn", // Missing PGP key "CQEGAgZUS9bAfA4DBqn+QyZ8DgQSaG9tZS5keW4uemFwZWsuY29tBgtNeSBjb21wdXRlcgUQeqXQPR8TNK2Dtd76cNvvAQcDHrnJ" }) void Parse_Error(String string) { var rsId = RSId.parse(string, CERTIFICATE); assertFalse(rsId.isPresent()); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rsid/RSIdArmorTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class RSIdArmorTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(RSIdArmor.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rsid/RSIdCrcTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import static io.xeres.app.crypto.rsid.RSIdCrc.calculate24bitsCrc; import static org.junit.jupiter.api.Assertions.assertEquals; class RSIdCrcTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(RSIdCrc.class); } @Test void Calculate24BitsCrc_Success() { var input = "The quick brown fox jumps over the lazy dog".getBytes(); assertEquals(10641804, calculate24bitsCrc(input, input.length)); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rsid/RSIdFakes.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.testutils.StringFakes; import static io.xeres.common.rsid.Type.CERTIFICATE; import static io.xeres.common.rsid.Type.SHORT_INVITE; public final class RSIdFakes { private RSIdFakes() { throw new UnsupportedOperationException("Utility class"); } public static RSId createShortInvite() { var profile = ProfileFakes.createProfile(); var builder = new RSIdBuilder(SHORT_INVITE); return builder.setName(StringFakes.createNickname().getBytes()) .setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier()) .setPgpFingerprint(profile.getProfileFingerprint().getBytes()) .build(); } public static RSId createRsCertificate() { var builder = new RSIdBuilder(CERTIFICATE); return builder.setName(StringFakes.createNickname().getBytes()) .setProfile(ProfileFakes.createProfile()) .setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier()) .build(); } public static RSId createRsCertificate(Profile profile) { var builder = new RSIdBuilder(CERTIFICATE); return builder.setName(profile.getName().getBytes()) .setProfile(profile) .setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier()) .build(); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rsid/RSSerialVersionTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import org.junit.jupiter.api.Test; import java.math.BigInteger; import java.util.concurrent.ThreadLocalRandom; import static io.xeres.app.crypto.rsid.RSSerialVersion.*; import static org.junit.jupiter.api.Assertions.assertEquals; class RSSerialVersionTest { @Test void Enum_Order_Fixed() { assertEquals(0, V06_0000.ordinal()); assertEquals(1, V06_0001.ordinal()); assertEquals(2, V07_0001.ordinal()); assertEquals(3, values().length); } @Test void GetFromSerialNumber_Success() { var rsOld = new BigInteger(Integer.toString(ThreadLocalRandom.current().nextInt(100000, 2000000000)), 16); var rs6_4 = new BigInteger("60000", 16); var rs6_5 = new BigInteger("60001", 16); var rs7 = new BigInteger("70001", 16); assertEquals(V06_0000, RSSerialVersion.getFromSerialNumber(rs6_4)); assertEquals(RSSerialVersion.V06_0001, RSSerialVersion.getFromSerialNumber(rs6_5)); assertEquals(RSSerialVersion.V07_0001, RSSerialVersion.getFromSerialNumber(rs7)); assertEquals(V06_0000, RSSerialVersion.getFromSerialNumber(rsOld)); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/rsid/RSShortInviteTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.rsid; import io.xeres.app.database.model.connection.Connection; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import static io.xeres.app.crypto.rsid.ShortInvite.*; import static io.xeres.common.rsid.Type.SHORT_INVITE; import static org.junit.jupiter.api.Assertions.*; class RSShortInviteTest { @Test void Values() { assertEquals(0x0, SSL_ID); assertEquals(0x1, NAME); assertEquals(0x2, LOCATOR); assertEquals(0x3, PGP_FINGERPRINT); assertEquals(0X4, CHECKSUM); assertEquals(0X90, HIDDEN_LOCATOR); assertEquals(0X91, DNS_LOCATOR); assertEquals(0X92, EXT4_LOCATOR); assertEquals(0X93, LOC4_LOCATOR); } @Test void SwapBytes_Success() { var input = new byte[]{1, 2, 3, 4, 5, 6}; var output = new byte[]{4, 3, 2, 1, 5, 6}; assertArrayEquals(output, swapBytes(input)); } @Test void SwapBytes_WrongInput_NoSwap() { var input = new byte[]{1, 2, 3, 4, 5, 6, 7}; var output = new byte[]{1, 2, 3, 4, 5, 6, 7}; assertArrayEquals(output, swapBytes(input)); } @Test void Build_Success() { var profile = ProfileFakes.createProfile("Nemesis", 0x792b20ca657e2706L, Id.toBytes("06d4b446d209e752fa711a39792b20ca657e2706"), new byte[]{1}); var location = LocationFakes.createLocation("Home", profile, LocationIdentifier.fromString("738ea192064e3f20e766438cc9305bd5")); var rsId = new RSIdBuilder(SHORT_INVITE) .setName(profile.getName().getBytes()) .setPgpFingerprint(profile.getProfileFingerprint().getBytes()) .setLocationIdentifier(location.getLocationIdentifier()) .addLocator(Connection.from(PeerAddress.fromAddress("192.168.1.50:1234"))) .addLocator(Connection.from(PeerAddress.fromAddress("85.1.2.3:1234"))) .addLocator(Connection.from(PeerAddress.fromAddress("foo.bar.com:1234"))) .addLocator(Connection.from(PeerAddress.fromAddress("85.1.2.4:1234"))) .build(); var armored = rsId.getArmored(); assertEquals("ABBzjqGSBk4/IOdmQ4zJMFvVAQdOZW1lc2lzAxQG1LRG0gnnUvpxGjl5KyDKZX4nBpENBNJmb28uYmFyLmNvbZIGAwIBVQTSkwYyAajABNICFGlwdjQ6Ly84NS4xLjIuNDoxMjM0BAOiD+U=", armored); } @Test void Parse_Success() { var string = "\nABBzjqGSBk4/IOdmQ4zJMFvVAQdOZW1lc2lzAxQG1LRG0gnnUvpxGjl5KyDKZX4nBpENBNJmb28uYmFyLmNvbZIGAwIBVQTSkwYyAajABNICFGlwdjQ6Ly84NS4xLjIuNDoxMjM0BAOiD+U=\n"; var rsId = RSId.parse(string, SHORT_INVITE); assertTrue(rsId.isPresent()); assertEquals("Nemesis", rsId.get().getName()); assertEquals(0x792b20ca657e2706L, rsId.get().getPgpIdentifier()); assertArrayEquals(Id.toBytes("06d4b446d209e752fa711a39792b20ca657e2706"), rsId.get().getPgpFingerprint().getBytes()); assertArrayEquals(Id.toBytes("738ea192064e3f20e766438cc9305bd5"), rsId.get().getLocationIdentifier().getBytes()); assertTrue(rsId.get().getHiddenNodeAddress().isEmpty()); assertTrue(rsId.get().getInternalIp().isPresent()); assertTrue(rsId.get().getInternalIp().get().getAddress().isPresent()); assertEquals("192.168.1.50:1234", rsId.get().getInternalIp().get().getAddress().get()); assertTrue(rsId.get().getExternalIp().isPresent()); assertTrue(rsId.get().getExternalIp().get().getAddress().isPresent()); assertEquals("85.1.2.3:1234", rsId.get().getExternalIp().get().getAddress().get()); assertTrue(rsId.get().getDnsName().isPresent()); assertTrue(rsId.get().getDnsName().get().getAddress().isPresent()); assertEquals("foo.bar.com:1234", rsId.get().getDnsName().get().getAddress().get()); assertFalse(rsId.get().getLocators().isEmpty()); assertTrue(rsId.get().getLocators().stream().findFirst().isPresent()); assertEquals("85.1.2.4:1234", rsId.get().getLocators().stream().findFirst().get().getAddress().orElseThrow()); } @Test void Parse_Empty() { var string = ""; var rsId = RSId.parse(string, SHORT_INVITE); assertFalse(rsId.isPresent()); } @ParameterizedTest @ValueSource(strings = { // Empty "", // Wrong checksum "ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEA6cUSw==", // Wrong checksum length "ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEAqcUSg==", // Missing checksum "ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbM=", // Packet shorter than advertised length "ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEBKcUSg==", // Missing location id "AQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEA4YtNA==", // Missing name "ABCE1fl2NmWv3Ri9EjwzgIHAAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEAzEjTQ==", // Missing PGP fingerprint "ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzkgb+2cNVQbOTBk4BqMBBswQDXfgj" }) void Parse_Error(String string) { var rsId = RSId.parse(string, SHORT_INVITE); assertFalse(rsId.isPresent()); } } ================================================ FILE: app/src/test/java/io/xeres/app/crypto/x509/X509Test.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.crypto.x509; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.crypto.rsid.RSSerialVersion; import io.xeres.testutils.TestUtils; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.io.IOException; import java.math.BigInteger; import java.security.KeyPair; import java.security.Security; import java.security.SignatureException; import java.security.cert.CertificateException; import java.util.Date; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; class X509Test { private static final int KEY_SIZE = 512; private static PGPSecretKey pgpSecretKey; private static KeyPair keyPair; @BeforeAll static void setup() throws PGPException { Security.addProvider(new BouncyCastleProvider()); pgpSecretKey = PGP.generateSecretKey("test", null, KEY_SIZE); keyPair = RSA.generateKeys(KEY_SIZE); } @Test void Instance_ThrowException() throws NoSuchMethodException { TestUtils.assertUtilityClass(X509.class); } /** * Generates an X509 certificate. */ @Test void GenerateCertificate_Success() throws PGPException, IOException, CertificateException, SignatureException { generateCertificate(RSSerialVersion.V07_0001.serialNumber()); } @Test void GenerateCertificate_OldRS_0_6_5_Success() throws PGPException, IOException, CertificateException, SignatureException { generateCertificate(RSSerialVersion.V06_0001.serialNumber()); } @Test void GenerateCertificate_OldestRS_Success() throws PGPException, IOException, CertificateException, SignatureException { generateCertificate(new BigInteger("123456", 16)); } private void generateCertificate(BigInteger serialNumber) throws IOException, CertificateException, PGPException, SignatureException { var issuer = "CN=1234"; var subject = "CN=-"; var from = new Date(0); var to = new Date(0); var cert = X509.generateCertificate(pgpSecretKey, keyPair.getPublic(), issuer, subject, from, to, serialNumber); assertNotNull(cert); assertEquals(issuer, cert.getIssuerX500Principal().getName()); assertEquals(subject, cert.getSubjectX500Principal().getName()); assertEquals(serialNumber, cert.getSerialNumber()); assertEquals(from, cert.getNotBefore()); assertEquals(to, cert.getNotAfter()); PGP.verify(pgpSecretKey.getPublicKey(), cert.getSignature(), new ByteArrayInputStream(cert.getTBSCertificate())); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/chat/ChatMapperTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.chat; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class ChatMapperTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(ChatMapper.class); } @Test void toDTO_Success() { var chatRoom = ChatRoomFakes.createChatRoom(); var chatRoomDTO = ChatMapper.toDTO(chatRoom.getAsRoomInfo()); assertEquals(chatRoom.getId(), chatRoomDTO.id()); assertEquals(chatRoom.getName(), chatRoomDTO.name()); assertEquals(chatRoom.getTopic(), chatRoomDTO.topic()); assertEquals(chatRoom.isSigned(), chatRoomDTO.isSigned()); // flags aren't compared as their logic is different } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/chat/ChatRoomFakes.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.chat; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.message.chat.RoomType; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; public final class ChatRoomFakes { private ChatRoomFakes() { throw new UnsupportedOperationException("Utility class"); } public static ChatRoom createChatRoomEntity() { return createChatRoomEntity(IdFakes.createLong(), IdentityFakes.createOwn(), RandomStringUtils.insecure().nextAlphabetic(8), RandomStringUtils.insecure().nextAlphabetic(8), 0); } public static ChatRoom createChatRoomEntity(IdentityGroupItem identityGroupItem) { return createChatRoomEntity(IdFakes.createLong(), identityGroupItem, RandomStringUtils.insecure().nextAlphabetic(8), RandomStringUtils.insecure().nextAlphabetic(8), 0); } public static ChatRoom createChatRoomEntity(long roomId, IdentityGroupItem identityGroupItem, String name, String topic, int flags) { return new ChatRoom(roomId, identityGroupItem, name, topic, flags); } public static io.xeres.app.xrs.service.chat.ChatRoom createChatRoom() { return createChatRoom(IdFakes.createLong(), RandomStringUtils.insecure().nextAlphabetic(8), RandomStringUtils.insecure().nextAlphabetic(8), RoomType.PUBLIC, 5, false); } public static io.xeres.app.xrs.service.chat.ChatRoom createChatRoom(long id, String name, String topic, RoomType roomType, int userCount, boolean isSigned) { return new io.xeres.app.xrs.service.chat.ChatRoom(id, name, topic, roomType, userCount, isSigned); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/connection/ConnectionFakes.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.connection; import io.xeres.app.net.protocol.PeerAddress; import java.util.concurrent.ThreadLocalRandom; public final class ConnectionFakes { private ConnectionFakes() { throw new UnsupportedOperationException("Utility class"); } public static Connection createConnection() { var r = ThreadLocalRandom.current(); return createConnection(PeerAddress.Type.IPV4, r.nextInt(11, 110) + "." + r.nextInt(1, 254) + "." + r.nextInt(1, 254) + "." + r.nextInt(1, 254) + ":" + r.nextInt(1025, 65534), false); } public static Connection createConnection(PeerAddress.Type type, String address, boolean isExternal) { var connection = new Connection(); connection.setType(type); connection.setAddress(address); connection.setExternal(isExternal); return connection; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/connection/ConnectionMapperTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.connection; import io.xeres.common.dto.connection.ConnectionDTO; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.junit.jupiter.api.Assertions.assertEquals; class ConnectionMapperTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(ConnectionMapper.class); } @Test void toDTO_Success() { var connection = ConnectionFakes.createConnection(); var connectionDTO = ConnectionMapper.toDTO(connection); assertEquals(connection.getId(), connectionDTO.id()); assertEquals(connection.getAddress(), connectionDTO.address()); assertEquals(connection.getLastConnected(), connectionDTO.lastConnected()); assertEquals(connection.isExternal(), connectionDTO.external()); } @Test void fromDTO_Success() { var connectionDTO = new ConnectionDTO( 1L, "85.11.11.12", Instant.now(), true ); var connection = ConnectionMapper.fromDTO(connectionDTO); assertEquals(connectionDTO.id(), connection.getId()); assertEquals(connectionDTO.address(), connection.getAddress()); assertEquals(connectionDTO.external(), connection.isExternal()); assertEquals(connectionDTO.lastConnected(), connection.getLastConnected()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/connection/ConnectionTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.connection; import io.xeres.app.net.protocol.PeerAddress; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class ConnectionTest { @Test void From_PeerAddress() { var ip = "1.1.1.1"; var port = 1234; var peerAddress = PeerAddress.from(ip, port); var connection = Connection.from(peerAddress); assertEquals(peerAddress.getType(), connection.getType()); assertEquals(peerAddress.isExternal(), connection.isExternal()); assertEquals(ip, connection.getIp()); assertEquals(port, connection.getPort()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/file/FileFakes.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.file; import io.xeres.common.id.Sha1Sum; import java.time.Instant; public final class FileFakes { private FileFakes() { throw new UnsupportedOperationException("Utility class"); } public static File createFile(String name) { return createFile(name, null); } public static File createFile(String name, File parent) { var file = new File(); file.setName(name); if (parent != null) { file.setParent(parent); } return file; } public static File createFile(String name, long size) { return createFile(name, size, null); } public static File createFile(String name, long size, Instant modified) { return createFile(name, size, modified, null); } public static File createFile(String name, long size, Instant modified, Sha1Sum hash) { var file = new File(); file.setName(name); file.setSize(size); file.setModified(modified); file.setHash(hash); return file; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/BoardGroupItemFakes.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.xrs.service.board.item.BoardGroupItem; import io.xeres.common.id.GxsId; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; public final class BoardGroupItemFakes { private BoardGroupItemFakes() { throw new UnsupportedOperationException("Utility class"); } public static BoardGroupItem createBoardGroupItem() { return createBoardGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8)); } public static BoardGroupItem createBoardGroupItem(GxsId gxsId, String name) { var item = new BoardGroupItem(gxsId, name); item.setDescription(RandomStringUtils.insecure().nextAlphabetic(8)); return item; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/BoardMessageItemFakes.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.xrs.service.board.item.BoardMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; public final class BoardMessageItemFakes { private BoardMessageItemFakes() { throw new UnsupportedOperationException("Utility class"); } public static BoardMessageItem createBoardMessageItem() { return createBoardMessageItem(IdFakes.createGxsId(), IdFakes.createMsgId(), RandomStringUtils.insecure().nextAlphabetic(8)); } private static BoardMessageItem createBoardMessageItem(GxsId gxsId, MsgId msgId, String name) { return new BoardMessageItem(gxsId, msgId, name); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/ChannelGroupItemFakes.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.xrs.service.channel.item.ChannelGroupItem; import io.xeres.common.id.GxsId; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; public final class ChannelGroupItemFakes { private ChannelGroupItemFakes() { throw new UnsupportedOperationException("Utility class"); } public static ChannelGroupItem createChannelGroupItem() { return createChannelGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8)); } public static ChannelGroupItem createChannelGroupItem(GxsId gxsId, String name) { var item = new ChannelGroupItem(gxsId, name); item.setDescription(RandomStringUtils.insecure().nextAlphabetic(8)); return item; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/ChannelMessageItemFakes.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.xrs.service.channel.item.ChannelMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; public final class ChannelMessageItemFakes { private ChannelMessageItemFakes() { throw new UnsupportedOperationException("Utility class"); } public static ChannelMessageItem createChannelMessageItem() { return createChannelMessageItem(IdFakes.createGxsId(), IdFakes.createMsgId(), RandomStringUtils.insecure().nextAlphabetic(8)); } private static ChannelMessageItem createChannelMessageItem(GxsId gxsId, MsgId msgId, String name) { var item = new ChannelMessageItem(gxsId, msgId, name); item.setContent(RandomStringUtils.insecure().nextAlphabetic(20)); return item; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/ForumGroupItemFakes.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.xrs.service.forum.item.ForumGroupItem; import io.xeres.common.id.GxsId; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; public final class ForumGroupItemFakes { private ForumGroupItemFakes() { throw new UnsupportedOperationException("Utility class"); } public static ForumGroupItem createForumGroupItem() { return createForumGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8)); } public static ForumGroupItem createForumGroupItem(GxsId gxsId, String name) { var item = new ForumGroupItem(gxsId, name); item.setDescription(RandomStringUtils.insecure().nextAlphabetic(8)); return item; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/ForumMessageItemFakes.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.database.model.forum.ForumMessageItemSummary; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.testutils.IdFakes; import io.xeres.testutils.StringFakes; import org.apache.commons.lang3.RandomStringUtils; import java.time.Instant; public final class ForumMessageItemFakes { private ForumMessageItemFakes() { throw new UnsupportedOperationException("Utility class"); } public static ForumMessageItem createForumMessageItem() { return createForumMessageItem(IdFakes.createGxsId(), IdFakes.createMsgId(), RandomStringUtils.insecure().nextAlphabetic(8)); } private static ForumMessageItem createForumMessageItem(GxsId gxsId, MsgId msgId, String name) { return new ForumMessageItem(gxsId, msgId, name); } public static ForumMessageItemSummary createForumMessageItemSummary() { return new ForumMessageItemSummaryFake(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createGxsId(), IdFakes.createMsgId(), IdFakes.createMsgId(), IdFakes.createMsgId(), IdFakes.createGxsId(), Instant.now(), false); } public static ForumMessageItemSummary createForumMessageItemSummary(MsgId msgId, GxsId authorGxsId, MsgId parentMsgId) { return new ForumMessageItemSummaryFake(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createGxsId(), msgId, IdFakes.createMsgId(), parentMsgId, authorGxsId, Instant.now(), false); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/ForumMessageItemSummaryFake.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.database.model.forum.ForumMessageItemSummary; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import java.time.Instant; import java.util.Objects; public final class ForumMessageItemSummaryFake implements ForumMessageItemSummary { private final long id; private final String name; private final GxsId gxsId; private final MsgId msgId; private final MsgId originalMsgId; private final MsgId parentMsgId; private final GxsId authorGxsId; private final Instant published; private final boolean read; public ForumMessageItemSummaryFake(long id, String name, GxsId gxsId, MsgId msgId, MsgId originalMsgId, MsgId parentMsgId, GxsId authorGxsId, Instant published, boolean read) { this.id = id; this.name = name; this.gxsId = gxsId; this.msgId = msgId; this.originalMsgId = originalMsgId; this.parentMsgId = parentMsgId; this.authorGxsId = authorGxsId; this.published = published; this.read = read; } @Override public long getId() { return id; } @Override public String getName() { return name; } @Override public GxsId getGxsId() { return gxsId; } @Override public MsgId getMsgId() { return msgId; } @Override public MsgId getOriginalMsgId() { return originalMsgId; } @Override public MsgId getParentMsgId() { return parentMsgId; } @Override public GxsId getAuthorGxsId() { return authorGxsId; } @Override public Instant getPublished() { return published; } @Override public boolean isRead() { return read; } @Override public boolean equals(Object obj) { if (obj == this) return true; if (obj == null || obj.getClass() != getClass()) return false; var that = (ForumMessageItemSummaryFake) obj; return id == that.id && Objects.equals(name, that.name) && Objects.equals(gxsId, that.gxsId) && Objects.equals(msgId, that.msgId) && Objects.equals(originalMsgId, that.originalMsgId) && Objects.equals(parentMsgId, that.parentMsgId) && Objects.equals(authorGxsId, that.authorGxsId) && Objects.equals(published, that.published) && read == that.read; } @Override public int hashCode() { return Objects.hash(id, name, gxsId, msgId, originalMsgId, parentMsgId, authorGxsId, published, read); } @Override public String toString() { return "ForumMessageItemSummaryFake[" + "id=" + id + ", " + "name=" + name + ", " + "gxsId=" + gxsId + ", " + "msgId=" + msgId + ", " + "originalMsgId=" + originalMsgId + ", " + "parentMsgId=" + parentMsgId + ", " + "authorMsgId=" + authorGxsId + ", " + "published=" + published + ", " + "read=" + read + ']'; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/GxsCircleTypeTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import org.junit.jupiter.api.Test; import static io.xeres.app.database.model.gxs.GxsCircleType.*; import static org.junit.jupiter.api.Assertions.assertEquals; class GxsCircleTypeTest { @Test void Enum_Order_Fixed() { assertEquals(0, UNKNOWN.ordinal()); assertEquals(1, PUBLIC.ordinal()); assertEquals(2, EXTERNAL.ordinal()); assertEquals(3, YOUR_FRIENDS_ONLY.ordinal()); assertEquals(4, LOCAL.ordinal()); assertEquals(5, EXTERNAL_SELF.ordinal()); assertEquals(6, YOUR_EYES_ONLY.ordinal()); assertEquals(7, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/GxsClientUpdateFakes.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.common.id.GxsId; import java.time.Instant; import java.util.concurrent.ThreadLocalRandom; public final class GxsClientUpdateFakes { private GxsClientUpdateFakes() { throw new UnsupportedOperationException("Utility class"); } public static GxsClientUpdate createGxsClientUpdate() { return createGxsClientUpdate(LocationFakes.createLocation(), ThreadLocalRandom.current().nextInt(1, 200)); } public static GxsClientUpdate createGxsClientUpdate(Location location, int serviceType) { return new GxsClientUpdate(location, serviceType, Instant.now()); } public static GxsClientUpdate createGxsClientUpdateWithMessages(Location location, GxsId gxsId, Instant update, int serviceType) { return new GxsClientUpdate(location, serviceType, gxsId, update); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/GxsPrivacyFlagsTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import org.junit.jupiter.api.Test; import static io.xeres.app.database.model.gxs.GxsPrivacyFlags.*; import static org.junit.jupiter.api.Assertions.assertEquals; class GxsPrivacyFlagsTest { @Test void Enum_Order_Fixed() { assertEquals(0, PRIVATE.ordinal()); assertEquals(1, RESTRICTED.ordinal()); assertEquals(2, PUBLIC.ordinal()); assertEquals(8, SIGNED_ID.ordinal()); assertEquals(9, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/GxsServiceSettingFakes.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import java.time.Instant; public final class GxsServiceSettingFakes { private GxsServiceSettingFakes() { throw new UnsupportedOperationException("Utility class"); } public static GxsServiceSetting createGxsServiceSetting(int id, Instant lastUpdated) { var gxsServiceSetting = new GxsServiceSetting(id, lastUpdated); gxsServiceSetting.setLastUpdated(lastUpdated); return gxsServiceSetting; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/GxsSignatureFlagsTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import org.junit.jupiter.api.Test; import static io.xeres.app.database.model.gxs.GxsSignatureFlags.*; import static org.junit.jupiter.api.Assertions.assertEquals; class GxsSignatureFlagsTest { @Test void Enum_Order_Fixed() { assertEquals(0, ENCRYPTED.ordinal()); assertEquals(1, ALL_SIGNED.ordinal()); assertEquals(2, THREAD_HEAD.ordinal()); assertEquals(3, NONE_REQUIRED.ordinal()); assertEquals(4, UNUSED_1.ordinal()); assertEquals(5, UNUSED_2.ordinal()); assertEquals(6, UNUSED_3.ordinal()); assertEquals(7, UNUSED_4.ordinal()); assertEquals(8, ANTI_SPAM.ordinal()); assertEquals(9, AUTHENTICATION_REQUIRED.ordinal()); assertEquals(10, IF_NO_PUB_SIGN.ordinal()); assertEquals(11, TRACK_MESSAGES.ordinal()); assertEquals(12, ANTI_SPAM_2.ordinal()); assertEquals(13, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/gxs/IdentityGroupItemFakes.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.gxs; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.Sha1Sum; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; import java.util.EnumSet; public final class IdentityGroupItemFakes { private IdentityGroupItemFakes() { throw new UnsupportedOperationException("Utility class"); } public static IdentityGroupItem createIdentityGroupItem() { return createIdentityGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8)); } public static IdentityGroupItem createIdentityGroupItem(GxsId gxsId, String name) { var item = new IdentityGroupItem(gxsId, name); item.setDiffusionFlags(EnumSet.noneOf(GxsPrivacyFlags.class)); item.setSignatureFlags(EnumSet.noneOf(GxsSignatureFlags.class)); item.setProfileHash(new Sha1Sum(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19})); return item; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/identity/IdentityFakes.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.identity; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.identity.Type; import io.xeres.testutils.IdFakes; import io.xeres.testutils.StringFakes; import static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID; public final class IdentityFakes { private IdentityFakes() { throw new UnsupportedOperationException("Utility class"); } private static long id = OWN_IDENTITY_ID + 1; private static long getUniqueId() { return id++; } public static IdentityGroupItem createOwn() { return createOwn(StringFakes.createNickname()); } public static IdentityGroupItem createOwn(String name) { var identity = new IdentityGroupItem(IdFakes.createGxsId(), name); identity.setId(1L); identity.setType(Type.OWN); return identity; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/identity/IdentityMapperTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.identity; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class IdentityMapperTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(IdentityMapper.class); } @Test void toDTO_Success() { var identity = IdentityFakes.createOwn(); var identityDTO = IdentityMapper.toDTO(identity); assertEquals(identity.getId(), identityDTO.id()); assertEquals(identity.getName(), identityDTO.name()); assertEquals(identity.getGxsId(), identityDTO.gxsId()); assertEquals(identity.getPublished(), identityDTO.updated()); assertEquals(identity.getType(), identityDTO.type()); assertEquals(identity.hasImage(), identityDTO.hasImage()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/location/LocationFakes.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.location; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.NetMode; import io.xeres.testutils.StringFakes; import java.util.concurrent.ThreadLocalRandom; import static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID; public final class LocationFakes { private LocationFakes() { throw new UnsupportedOperationException("Utility class"); } private static long id = OWN_LOCATION_ID + 1; private static long getUniqueId() { return id++; } public static Location createOwnLocation() { return new Location(OWN_LOCATION_ID, StringFakes.createNickname(), ProfileFakes.createProfile(), new LocationIdentifier(getRandomArray())); } public static Location createLocation() { return createLocation(StringFakes.createNickname(), ProfileFakes.createProfile(), new LocationIdentifier(getRandomArray())); } public static Location createLocation(String name, Profile profile) { return createLocation(name, profile, new LocationIdentifier(getRandomArray())); } public static Location createFreshLocation(String name, Profile profile) { var location = new Location(0L, name, profile, new LocationIdentifier(getRandomArray())); location.setNetMode(NetMode.UPNP); location.setVersion("Xeres 0.1.1"); return location; } public static Location createLocation(String name, Profile profile, LocationIdentifier locationIdentifier) { var location = new Location(getUniqueId(), name, profile, locationIdentifier); location.setNetMode(NetMode.UPNP); location.setVersion("Xeres 0.1.1"); return location; } private static byte[] getRandomArray() { var a = new byte[16]; ThreadLocalRandom.current().nextBytes(a); return a; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/location/LocationMapperTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.location; import io.xeres.app.database.model.connection.ConnectionFakes; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.location.Availability; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; class LocationMapperTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(LocationMapper.class); } @Test void toDTO_Success() { var location = LocationFakes.createLocation("test", ProfileFakes.createProfile("test", 1)); var locationDTO = LocationMapper.toDTO(location); assertEquals(location.getId(), locationDTO.id()); assertEquals(location.getName(), locationDTO.name()); assertArrayEquals(location.getLocationIdentifier().getBytes(), locationDTO.locationIdentifier()); assertEquals(location.isConnected(), locationDTO.connected()); assertEquals(location.getLastConnected(), locationDTO.lastConnected()); } @Test void toDeepDTO_Success() { var location = LocationFakes.createLocation("test", ProfileFakes.createProfile("test", 1)); location.addConnection(ConnectionFakes.createConnection()); var locationDTO = LocationMapper.toDeepDTO(location); assertEquals(location.getId(), locationDTO.id()); assertEquals(location.getConnections().getFirst().getAddress(), locationDTO.connections().getFirst().address()); } @Test void fromDTO_Success() { var locationDTO = new LocationDTO( 1L, "test", new byte[16], "foo", null, true, Instant.now(), Availability.AVAILABLE, "Xeres 2.3.2" ); var location = LocationMapper.fromDTO(locationDTO); assertEquals(locationDTO.id(), location.getId()); assertEquals(locationDTO.name(), location.getName()); assertArrayEquals(locationDTO.locationIdentifier(), location.getLocationIdentifier().getBytes()); assertEquals(locationDTO.connected(), location.isConnected()); assertEquals(locationDTO.lastConnected(), location.getLastConnected()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/profile/ProfileFakes.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.profile; import io.xeres.common.id.ProfileFingerprint; import io.xeres.testutils.StringFakes; import java.time.Instant; import java.util.concurrent.ThreadLocalRandom; import static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID; public final class ProfileFakes { private ProfileFakes() { throw new UnsupportedOperationException("Utility class"); } private static long id = OWN_PROFILE_ID + 1; private static long getUniqueId() { return id++; } public static Profile createProfile() { return createProfile(StringFakes.createNickname(), ThreadLocalRandom.current().nextLong()); } public static Profile createFreshProfile(String name, long pgpIdentifier) { return new Profile(0L, name, pgpIdentifier, Instant.now(), new ProfileFingerprint(getRandomArray(20)), getRandomArray(200)); } public static Profile createProfile(String name, long pgpIdentifier) { return createProfile(name, pgpIdentifier, new ProfileFingerprint(getRandomArray(20)), getRandomArray(200)); } public static Profile createProfile(String name, long pgpIdentifier, byte[] pgpFingerprint, byte[] data) { return new Profile(getUniqueId(), name, pgpIdentifier, Instant.now(), new ProfileFingerprint(pgpFingerprint), data); } public static Profile createProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint, byte[] data) { return new Profile(getUniqueId(), name, pgpIdentifier, Instant.now(), profileFingerprint, data); } public static Profile createOwnProfile() { return new Profile(1L, StringFakes.createNickname(), ThreadLocalRandom.current().nextLong(), Instant.now(), new ProfileFingerprint(getRandomArray(20)), getRandomArray(200)); } private static byte[] getRandomArray(int size) { var a = new byte[size]; ThreadLocalRandom.current().nextBytes(a); return a; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/profile/ProfileMapperTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.profile; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.common.dto.profile.ProfileDTO; import io.xeres.common.pgp.Trust; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; class ProfileMapperTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(ProfileMapper.class); } @Test void toDTO_Success() { var profile = ProfileFakes.createProfile("test", 1); var profileDTO = ProfileMapper.toDTO(profile); assertEquals(profile.getId(), profileDTO.id()); assertEquals(profile.getName(), profileDTO.name()); assertEquals(profile.getPgpIdentifier(), Long.parseLong(profileDTO.pgpIdentifier())); assertArrayEquals(profile.getProfileFingerprint().getBytes(), profileDTO.pgpFingerprint()); assertArrayEquals(profile.getPgpPublicKeyData(), profileDTO.pgpPublicKeyData()); assertEquals(profile.isAccepted(), profileDTO.accepted()); assertEquals(profile.getTrust(), profileDTO.trust()); } @Test void toDeepDTO_Success() { var profile = ProfileFakes.createProfile("test", 1); profile.addLocation(LocationFakes.createLocation("foo", profile)); var profileDTO = ProfileMapper.toDeepDTO(profile); assertEquals(profile.getId(), profileDTO.id()); assertEquals(profile.getLocations().getFirst().getId(), profileDTO.locations().getFirst().id()); } @Test void fromDTO_Success() { var profileDTO = new ProfileDTO( 1L, "prout", "2", Instant.now(), new byte[20], new byte[4], true, Trust.ULTIMATE, null ); var profile = ProfileMapper.fromDTO(profileDTO); assertEquals(profileDTO.id(), profile.getId()); assertEquals(profileDTO.name(), profile.getName()); assertEquals(profileDTO.pgpIdentifier(), String.valueOf(profile.getPgpIdentifier())); assertArrayEquals(profileDTO.pgpFingerprint(), profile.getProfileFingerprint().getBytes()); assertArrayEquals(profileDTO.pgpPublicKeyData(), profile.getPgpPublicKeyData()); assertEquals(profileDTO.accepted(), profile.isAccepted()); assertEquals(profileDTO.trust(), profile.getTrust()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/settings/SettingsFakes.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.settings; import java.util.concurrent.ThreadLocalRandom; public final class SettingsFakes { private SettingsFakes() { throw new UnsupportedOperationException("Utility class"); } public static Settings createSettings() { var settings = new Settings(); settings.setPgpPrivateKeyData(getRandomArray(2000)); settings.setLocationPrivateKeyData(getRandomArray(2000)); settings.setLocationPublicKeyData(getRandomArray(500)); settings.setLocationCertificate(getRandomArray(200)); return settings; } private static byte[] getRandomArray(int size) { var a = new byte[size]; ThreadLocalRandom.current().nextBytes(a); return a; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/model/share/ShareFakes.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.model.share; import io.xeres.app.database.model.file.File; import io.xeres.app.database.model.file.FileFakes; import java.nio.file.Path; public final class ShareFakes { private ShareFakes() { throw new UnsupportedOperationException("Utility class"); } public static Share createShare(Path path) { File file = FileFakes.createFile(path.getRoot().toString(), null); for (Path component : path) { file = FileFakes.createFile(component.getFileName().toString(), file); } return createShare(file); } public static Share createShare(File file) { var share = new Share(); share.setFile(file); return share; } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/ChatRoomRepositoryTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.chat.ChatRoomFakes; import io.xeres.app.database.model.identity.IdentityFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class ChatRoomRepositoryTest { @Autowired private ChatRoomRepository chatRoomRepository; @Test void CRUD_Success() { var identity = IdentityFakes.createOwn(); var chatRoom1 = ChatRoomFakes.createChatRoomEntity(identity); var chatRoom2 = ChatRoomFakes.createChatRoomEntity(identity); var chatRoom3 = ChatRoomFakes.createChatRoomEntity(identity); chatRoom1.setSubscribed(true); chatRoom2.setSubscribed(true); chatRoom3.setSubscribed(false); var savedChatRoom1 = chatRoomRepository.save(chatRoom1); chatRoomRepository.save(chatRoom2); chatRoomRepository.save(chatRoom3); var chatRooms = chatRoomRepository.findAllBySubscribedTrueAndJoinedFalse(); assertNotNull(chatRooms); assertEquals(2, chatRooms.size()); var first = chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom1.getRoomId(), identity).orElse(null); assertNotNull(first); assertEquals(savedChatRoom1.getId(), first.getId()); assertEquals(savedChatRoom1.getName(), first.getName()); first.setJoined(true); var updatedChatRoom = chatRoomRepository.save(first); assertNotNull(updatedChatRoom); assertEquals(first.getId(), updatedChatRoom.getId()); assertTrue(updatedChatRoom.isJoined()); chatRoomRepository.deleteById(first.getId()); var deleted = chatRoomRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/FileRepositoryTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.file.FileFakes; import io.xeres.common.file.FileType; import io.xeres.testutils.Sha1SumFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class FileRepositoryTest { @Autowired private FileRepository fileRepository; @Test void CRUD_Success() { var file1 = FileFakes.createFile("foo", null); var file2 = FileFakes.createFile("bar", null); var file3 = FileFakes.createFile("plop", null); var savedFile = fileRepository.save(file1); fileRepository.save(file2); fileRepository.save(file3); var files = fileRepository.findAll(); assertNotNull(files); assertEquals(3, files.size()); var first = fileRepository.findById(files.getFirst().getId()).orElse(null); assertNotNull(first); assertEquals(savedFile.getId(), first.getId()); assertEquals(savedFile.getName(), first.getName()); first.setType(FileType.VIDEO); var updatedFile = fileRepository.save(first); assertNotNull(updatedFile); assertEquals(first.getId(), updatedFile.getId()); assertEquals(FileType.VIDEO, updatedFile.getType()); fileRepository.deleteById(first.getId()); var deleted = fileRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); } @Test void FindByHash_Success() { var hash = Sha1SumFakes.createSha1Sum(); var file = FileFakes.createFile("foo", null); file.setHash(hash); fileRepository.save(file); var found = fileRepository.findByHash(hash).getFirst(); assertNotNull(found); } @Test void FindByEncryptedHash_Success() { var hash = Sha1SumFakes.createSha1Sum(); var file = FileFakes.createFile("foo", null); file.setEncryptedHash(hash); fileRepository.save(file); var found = fileRepository.findByEncryptedHash(hash).getFirst(); assertNotNull(found); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/GxsClientUpdateRepositoryTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.gxs.GxsClientUpdateFakes; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.testutils.IdFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import java.time.Instant; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class GxsClientUpdateRepositoryTest { @Autowired private ProfileRepository profileRepository; @Autowired private GxsClientUpdateRepository gxsClientUpdateRepository; @Test void CRUD_Success() { var profile = ProfileFakes.createFreshProfile("profile1", 1); profile = profileRepository.save(profile); var location = LocationFakes.createFreshLocation("location1", profile); profile.addLocation(location); profile = profileRepository.save(profile); var gxsClientUpdate1 = GxsClientUpdateFakes.createGxsClientUpdate(profile.getLocations().getFirst(), 200); var gxsClientUpdate2 = GxsClientUpdateFakes.createGxsClientUpdate(profile.getLocations().getFirst(), 201); var gxsClientUpdate3 = GxsClientUpdateFakes.createGxsClientUpdate(profile.getLocations().getFirst(), 202); var savedGxsClientUpdate1 = gxsClientUpdateRepository.save(gxsClientUpdate1); var savedGxsClientUpdate2 = gxsClientUpdateRepository.save(gxsClientUpdate2); gxsClientUpdateRepository.save(gxsClientUpdate3); var gxsClientUpdates = gxsClientUpdateRepository.findAll(); assertNotNull(gxsClientUpdates); assertEquals(3, gxsClientUpdates.size()); var first = gxsClientUpdateRepository.findById(gxsClientUpdates.getFirst().getId()).orElse(null); assertNotNull(first); assertEquals(savedGxsClientUpdate1.getId(), first.getId()); assertEquals(savedGxsClientUpdate1.getServiceType(), first.getServiceType()); var second = gxsClientUpdateRepository.findByLocationAndServiceType(gxsClientUpdate2.getLocation(), gxsClientUpdate2.getServiceType()).orElse(null); assertNotNull(second); assertEquals(savedGxsClientUpdate2.getId(), second.getId()); assertEquals(savedGxsClientUpdate2.getServiceType(), second.getServiceType()); first.setServiceType(300); var updatedGxsClientUpdate = gxsClientUpdateRepository.save(first); assertNotNull(updatedGxsClientUpdate); assertEquals(first.getId(), updatedGxsClientUpdate.getId()); assertEquals(300, updatedGxsClientUpdate.getServiceType()); gxsClientUpdateRepository.deleteById(first.getId()); var deleted = gxsClientUpdateRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); } @Test void CRUD_Messages_Success() { var profile = ProfileFakes.createFreshProfile("profile1", 1); profile = profileRepository.save(profile); var location = LocationFakes.createFreshLocation("location1", profile); profile.addLocation(location); profile = profileRepository.save(profile); var gxsId1 = IdFakes.createGxsId(); var time1 = "2007-12-03T10:15:30.00Z"; var update1 = Instant.parse(time1); var gxsId2 = IdFakes.createGxsId(); var time2 = "2014-11-05T09:28:35.00Z"; var update2 = Instant.parse(time2); var gxsId3 = IdFakes.createGxsId(); var time3 = "2021-01-01T14:45:00.00Z"; var update3 = Instant.parse(time3); var gxsClientUpdate = GxsClientUpdateFakes.createGxsClientUpdateWithMessages(profile.getLocations().getFirst(), gxsId1, update1, 200); gxsClientUpdate.putMessageUpdate(gxsId2, update2); gxsClientUpdate.putMessageUpdate(gxsId3, update3); var savedGxsClientUpdate = gxsClientUpdateRepository.save(gxsClientUpdate); var gxsClientUpdates = gxsClientUpdateRepository.findAll(); assertNotNull(gxsClientUpdates); assertEquals(1, gxsClientUpdates.size()); var first = gxsClientUpdateRepository.findById(gxsClientUpdates.getFirst().getId()).orElse(null); assertNotNull(first); assertEquals(savedGxsClientUpdate.getId(), first.getId()); assertEquals(savedGxsClientUpdate.getServiceType(), first.getServiceType()); assertEquals(update1, first.getMessageUpdate(gxsId1)); assertEquals(update2, first.getMessageUpdate(gxsId2)); assertEquals(update3, first.getMessageUpdate(gxsId3)); first.removeMessageUpdate(gxsId3); var updatedGxsClientUpdate = gxsClientUpdateRepository.save(first); assertNotNull(updatedGxsClientUpdate); assertNull(first.getMessageUpdate(gxsId3)); assertEquals(update1, first.getMessageUpdate(gxsId1)); assertEquals(update2, first.getMessageUpdate(gxsId2)); gxsClientUpdateRepository.deleteById(first.getId()); var deleted = gxsClientUpdateRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/GxsIdentityRepositoryTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.gxs.IdentityGroupItemFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class GxsIdentityRepositoryTest { @Autowired private GxsIdentityRepository gxsIdentityRepository; @Test void CRUD_Success() { var gxsIdGroupItem1 = IdentityGroupItemFakes.createIdentityGroupItem(); var gxsIdGroupItem2 = IdentityGroupItemFakes.createIdentityGroupItem(); var gxsIdGroupItem3 = IdentityGroupItemFakes.createIdentityGroupItem(); var savedGxsIdGroupItem1 = gxsIdentityRepository.save(gxsIdGroupItem1); var savedGxsIdGroupItem2 = gxsIdentityRepository.save(gxsIdGroupItem2); gxsIdentityRepository.save(gxsIdGroupItem3); var gxsIdGroupItems = gxsIdentityRepository.findAll(); assertNotNull(gxsIdGroupItems); assertEquals(3, gxsIdGroupItems.size()); var first = gxsIdentityRepository.findById(gxsIdGroupItems.getFirst().getId()).orElse(null); assertNotNull(first); assertEquals(savedGxsIdGroupItem1.getId(), first.getId()); assertEquals(savedGxsIdGroupItem1.getName(), first.getName()); var second = gxsIdentityRepository.findByGxsId(gxsIdGroupItem2.getGxsId()).orElse(null); assertNotNull(second); assertEquals(savedGxsIdGroupItem2.getId(), second.getId()); assertEquals(savedGxsIdGroupItem2.getName(), second.getName()); first.setIdentityScore(10); var updatedGxsIdGroupItem = gxsIdentityRepository.save(first); assertNotNull(updatedGxsIdGroupItem); assertEquals(first.getId(), updatedGxsIdGroupItem.getId()); assertEquals(10, updatedGxsIdGroupItem.getIdentityScore()); gxsIdentityRepository.deleteById(first.getId()); var deleted = gxsIdentityRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/GxsServiceSettingRepositoryTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.gxs.GxsServiceSettingFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import java.time.Instant; import java.time.temporal.ChronoUnit; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class GxsServiceSettingRepositoryTest { @Autowired private GxsServiceSettingRepository gxsServiceSettingRepository; @Test void CRUD_Success() { var instantEpoch = Instant.EPOCH; var instantNow = Instant.now(); var instantYesterday = instantNow.minus(1, ChronoUnit.DAYS); var gxsServiceSetting1 = GxsServiceSettingFakes.createGxsServiceSetting(1, instantEpoch); var gxsServiceSetting2 = GxsServiceSettingFakes.createGxsServiceSetting(2, instantYesterday); var gxsServiceSetting3 = GxsServiceSettingFakes.createGxsServiceSetting(3, instantNow); var savedGxsServiceSetting1 = gxsServiceSettingRepository.save(gxsServiceSetting1); var savedGxsServiceSetting2 = gxsServiceSettingRepository.save(gxsServiceSetting2); gxsServiceSettingRepository.save(gxsServiceSetting3); var gxsServiceSettings = gxsServiceSettingRepository.findAll(); assertNotNull(gxsServiceSettings); assertEquals(3, gxsServiceSettings.size()); var first = gxsServiceSettingRepository.findById(gxsServiceSettings.getFirst().getId()).orElse(null); assertNotNull(first); assertEquals(savedGxsServiceSetting1.getId(), first.getId()); assertEquals(savedGxsServiceSetting1.getLastUpdated(), first.getLastUpdated()); var second = gxsServiceSettingRepository.findById(savedGxsServiceSetting2.getId()).orElse(null); assertNotNull(second); assertEquals(savedGxsServiceSetting2.getId(), second.getId()); assertEquals(savedGxsServiceSetting2.getLastUpdated(), second.getLastUpdated()); first.setLastUpdated(instantNow.plus(1, ChronoUnit.DAYS)); var updatedGxsServiceSetting = gxsServiceSettingRepository.save(first); assertNotNull(updatedGxsServiceSetting); assertEquals(first.getId(), updatedGxsServiceSetting.getId()); assertEquals(instantNow.plus(1, ChronoUnit.DAYS), updatedGxsServiceSetting.getLastUpdated()); gxsServiceSettingRepository.deleteById(first.getId()); var deleted = gxsServiceSettingRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/LocationRepositoryTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.ProfileFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class LocationRepositoryTest { @Autowired private ProfileRepository profileRepository; @Autowired private LocationRepository locationRepository; @Test void CRUD_Success() { var profile = ProfileFakes.createFreshProfile("test", 1); profile = profileRepository.save(profile); var location1 = LocationFakes.createFreshLocation("test1", profile); var location2 = LocationFakes.createFreshLocation("test2", profile); var location3 = LocationFakes.createFreshLocation("test3", profile); profile.addLocation(location1); profile.addLocation(location2); profile.addLocation(location3); profileRepository.save(profile); var locations = locationRepository.findAll(); assertNotNull(locations); assertEquals(3, locations.size()); var first = locationRepository.findById(locations.getFirst().getId()).orElse(null); assertNotNull(first); assertEquals(locations.getFirst().getId(), first.getId()); assertEquals(locations.getFirst().getName(), first.getName()); first.setConnected(true); var updatedLocation = locationRepository.save(first); assertNotNull(updatedLocation); assertEquals(first.getId(), updatedLocation.getId()); assertTrue(updatedLocation.isConnected()); locationRepository.deleteById(first.getId()); var deleted = locationRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); profileRepository.deleteById(profile.getId()); deleted = locationRepository.findById(location2.getId()); assertTrue(deleted.isEmpty()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/ProfileRepositoryTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.profile.ProfileFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class ProfileRepositoryTest { @Autowired private ProfileRepository profileRepository; @Test void CRUD_Success() { var profile1 = ProfileFakes.createFreshProfile("test1", 1); var profile2 = ProfileFakes.createFreshProfile("test2", 2); var profile3 = ProfileFakes.createFreshProfile("test3", 3); var savedProfile = profileRepository.save(profile1); profileRepository.save(profile2); profileRepository.save(profile3); var profiles = profileRepository.findAll(); assertNotNull(profiles); assertEquals(3, profiles.size()); var first = profileRepository.findById(profiles.getFirst().getId()).orElse(null); assertNotNull(first); assertEquals(savedProfile.getId(), first.getId()); assertEquals(savedProfile.getName(), first.getName()); first.setAccepted(false); var updatedProfile = profileRepository.save(first); assertNotNull(updatedProfile); assertEquals(first.getId(), updatedProfile.getId()); assertFalse(updatedProfile.isAccepted()); profileRepository.deleteById(first.getId()); var deleted = profileRepository.findById(first.getId()); assertTrue(deleted.isEmpty()); } } ================================================ FILE: app/src/test/java/io/xeres/app/database/repository/SettingsRepositoryTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.database.repository; import io.xeres.app.database.model.settings.SettingsFakes; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class SettingsRepositoryTest { @Autowired private SettingsRepository settingsRepository; @Test void CRUD_Success() { var prefs = SettingsFakes.createSettings(); var unwantedPrefs = SettingsFakes.createSettings(); var savedPrefs = settingsRepository.save(prefs); settingsRepository.save(unwantedPrefs); var prefsList = settingsRepository.findAll(); assertNotNull(prefsList); assertEquals(1, prefsList.size()); var first = settingsRepository.findById((byte) 1).orElse(null); assertNotNull(first); assertArrayEquals(savedPrefs.getPgpPrivateKeyData(), first.getPgpPrivateKeyData()); first.setPgpPrivateKeyData(new byte[]{1}); var updatedPrefs = settingsRepository.save(first); assertNotNull(updatedPrefs); assertArrayEquals(first.getPgpPrivateKeyData(), updatedPrefs.getPgpPrivateKeyData()); settingsRepository.deleteById((byte) 1); var deleted = settingsRepository.findById((byte) 1); assertTrue(deleted.isEmpty()); // And then save again to make sure the ID stays at 1 settingsRepository.save(prefs); prefsList = settingsRepository.findAll(); assertNotNull(prefsList); assertEquals(1, prefsList.size()); } } ================================================ FILE: app/src/test/java/io/xeres/app/environment/CloudTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.environment; import io.xeres.app.application.environment.Cloud; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class CloudTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(Cloud.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/environment/CommandArgumentTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.environment; import io.xeres.app.application.environment.CommandArgument; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class CommandArgumentTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(CommandArgument.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/environment/HostVariableTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.environment; import io.xeres.app.application.environment.HostVariable; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class HostVariableTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(HostVariable.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/job/IdleDetectionJobTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.job; import io.xeres.app.service.PeerService; import io.xeres.app.xrs.service.status.IdleChecker; import io.xeres.app.xrs.service.status.StatusRsService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static io.xeres.common.location.Availability.AVAILABLE; import static io.xeres.common.location.Availability.AWAY; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class IdleDetectionJobTest { @Mock private PeerService peerService; @Mock private StatusRsService statusRsService; @Mock private IdleChecker idleChecker; @InjectMocks private IdleDetectionJob idleDetectionJob; @Test void IsOnline_Automatic_Success() { when(peerService.isRunning()).thenReturn(true); when(idleChecker.getIdleTime()).thenReturn(0); idleDetectionJob.checkIdle(); verify(statusRsService).changeAvailabilityAutomatically(argThat(status -> { assertEquals(AVAILABLE, status); return true; })); } @Test void IsAway_Automatic_Success() { when(peerService.isRunning()).thenReturn(true); when(idleChecker.getIdleTime()).thenReturn(60 * 5 + 1); idleDetectionJob.checkIdle(); verify(statusRsService).changeAvailabilityAutomatically(argThat(status -> { assertEquals(AWAY, status); return true; })); } } ================================================ FILE: app/src/test/java/io/xeres/app/job/PeerConnectionJobTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.job; import io.xeres.app.database.model.connection.ConnectionFakes; import io.xeres.app.net.peer.bootstrap.PeerI2pClient; import io.xeres.app.net.peer.bootstrap.PeerTcpClient; import io.xeres.app.net.peer.bootstrap.PeerTorClient; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.service.LocationService; import io.xeres.app.service.PeerService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PeerConnectionJobTest { @Mock private PeerService peerService; @Mock private LocationService locationService; @Mock private PeerTcpClient peerTcpClient; @Mock private PeerTorClient peerTorClient; @Mock private PeerI2pClient peerI2pClient; @InjectMocks private PeerConnectionJob peerConnectionJob; @Test void IsNotRunning_Success() { when(peerService.isRunning()).thenReturn(false); peerConnectionJob.checkConnections(); verify(peerService).isRunning(); verify(locationService, never()).getConnectionsToConnectTo(anyInt()); } @Test void ConnectToPeers_TCP_Success() { when(peerService.isRunning()).thenReturn(true); when(locationService.getConnectionsToConnectTo(anyInt())).thenReturn(List.of(ConnectionFakes.createConnection(PeerAddress.Type.IPV4, "1.1.1.1:1234", true))); peerConnectionJob.checkConnections(); verify(peerService).isRunning(); verify(locationService).getConnectionsToConnectTo(anyInt()); verify(peerTcpClient).connect(any(PeerAddress.class)); } @Test void ConnectToPeers_Tor_Success() { when(peerService.isRunning()).thenReturn(true); when(locationService.getConnectionsToConnectTo(anyInt())).thenReturn(List.of(ConnectionFakes.createConnection(PeerAddress.Type.TOR, "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", true))); peerConnectionJob.checkConnections(); verify(peerService).isRunning(); verify(locationService).getConnectionsToConnectTo(anyInt()); verify(peerTorClient).connect(any(PeerAddress.class)); } @Test void ConnectToPeers_I2p_Success() { when(peerService.isRunning()).thenReturn(true); when(locationService.getConnectionsToConnectTo(anyInt())).thenReturn(List.of(ConnectionFakes.createConnection(PeerAddress.Type.TOR, "udhdrtrcetjm5sxzskjyr5ztpeszydbh4dpl3pl4utgqqw2v4jna.b32.i2p:80", true))); peerConnectionJob.checkConnections(); verify(peerService).isRunning(); verify(locationService).getConnectionsToConnectTo(anyInt()); verify(peerI2pClient).connect(any(PeerAddress.class)); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/bdisc/BroadcastDiscoveryServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.bdisc; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.service.LocationService; import io.xeres.common.protocol.ip.IP; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.Duration; import java.util.Optional; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class BroadcastDiscoveryServiceTest { @Mock private LocationService locationService; @Mock private DatabaseSessionManager databaseSessionManager; @InjectMocks private BroadcastDiscoveryService broadcastDiscoveryService; @Test void StartStop_Success() { var ownLocation = LocationFakes.createOwnLocation(); when(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation)); broadcastDiscoveryService.start(IP.getLocalIpAddress(), 36406); // nothing should reply in there, hopefully. We can't use localhost because linux has no broadcast in it await().atMost(Duration.ofSeconds(10)).until(() -> broadcastDiscoveryService.isRunning()); broadcastDiscoveryService.stop(); broadcastDiscoveryService.waitForTermination(); assertFalse(broadcastDiscoveryService.isRunning()); verify(locationService).findOwnLocation(); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/bdisc/UdpDiscoveryProtocolTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.bdisc; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.ProfileFingerprint; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import static org.junit.jupiter.api.Assertions.*; class UdpDiscoveryProtocolTest { private static final int APP_ID = 904571; private static final int PEER_ID = 1730783293; private static final int PACKET_INDEX = 32921; private static final UdpDiscoveryPeer.Status STATUS_PRESENT = UdpDiscoveryPeer.Status.PRESENT; private static final ProfileFingerprint FINGERPRINT = new ProfileFingerprint(Id.toBytes("54B7C121B73E434539DC3E0BA87461B115390F34")); private static final LocationIdentifier LOCATION_ID = new LocationIdentifier(Id.toBytes("ec65a805a3faa6d4b88e7a2ee5a45f33")); private static final String LOCAL_IP = "127.0.0.1"; private static final int LOCAL_PORT = 8600; private static final String PROFILE_NAME = "retroshare.ch"; private static final String DATA = "524e36550000000000000dcd7b6729a83d00008099000037000054b7c121b73e434539dc3e0ba87461b115390f34ec65a805a3faa6d4b88e7a2ee5a45f3321980000000d726574726f73686172652e6368"; private static final String DATA_NEW = "534f37560100000000000dcd7b6729a83d0000000000008099003754b7c121b73e434539dc3e0ba87461b115390f34ec65a805a3faa6d4b88e7a2ee5a45f3321980000000d726574726f73686172652e6368"; @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(UdpDiscoveryProtocol.class); } @Test void ParsePacket_Success() { var peer = UdpDiscoveryProtocol.parsePacket(ByteBuffer.wrap(Id.toBytes(DATA)), new InetSocketAddress(LOCAL_IP, 6666)); assertNotNull(peer); assertEquals(APP_ID, peer.getAppId()); assertEquals(PEER_ID, peer.getPeerId()); assertEquals(PACKET_INDEX, peer.getPacketIndex()); assertEquals(STATUS_PRESENT, peer.getStatus()); assertEquals(FINGERPRINT, peer.getFingerprint()); assertEquals(LOCATION_ID, peer.getLocationIdentifier()); assertEquals(LOCAL_IP, peer.getIpAddress()); assertEquals(LOCAL_PORT, peer.getLocalPort()); assertEquals(PROFILE_NAME, peer.getProfileName()); } @Test void CreatePacket_Success() { var data = UdpDiscoveryProtocol.createPacket( 512, STATUS_PRESENT, APP_ID, PEER_ID, PACKET_INDEX, FINGERPRINT, LOCATION_ID, LOCAL_PORT, PROFILE_NAME); var a = new byte[data.position()]; data.flip(); data.get(a); assertArrayEquals(Id.toBytes(DATA_NEW), a); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/dht/NodeIdTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.dht; import io.xeres.common.id.LocationIdentifier; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertArrayEquals; class NodeIdTest { @Test void Create_Success() { var locationIdentifier = new LocationIdentifier(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}); var result = NodeId.create(locationIdentifier); assertArrayEquals(new byte[]{2, -90, -53, -104, 57, 58, 24, -74, 10, 11, 61, 1, -120, 28, -26, 84, 78, 6, 91, 59}, result); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/AbstractPipelineTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.buffer.ByteBuf; public abstract class AbstractPipelineTest { static byte[] getByteBufAsArray(ByteBuf buf) { buf.markReaderIndex(); buf.readerIndex(0); var out = new byte[buf.writerIndex()]; buf.readBytes(out); buf.resetReaderIndex(); return out; } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/ChannelFake.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.buffer.ByteBufAllocator; import io.netty.channel.*; import io.netty.util.Attribute; import io.netty.util.AttributeKey; import io.netty.util.AttributeMap; import io.netty.util.DefaultAttributeMap; import java.net.SocketAddress; public class ChannelFake implements Channel { private final AttributeMap attributes = new DefaultAttributeMap(); @Override public ChannelId id() { return null; } @Override public EventLoop eventLoop() { return null; } @Override public Channel parent() { return null; } @Override public ChannelConfig config() { return null; } @Override public boolean isOpen() { return false; } @Override public boolean isRegistered() { return false; } @Override public boolean isActive() { return false; } @Override public ChannelMetadata metadata() { return null; } @Override public SocketAddress localAddress() { return null; } @Override public SocketAddress remoteAddress() { return null; } @Override public ChannelFuture closeFuture() { return null; } @Override public boolean isWritable() { return false; } @Override public long bytesBeforeUnwritable() { return 0; } @Override public long bytesBeforeWritable() { return 0; } @Override public Unsafe unsafe() { return null; } @Override public ChannelPipeline pipeline() { return null; } @Override public ByteBufAllocator alloc() { return null; } @Override public ChannelFuture bind(SocketAddress socketAddress) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1) { return null; } @Override public ChannelFuture disconnect() { return null; } @Override public ChannelFuture close() { return null; } @Override public ChannelFuture deregister() { return null; } @Override public ChannelFuture bind(SocketAddress socketAddress, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture disconnect(ChannelPromise channelPromise) { return null; } @Override public ChannelFuture close(ChannelPromise channelPromise) { return null; } @Override public ChannelFuture deregister(ChannelPromise channelPromise) { return null; } @Override public Channel read() { return null; } @Override public ChannelFuture write(Object o) { return null; } @Override public ChannelFuture write(Object o, ChannelPromise channelPromise) { return null; } @Override public Channel flush() { return null; } @Override public ChannelFuture writeAndFlush(Object o, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture writeAndFlush(Object o) { return null; } @Override public ChannelPromise newPromise() { return null; } @Override public ChannelProgressivePromise newProgressivePromise() { return null; } @Override public ChannelFuture newSucceededFuture() { return null; } @Override public ChannelFuture newFailedFuture(Throwable throwable) { return null; } @Override public ChannelPromise voidPromise() { return null; } @Override public Attribute attr(AttributeKey attributeKey) { return attributes.attr(attributeKey); } @Override public boolean hasAttr(AttributeKey attributeKey) { return false; } @Override public int compareTo(Channel o) { return 0; } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/ChannelHandlerContextFake.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.buffer.ByteBufAllocator; import io.netty.channel.*; import io.netty.util.Attribute; import io.netty.util.AttributeKey; import io.netty.util.concurrent.EventExecutor; import java.net.SocketAddress; public class ChannelHandlerContextFake implements ChannelHandlerContext { private final Channel channel = new ChannelFake(); @Override public Channel channel() { return channel; } @Override public EventExecutor executor() { return null; } @Override public String name() { return ""; } @Override public ChannelHandler handler() { return null; } @Override public boolean isRemoved() { return false; } @Override public ChannelHandlerContext fireChannelRegistered() { return null; } @Override public ChannelHandlerContext fireChannelUnregistered() { return null; } @Override public ChannelHandlerContext fireChannelActive() { return null; } @Override public ChannelHandlerContext fireChannelInactive() { return null; } @Override public ChannelHandlerContext fireExceptionCaught(Throwable throwable) { return null; } @Override public ChannelHandlerContext fireUserEventTriggered(Object o) { return null; } @Override public ChannelHandlerContext fireChannelRead(Object o) { return null; } @Override public ChannelHandlerContext fireChannelReadComplete() { return null; } @Override public ChannelHandlerContext fireChannelWritabilityChanged() { return null; } @Override public ChannelFuture bind(SocketAddress socketAddress) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1) { return null; } @Override public ChannelFuture disconnect() { return null; } @Override public ChannelFuture close() { return null; } @Override public ChannelFuture deregister() { return null; } @Override public ChannelFuture bind(SocketAddress socketAddress, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture disconnect(ChannelPromise channelPromise) { return null; } @Override public ChannelFuture close(ChannelPromise channelPromise) { return null; } @Override public ChannelFuture deregister(ChannelPromise channelPromise) { return null; } @Override public ChannelHandlerContext read() { return null; } @Override public ChannelFuture write(Object o) { return null; } @Override public ChannelFuture write(Object o, ChannelPromise channelPromise) { return null; } @Override public ChannelHandlerContext flush() { return null; } @Override public ChannelFuture writeAndFlush(Object o, ChannelPromise channelPromise) { return null; } @Override public ChannelFuture writeAndFlush(Object o) { return null; } @Override public ChannelPromise newPromise() { return null; } @Override public ChannelProgressivePromise newProgressivePromise() { return null; } @Override public ChannelFuture newSucceededFuture() { return null; } @Override public ChannelFuture newFailedFuture(Throwable throwable) { return null; } @Override public ChannelPromise voidPromise() { return null; } @Override public ChannelPipeline pipeline() { return null; } @Override public ByteBufAllocator alloc() { return null; } @Override public Attribute attr(AttributeKey attributeKey) { return null; } @Override public boolean hasAttr(AttributeKey attributeKey) { return false; } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/PacketDecoderPipelineTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.DecoderException; import io.netty.handler.codec.TooLongFrameException; import io.netty.util.ReferenceCountUtil; import io.xeres.app.net.peer.packet.MultiPacketBuilder; import io.xeres.app.net.peer.packet.SimplePacketBuilder; import io.xeres.app.net.peer.pipeline.ItemDecoder; import io.xeres.app.net.peer.pipeline.PacketDecoder; import io.xeres.app.xrs.item.RawItem; import org.bouncycastle.crypto.Digest; import org.bouncycastle.crypto.digests.SHA256Digest; import org.junit.jupiter.api.Test; import java.net.ProtocolException; import java.util.concurrent.ThreadLocalRandom; import static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_END; import static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_START; import static io.xeres.app.net.peer.packet.Packet.OPTIMAL_PACKET_SIZE; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.*; class PacketDecoderPipelineTest extends AbstractPipelineTest { @Test void NewPacket_Success() { var channel = new EmbeddedChannel(new PacketDecoder()); var inPacket = MultiPacketBuilder.builder() .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); ByteBuf inBuf = channel.readInbound(); assertArrayEquals(inPacket, getByteBufAsArray(inBuf)); ReferenceCountUtil.release(inBuf); } @Test void NewPacket_ZeroSize() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = MultiPacketBuilder.builder() .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); RawItem rawItem = channel.readInbound(); assertNotNull(rawItem); ReferenceCountUtil.release(rawItem); } @Test void OldPacket_Success() { var channel = new EmbeddedChannel(new PacketDecoder()); var inPacket = SimplePacketBuilder.builder() .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); ByteBuf inBuf = channel.readInbound(); assertArrayEquals(inPacket, getByteBufAsArray(inBuf)); ReferenceCountUtil.release(inBuf); } @Test void OldPacket_TooSmall() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = SimplePacketBuilder.builder() .setHeaderSize(6) .build(); var message = Unpooled.wrappedBuffer(inPacket); assertThatThrownBy(() -> channel.writeInbound(message)) .isInstanceOf(DecoderException.class) .hasCauseInstanceOf(ProtocolException.class) .hasMessageContaining("Packet size too small"); } /** * The old packets can be oversized, the new ones can't. */ @Test void OldPacket_Oversized() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = SimplePacketBuilder.builder() .setHeaderSize(Integer.MAX_VALUE - 8) .build(); var message = Unpooled.wrappedBuffer(inPacket); assertThatThrownBy(() -> channel.writeInbound(message)) .isInstanceOf(TooLongFrameException.class) .hasMessageStartingWith("Frame is too long"); } @Test void OldPacket_Empty_Success() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = SimplePacketBuilder.builder() .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); RawItem rawItem = channel.readInbound(); assertNotNull(rawItem); ReferenceCountUtil.release(rawItem); } @Test void NewPacket_Empty_DoubleStartPacket() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = MultiPacketBuilder.builder() .setFlags(SLICE_FLAG_START) .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); var message = Unpooled.wrappedBuffer(inPacket); assertThatThrownBy(() -> channel.writeInbound(message)).isInstanceOf(DecoderException.class) .hasCauseInstanceOf(ProtocolException.class) .hasMessageFindingMatch("Start packet [0-9]* already received"); } @Test void NewPacket_Empty_MiddlePacketWithoutStartPacket() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = MultiPacketBuilder.builder() .setFlags(0) .build(); var message = Unpooled.wrappedBuffer(inPacket); assertThatThrownBy(() -> channel.writeInbound(message)).isInstanceOf(DecoderException.class) .hasCauseInstanceOf(ProtocolException.class) .hasMessageFindingMatch("Middle packet [0-9]* received without corresponding start packet"); } @Test void NewPacket_Empty_EndPacketWithoutStartPacket() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = MultiPacketBuilder.builder() .setFlags(SLICE_FLAG_END) .build(); var message = Unpooled.wrappedBuffer(inPacket); assertThatThrownBy(() -> channel.writeInbound(message)).isInstanceOf(DecoderException.class) .hasCauseInstanceOf(ProtocolException.class) .hasMessageFindingMatch("End packet [0-9]* received without corresponding start packet"); } @Test void NewPacket_Empty_Success() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket = MultiPacketBuilder.builder() .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); RawItem rawItem = channel.readInbound(); assertNotNull(rawItem); assertEquals(0, rawItem.getBuffer().writerIndex()); assertFalse(channel.finish()); ReferenceCountUtil.release(rawItem); } @Test void NewPacket_Slicing_SizesWithHeaders_Success() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var inPacket1 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(SLICE_FLAG_START) .setData(new byte[OPTIMAL_PACKET_SIZE]) .build(); var inPacket2 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(0) .setData(new byte[200]) .build(); var inPacket3 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(SLICE_FLAG_END) .setData(new byte[100]) .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket1)); channel.writeInbound(Unpooled.wrappedBuffer(inPacket2)); channel.writeInbound(Unpooled.wrappedBuffer(inPacket3)); RawItem rawItem = channel.readInbound(); assertNotNull(rawItem); assertEquals(OPTIMAL_PACKET_SIZE + 200 + 100, rawItem.getBuffer().writerIndex()); assertFalse(channel.finish()); ReferenceCountUtil.release(rawItem); } /** * Creates 3 sliced buffers and tests if they're reassembled properly. */ @Test void NewPacket_Slicing_DataIntegrity_Success() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var data1 = new byte[OPTIMAL_PACKET_SIZE]; var data2 = new byte[200]; var data3 = new byte[100]; ThreadLocalRandom.current().nextBytes(data1); ThreadLocalRandom.current().nextBytes(data2); ThreadLocalRandom.current().nextBytes(data3); var hashIn = computeHash(data1, data2, data3); var inPacket1 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(SLICE_FLAG_START) .setData(data1) .build(); var inPacket2 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(0) .setData(data2) .build(); var inPacket3 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(SLICE_FLAG_END) .setData(data3) .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket1)); channel.writeInbound(Unpooled.wrappedBuffer(inPacket2)); channel.writeInbound(Unpooled.wrappedBuffer(inPacket3)); RawItem rawItem = channel.readInbound(); assertNotNull(rawItem); assertEquals(data1.length + data2.length + data3.length, rawItem.getBuffer().writerIndex()); assertFalse(channel.finish()); assertArrayEquals(hashIn, computeHash(getByteBufAsArray(rawItem.getBuffer()))); ReferenceCountUtil.release(rawItem); } @Test void NewPacket_Slicing_DataIntegrity_Intermixed_Success() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var dataA1 = new byte[100]; var dataA2 = new byte[150]; var dataA3 = new byte[200]; var dataB1 = new byte[300]; var dataB2 = new byte[200]; var dataB3 = new byte[100]; ThreadLocalRandom.current().nextBytes(dataA1); ThreadLocalRandom.current().nextBytes(dataA2); ThreadLocalRandom.current().nextBytes(dataA3); ThreadLocalRandom.current().nextBytes(dataB1); ThreadLocalRandom.current().nextBytes(dataB2); ThreadLocalRandom.current().nextBytes(dataB3); var hashInA = computeHash(dataA1, dataA2, dataA3); var hashInB = computeHash(dataB1, dataB2, dataB3); var inPacketA1 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(SLICE_FLAG_START) .setData(dataA1) .build(); var inPacketA2 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(0) .setData(dataA2) .build(); var inPacketA3 = MultiPacketBuilder.builder() .setPacketId(1) .setFlags(SLICE_FLAG_END) .setData(dataA3) .build(); var inPacketB1 = MultiPacketBuilder.builder() .setPacketId(2) .setFlags(SLICE_FLAG_START) .setData(dataB1) .build(); var inPacketB2 = MultiPacketBuilder.builder() .setPacketId(2) .setFlags(0) .setData(dataB2) .build(); var inPacketB3 = MultiPacketBuilder.builder() .setPacketId(2) .setFlags(SLICE_FLAG_END) .setData(dataB3) .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacketA1)); channel.writeInbound(Unpooled.wrappedBuffer(inPacketB1)); channel.writeInbound(Unpooled.wrappedBuffer(inPacketA2)); channel.writeInbound(Unpooled.wrappedBuffer(inPacketB2)); channel.writeInbound(Unpooled.wrappedBuffer(inPacketA3)); channel.writeInbound(Unpooled.wrappedBuffer(inPacketB3)); RawItem rawItemA = channel.readInbound(); RawItem rawItemB = channel.readInbound(); assertNotNull(rawItemA); assertNotNull(rawItemB); assertEquals(dataA1.length + dataA2.length + dataA3.length, rawItemA.getBuffer().writerIndex()); assertEquals(dataB1.length + dataB2.length + dataB3.length, rawItemB.getBuffer().writerIndex()); assertFalse(channel.finish()); assertArrayEquals(hashInA, computeHash(getByteBufAsArray(rawItemA.getBuffer()))); assertArrayEquals(hashInB, computeHash(getByteBufAsArray(rawItemB.getBuffer()))); ReferenceCountUtil.release(rawItemA); ReferenceCountUtil.release(rawItemB); } private byte[] computeHash(byte[]... buffers) { var hash = new byte[32]; Digest digest = new SHA256Digest(); for (var buf : buffers) { digest.update(buf, 0, buf.length); } digest.doFinal(hash, 0); return hash; } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/PacketEncoderPipelineTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.buffer.ByteBuf; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.util.ReferenceCountUtil; import io.xeres.app.net.peer.packet.Packet; import io.xeres.app.net.peer.packet.SimplePacketBuilder; import io.xeres.app.net.peer.pipeline.SimplePacketEncoder; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class PacketEncoderPipelineTest extends AbstractPipelineTest { @Test void RsOldPacketEncoder_Success() { var channel = new EmbeddedChannel(new SimplePacketEncoder()); Packet outPacket = SimplePacketBuilder.builder().buildPacket(); channel.writeAndFlush(outPacket); ByteBuf outBuf = channel.readOutbound(); assertEquals(outPacket.getSize(), outBuf.writerIndex()); ReferenceCountUtil.release(outBuf); } public void RsNewPacketEncoder_OK() { // var channel = new EmbeddedChannel(new MultiPacketEncoder()); // // Packet inPacket = PacketBuilder.builder().buildPacket(); // // channel.attr(PeerHandler.MULTI_PACKET).set(true); // channel.writeAndFlush(inPacket); // ByteBuf outBuf = channel.readOutbound(); // assertEquals(inPacket.getSize(), outBuf.readableBytes()); // ReferenceCountUtil.release(outBuf); } public void RsNewPacketEncoder_OldPacket_OK() { // var channel = new EmbeddedChannel(new MultiPacketEncoder()); // // Packet inPacket = PacketBuilder.builder() // .setData(new byte[]{1, 2, 3, 4}) // .buildPacket(); // // channel.writeAndFlush(inPacket); // var outPacket = new byte[4]; // ByteBuf outBuf = channel.readOutbound(); // outBuf.readBytes(outPacket); // //assertArrayEquals(inPacket.getData(), outPacket); // // ReferenceCountUtil.release(outBuf); } public void RsPacketEncoder_Small_OK() { // var channel = new EmbeddedChannel(new MultiPacketEncoder()); // // Packet inPacket = PacketBuilder.builder() // .setData(new byte[]{1, 2, 3, 4}) // .buildPacket(); // // channel.attr(PeerHandler.MULTI_PACKET).set(true); // channel.writeAndFlush(inPacket); // channel.runPendingTasks(); // var outPacket = new byte[4]; // skipHeader(channel); // ByteBuf outBuf = channel.readOutbound(); // outBuf.readBytes(outPacket); // //assertArrayEquals(inPacket.getData(), outPacket); // // ReferenceCountUtil.release(outBuf); } public void RsPacketEncoder_Optimal_OK() { // EmbeddedChannel channel = new EmbeddedChannel(new MultiPacketEncoder()); // // Packet inPacket = PacketBuilder.builder() // .setRandomData(Packet.OPTIMAL_PACKET_SIZE) // .buildPacket(); // // channel.attr(PeerHandler.MULTI_PACKET).set(true); // channel.writeAndFlush(inPacket); // channel.runPendingTasks(); // var outPacket = new byte[Packet.OPTIMAL_PACKET_SIZE]; // skipHeader(channel); // ByteBuf outBuf = channel.readOutbound(); // outBuf.readBytes(outPacket); // //assertArrayEquals(inPacket.getData(), outPacket); // // ReferenceCountUtil.release(outBuf); } public void RsPacketEncoder_Big_OK() { // var channel = new EmbeddedChannel(new MultiPacketEncoder()); // // byte[] inPacket = PacketBuilder.builder() // .setRandomData(Packet.OPTIMAL_PACKET_SIZE * 3 + 200) // .build(); // // channel.attr(PeerHandler.MULTI_PACKET).set(true); // channel.writeAndFlush(Unpooled.wrappedBuffer(inPacket)); // ByteBuf outBuf = channel.readOutbound(); // byte[] outPacket = outBuf.array(); // assertArrayEquals(inPacket, outPacket); // // ReferenceCountUtil.release(outBuf); } public void RsPacketEncoder_Multiple_OK() { // var channel = new EmbeddedChannel(new MultiPacketEncoder()); // // byte[] inPacket1 = PacketBuilder.builder() // .setRandomData(Packet.OPTIMAL_PACKET_SIZE * 3 + 200) // .build(); // // byte[] inPacket2 = PacketBuilder.builder() // .setRandomData(6) // .build(); // // byte[] inPacket3 = PacketBuilder.builder() // .setRandomData(Packet.OPTIMAL_PACKET_SIZE * 2) // .build(); // // channel.attr(PeerHandler.MULTI_PACKET).set(true); // channel.writeAndFlush(Unpooled.wrappedBuffer(inPacket1)); // channel.writeAndFlush(Unpooled.wrappedBuffer(inPacket2)); // channel.writeAndFlush(Unpooled.wrappedBuffer(inPacket3)); // ByteBuf outBuf1 = channel.readOutbound(); // byte[] outPacket1 = outBuf1.array(); // ByteBuf outBuf2 = channel.readOutbound(); // byte[] outPacket2 = outBuf1.array(); // ByteBuf outBuf3 = channel.readOutbound(); // byte[] outPacket3 = outBuf1.array(); // // assertArrayEquals(inPacket1, outPacket1); // assertArrayEquals(inPacket2, outPacket2); // assertArrayEquals(inPacket3, outPacket3); // // ReferenceCountUtil.release(outBuf1); // ReferenceCountUtil.release(outBuf2); // ReferenceCountUtil.release(outBuf3); } public void RsPacketEncoder_Multiple_Priority_OK() { // var channel = new EmbeddedChannel(new MultiPacketEncoder()); // // byte[] inPacket1 = PacketBuilder.builder() // .setRandomData(Packet.OPTIMAL_PACKET_SIZE * 3 + 200) // .build(); // // byte[] inPacket2 = PacketBuilder.builder() // .setRandomData(6) // .setPriority(9) // .build(); // // byte[] inPacket3 = PacketBuilder.builder() // .setRandomData(Packet.OPTIMAL_PACKET_SIZE * 2) // .build(); // // channel.attr(PeerHandler.MULTI_PACKET).set(true); // channel.writeAndFlush(Unpooled.wrappedBuffer(inPacket1)); // channel.writeAndFlush(Unpooled.wrappedBuffer(inPacket2)); // channel.writeAndFlush(Unpooled.wrappedBuffer(inPacket3)); // // ByteBuf outBuf1 = channel.readOutbound(); // byte[] outPacket1 = outBuf1.array(); // ByteBuf outBuf2 = channel.readOutbound(); // byte[] outPacket2 = outBuf1.array(); // ByteBuf outBuf3 = channel.readOutbound(); // byte[] outPacket3 = outBuf1.array(); // // assertArrayEquals(inPacket1, outPacket1); // assertArrayEquals(inPacket2, outPacket2); // assertArrayEquals(inPacket3, outPacket3); // // ReferenceCountUtil.release(outBuf1); // ReferenceCountUtil.release(outBuf2); // ReferenceCountUtil.release(outBuf3); } private void skipHeader(EmbeddedChannel channel) { ByteBuf byteBuf = channel.readOutbound(); byteBuf.skipBytes(8); ReferenceCountUtil.release(byteBuf); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/PeerAttributeTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class PeerAttributeTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(PeerAttribute.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/PeerConnectionFakes.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.xeres.app.database.model.location.LocationFakes; public final class PeerConnectionFakes { private PeerConnectionFakes() { throw new UnsupportedOperationException("Utility class"); } public static PeerConnection createPeerConnection() { return new PeerConnection(LocationFakes.createLocation(), null); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/PeerConnectionManagerTest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.service.notification.availability.AvailabilityNotificationService; import io.xeres.app.service.notification.status.StatusNotificationService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) class PeerConnectionManagerTest { @Mock private StatusNotificationService statusNotificationService; @Mock private AvailabilityNotificationService availabilityNotificationService; @Mock ApplicationEventPublisher publisher; @InjectMocks private PeerConnectionManager peerConnectionManager; @Test void addAndRemovePeers() { var location = LocationFakes.createLocation(); var peerConnection = peerConnectionManager.addPeer(location, new ChannelHandlerContextFake()); assertNotNull(peerConnection); assertEquals(1, peerConnectionManager.getNumberOfPeers()); assertEquals(peerConnection, peerConnectionManager.getPeerByLocation(location.getId())); assertEquals(peerConnection, peerConnectionManager.getRandomPeer()); peerConnectionManager.removePeer(location); assertEquals(0, peerConnectionManager.getNumberOfPeers()); } @Test void addPeerAlreadyHere() { var location = LocationFakes.createLocation(); peerConnectionManager.addPeer(location, new ChannelHandlerContextFake()); assertThrows(IllegalStateException.class, () -> peerConnectionManager.addPeer(location, new ChannelHandlerContextFake())); } @Test void removePeerNotHere() { var location = LocationFakes.createLocation(); assertThrows(IllegalStateException.class, () -> peerConnectionManager.removePeer(location)); } @Test void getRandomPeer() { var location1 = LocationFakes.createLocation(); var location2 = LocationFakes.createLocation(); peerConnectionManager.addPeer(location1, new ChannelHandlerContextFake()); peerConnectionManager.addPeer(location2, new ChannelHandlerContextFake()); assertNotNull(peerConnectionManager.getRandomPeer()); } @Test void getRandomPeer_Empty() { assertNull(peerConnectionManager.getRandomPeer()); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/RawItemDecoderPipelineTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer; import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.Unpooled; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.util.ReferenceCountUtil; import io.xeres.app.net.peer.packet.MultiPacketBuilder; import io.xeres.app.net.peer.packet.SimplePacketBuilder; import io.xeres.app.net.peer.pipeline.ItemDecoder; import io.xeres.app.net.peer.pipeline.PacketDecoder; import io.xeres.app.xrs.item.RawItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.service.sliceprobe.item.SliceProbeItem; import org.junit.jupiter.api.Test; import java.util.EnumSet; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; class RawItemDecoderPipelineTest extends AbstractPipelineTest { @Test void NewPacket_Decode_Success() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var item = new SliceProbeItem(); item.setOutgoing(ByteBufAllocator.DEFAULT, null); var itemIn = item.serializeItem(EnumSet.noneOf(SerializationFlags.class)); var inPacket = MultiPacketBuilder.builder() .setRawItem(itemIn) .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); RawItem rawItemOut = channel.readInbound(); assertNotNull(rawItemOut); assertArrayEquals(getByteBufAsArray(itemIn.getBuffer()), getByteBufAsArray(rawItemOut.getBuffer())); ReferenceCountUtil.release(rawItemOut); } @Test void OldPacket_Decode_Success() { var channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder()); var item = new SliceProbeItem(); item.setOutgoing(ByteBufAllocator.DEFAULT, null); var itemIn = item.serializeItem(EnumSet.noneOf(SerializationFlags.class)); var inPacket = SimplePacketBuilder.builder() .setRawItem(itemIn) .build(); channel.writeInbound(Unpooled.wrappedBuffer(inPacket)); RawItem rawItemOut = channel.readInbound(); assertNotNull(rawItemOut); assertArrayEquals(getByteBufAsArray(itemIn.getBuffer()), getByteBufAsArray(rawItemOut.getBuffer())); ReferenceCountUtil.release(rawItemOut); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/packet/MultiPacketBuilder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.packet; import io.netty.buffer.Unpooled; import io.xeres.app.xrs.item.RawItem; import static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_END; import static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_START; import static io.xeres.app.net.peer.packet.Packet.SLICE_PROTOCOL_VERSION_ID_01; public final class MultiPacketBuilder { private MultiPacketBuilder() { throw new UnsupportedOperationException("Utility class"); } public static final class Builder { private int flags = SLICE_FLAG_START | SLICE_FLAG_END; // XXX: make that settable private int packetId; private byte[] data = new byte[0]; private RawItem rawItem; private Builder() { } public Builder setFlags(int flags) { this.flags = flags; return this; } public Builder setPacketId(int packetId) { this.packetId = packetId; return this; } public Builder setData(byte[] data) { this.data = data; return this; } public Builder setRawItem(RawItem rawItem) { this.rawItem = rawItem; return this; } public MultiPacket buildPacket() { var buf = Unpooled.buffer(); buf.writeByte(SLICE_PROTOCOL_VERSION_ID_01); buf.writeByte(flags); buf.writeInt(packetId); if (rawItem != null) { var itemBuf = rawItem.getBuffer(); buf.writeShort(itemBuf.writerIndex()); buf.writeBytes(rawItem.getBuffer()); } else { buf.writeShort(data.length); buf.writeBytes(data); } return new MultiPacket(buf); } public byte[] build() // XXX: is it what we want or do we just need the content? { var buf = buildPacket().getBuffer(); var bytes = new byte[buf.writerIndex()]; buf.readBytes(bytes); return bytes; } } public static Builder builder() { return new Builder(); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/packet/PacketTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.packet; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class PacketTest { @Test void IsSimple_Success() { var packet = SimplePacketBuilder.builder().buildPacket(); assertFalse(packet.isMulti()); } @Test void IsMulti_Success() { var packet = MultiPacketBuilder.builder().buildPacket(); assertTrue(packet.isMulti()); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/packet/SimplePacketBuilder.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.packet; import io.netty.buffer.Unpooled; import io.xeres.app.xrs.item.RawItem; import static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE; public final class SimplePacketBuilder { private SimplePacketBuilder() { throw new UnsupportedOperationException("Utility class"); } public static final class Builder { private int headerSize = HEADER_SIZE; private int version; private int service; private int subPacket; private byte[] data = new byte[0]; private RawItem rawItem; private Builder() { } public Builder setVersion(int version) { this.version = version; return this; } public Builder setService(int service) { this.service = service; return this; } public Builder setSubPacket(int subPacket) { this.subPacket = subPacket; return this; } public Builder setData(byte[] data) { this.data = data; return this; } public Builder setRawItem(RawItem rawItem) { this.rawItem = rawItem; return this; } public Builder setHeaderSize(int size) { headerSize = size; return this; } public SimplePacket buildPacket() { var buf = Unpooled.buffer(); if (rawItem != null) { buf.writeBytes(rawItem.getBuffer()); } else { buf.writeByte(version); buf.writeShort(service); buf.writeByte(subPacket); buf.writeInt(headerSize + data.length); buf.writeBytes(data); } return new SimplePacket(buf); } public byte[] build() { var buf = buildPacket().getBuffer(); var bytes = new byte[buf.writerIndex()]; buf.readBytes(bytes); return bytes; } } public static Builder builder() { return new Builder(); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/peer/ssl/SSLTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.peer.ssl; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.crypto.rsid.RSSerialVersion; import io.xeres.app.crypto.x509.X509; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.testutils.TestUtils; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import javax.net.ssl.SSLException; import java.io.IOException; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.Security; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.security.spec.InvalidKeySpecException; import java.util.Date; import java.util.Optional; import static io.xeres.app.net.peer.ConnectionType.*; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SSLTest { private static PGPSecretKey pgpKey; private static KeyPair rsaKey; private static Profile profile; private static X509Certificate certificate; @Mock private ProfileService profileService; @Mock private LocationService locationService; @BeforeAll static void setup() throws PGPException, IOException, CertificateException { Security.addProvider(new BouncyCastleProvider()); pgpKey = PGP.generateSecretKey("foo", "", 512); rsaKey = RSA.generateKeys(512); profile = ProfileFakes.createProfile("foo", pgpKey.getKeyID(), pgpKey.getPublicKey().getFingerprint(), pgpKey.getPublicKey().getEncoded()); profile.setAccepted(true); certificate = X509.generateCertificate(pgpKey, rsaKey.getPublic(), "CN=" + Id.toString(profile.getPgpIdentifier()), "CN=-", new Date(0), new Date(0), RSSerialVersion.V07_0001.serialNumber()); } @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(SSL.class); } @Test void CreateClientContext_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException { var sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, TCP_OUTGOING); assertNotNull(sslContext); assertTrue(sslContext.isClient()); } @Test void CreateServerContext_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException { var sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, TCP_INCOMING); assertNotNull(sslContext); assertTrue(sslContext.isServer()); } @Test void CreateServerContext_Tor_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException { var sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, TOR_OUTGOING); assertNotNull(sslContext); assertTrue(sslContext.isClient()); } @Test void CreateServerContext_I2P_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException { var sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, I2P_OUTGOING); assertNotNull(sslContext); assertTrue(sslContext.isClient()); } @Test void CheckPeerCertificate_Success() throws CertificateException { var location = LocationFakes.createLocation("bar", profile); when(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.of(location)); var result = SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{certificate}); assertEquals(result, location); verify(locationService).findLocationByLocationIdentifier(any(LocationIdentifier.class)); } @Test void CheckPeerCertificate_EmptyCertificate_Failure() { assertThatThrownBy(() -> SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{})) .isInstanceOf(CertificateException.class) .hasMessage("Empty certificate"); verify(locationService, never()).findLocationByLocationIdentifier(any(LocationIdentifier.class)); } @Test void CheckPeerCertificate_AlreadyConnected_Failure() { var location = LocationFakes.createLocation("bar", profile); location.setConnected(true); when(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.of(location)); assertThatThrownBy(() -> SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{certificate})) .isInstanceOf(CertificateException.class) .hasMessage("Already connected"); verify(locationService).findLocationByLocationIdentifier(any(LocationIdentifier.class)); } @Test void CheckPeerCertificate_WrongCertificate_Failure() throws CertificateException, IOException, PGPException { var wrongPgpKey = PGP.generateSecretKey("notFoo", "", 512); var wrongCertificate = X509.generateCertificate(wrongPgpKey, rsaKey.getPublic(), "CN=me", "CN=foobar", new Date(0), new Date(0), RSSerialVersion.V07_0001.serialNumber()); var location = LocationFakes.createLocation("bar", profile); when(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.of(location)); assertThatThrownBy(() -> SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{wrongCertificate})) .isInstanceOf(CertificateException.class) .hasMessageContaining("Wrong signature"); verify(locationService).findLocationByLocationIdentifier(any(LocationIdentifier.class)); } @Test void CheckPeerCertificate_NoLocationButProfile_Success() throws CertificateException { when(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.empty()); when(profileService.findProfileByPgpIdentifier(profile.getPgpIdentifier())).thenReturn(Optional.of(profile)); var newLocation = SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{certificate}); assertNotNull(newLocation); assertNull(newLocation.getName()); assertEquals("[Unknown]", newLocation.getSafeName()); assertEquals(newLocation.getProfile(), profile); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/protocol/PeerAddressTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.protocol; import io.xeres.app.net.protocol.PeerAddress.Type; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import java.net.InetSocketAddress; import java.util.NoSuchElementException; import java.util.Optional; import static io.xeres.app.net.protocol.PeerAddress.Type.*; import static org.junit.jupiter.api.Assertions.*; class PeerAddressTest { /** * Builds a PeerAddress from a string like "85.123.33.21:21232" */ @Test void FromIpAndPort_Success() { var ipAndPort = "85.123.33.21:21232"; var peerAddress = PeerAddress.fromIpAndPort(ipAndPort); assertEquals(Optional.of(ipAndPort), peerAddress.getAddress()); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isExternal()); assertFalse(peerAddress.isHidden()); assertFalse(peerAddress.isHostname()); assertFalse(peerAddress.isLAN()); } @ParameterizedTest @ValueSource(strings = { "500.500.500.500:21232", // overflow "85.123.33:21232", // octet missing "85.123.33.01:21232", // octet zero prefix "85.123.33.a:21232", // octet not a number "85.123.33.1:a", // port not a number "85.123.33.1:", // separator but missing port ":2323", // separator but missing IP "85.1:21232", // valid IP but confusing "85.123.33.0xa", // valid IP but confusing "85.65530:21232", // valid IP but confusing "283943283", // valid IP but confusing "2384902378237892", // invalid IP (and confusing) "85.123.33.21:0", // low port "85.123.33.21:65537", // illegal port "127.0.0.1:21232", // localhost "0.0.0.0:21232", // "network" address "255.255.255.255:21232", // "broadcast" address "0.1.1.1:21232", // non routable }) void FromIpAndPort_Fail(String source) { var peerAddress = PeerAddress.fromIpAndPort(source); assertFalse(peerAddress.isValid()); assertTrue(peerAddress.isInvalid()); assertTrue(peerAddress.getAddress().isEmpty()); assertTrue(peerAddress.getAddressAsBytes().isEmpty()); assertFalse(peerAddress.isHostname()); assertFalse(peerAddress.isHidden()); assertNull(peerAddress.getSocketAddress()); assertEquals(INVALID, peerAddress.getType()); assertThrows(NoSuchElementException.class, peerAddress::getUrl); } @Test void FromUrl_Success() { var url = "ipv4://194.28.22.1:2233"; var peerAddress = PeerAddress.fromUrl(url); assertEquals(url, peerAddress.getUrl()); assertTrue(peerAddress.isValid()); assertFalse(peerAddress.isHidden()); assertFalse(peerAddress.isHostname()); } @ParameterizedTest @NullAndEmptySource @ValueSource(strings = { "ipv4://194.28.22.1", // missing port "ipv5://194.28.22.1:1234", // bad protocol "ipv666://23sd.2343.2487.asdk" // nonsense }) void FromUrl_Failure(String url) { var peerAddress = PeerAddress.fromUrl(url); assertFalse(peerAddress.isValid()); assertThrows(NoSuchElementException.class, peerAddress::getUrl); } @ParameterizedTest @ValueSource(strings = { "194.28.22.1:1026", "1.0.0.1:1026" }) void FromAddress_Success(String source) { var peerAddress = PeerAddress.fromAddress(source); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isExternal()); assertFalse(peerAddress.isLAN()); } @Test void FromAddress_MissingPort_Failure() { var peerAddress = PeerAddress.fromAddress("194.28.22.1"); assertFalse(peerAddress.isValid()); } @Test void FromIpAndPort_NotPublicButPrivateLan_Success() { var peerAddress = PeerAddress.fromIpAndPort("192.168.1.5:21232"); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isLAN()); } @ParameterizedTest @ValueSource(strings = { "1.1.1.255:21232", // broadcast convention "1.1.1.0:21232" // network convention }) void FromIpAndPort_ConventionButRoutable_Success(String source) { var peerAddress = PeerAddress.fromIpAndPort(source); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isExternal()); assertFalse(peerAddress.isLAN()); } /** * Tor v2 is not supported anymore */ @Test void FromTor_v2_Failure() { var peerAddress = PeerAddress.fromOnion("expyuzz4wqqyqhjn.onion"); assertFalse(peerAddress.isValid()); } @Test void FromTor_v3_Success() { var peerAddress = PeerAddress.fromOnion("xpxduj55x2j27l2qytu2tcetykyfxbjbafin3x4i3ywddzphkbrd3jyd.onion:1234"); assertTrue(peerAddress.isValid()); assertEquals(Type.TOR, peerAddress.getType()); } @Test void FromI2p_Success() { var peerAddress = PeerAddress.fromI2p("g6u4vqiuy6bdc3dbu6a7gmi3ip45sqwgtbgrr6uupqaaqfyztrka.b32.i2p:1234"); assertTrue(peerAddress.isValid()); assertEquals(Type.I2P, peerAddress.getType()); } @Test void FromTor_WrongAddress_Failure() { var peerAddress = PeerAddress.fromOnion("192.168.1.2:8080"); assertFalse(peerAddress.isValid()); assertFalse(peerAddress.isHidden()); } @Test void FromHidden_Success() { var peerAddress = PeerAddress.fromHidden("xpxduj55x2j27l2qytu2tcetykyfxbjbafin3x4i3ywddzphkbrd3jyd.onion:1234"); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isHidden()); } @Test void FromHidden_WrongAddress_Failure() { var peerAddress = PeerAddress.fromHidden("192.168.1.2:8080"); assertFalse(peerAddress.isValid()); assertFalse(peerAddress.isHidden()); } @Test void FromHostname_Success() { var peerAddress = PeerAddress.fromHostname("foo.bar.com"); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isHostname()); assertInstanceOf(DomainNameSocketAddress.class, peerAddress.getSocketAddress()); } @Test void FromHostName_Invalid() { var peerAddress = PeerAddress.fromHostname("verylonghostnamethatismorethan63charsandislikelyinvalidandwillfailspectacularly.com"); assertFalse(peerAddress.isValid()); } @Test void FromHostNameAndPort_Success() { var peerAddress = PeerAddress.fromHostname("foo.bar.com", 8080); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isHostname()); assertInstanceOf(InetSocketAddress.class, peerAddress.getSocketAddress()); assertEquals("foo.bar.com", ((InetSocketAddress) peerAddress.getSocketAddress()).getHostString()); assertEquals(8080, ((InetSocketAddress) peerAddress.getSocketAddress()).getPort()); } @Test void FromSocketAddress_Success() { var peerAddress = PeerAddress.fromSocketAddress(InetSocketAddress.createUnresolved("foobar.com", 1234)); assertTrue(peerAddress.isValid()); assertFalse(peerAddress.isHostname()); assertInstanceOf(InetSocketAddress.class, peerAddress.getSocketAddress()); assertEquals("foobar.com", ((InetSocketAddress) peerAddress.getSocketAddress()).getHostString()); assertEquals(1234, ((InetSocketAddress) peerAddress.getSocketAddress()).getPort()); } @Test void FromHostNameAndPortString_Success() { var peerAddress = PeerAddress.fromHostnameAndPort("foo.bar.com:8080"); assertTrue(peerAddress.isValid()); assertTrue(peerAddress.isHostname()); assertInstanceOf(InetSocketAddress.class, peerAddress.getSocketAddress()); assertEquals("foo.bar.com", ((InetSocketAddress) peerAddress.getSocketAddress()).getHostString()); assertEquals(8080, ((InetSocketAddress) peerAddress.getSocketAddress()).getPort()); } @Test void Type_Enum_Order() { assertEquals(0, INVALID.ordinal()); assertEquals(1, IPV4.ordinal()); assertEquals(2, IPV6.ordinal()); assertEquals(3, TOR.ordinal()); assertEquals(4, HOSTNAME.ordinal()); assertEquals(5, I2P.ordinal()); assertEquals(6, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/protocol/i2p/I2pAddressTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.protocol.i2p; import io.xeres.common.protocol.i2p.I2pAddress; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import static io.xeres.common.protocol.i2p.I2pAddress.isValidAddress; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class I2pAddressTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(I2pAddress.class); } @Test void IsValidAddress_Success() { assertTrue(isValidAddress("g6u4vqiuy6bdc3dbu6a7gmi3ip45sqwgtbgrr6uupqaaqfyztrka.b32.i2p:1234")); } @Test void IsValidAddress_Failure() { assertFalse(isValidAddress("foobar.b32.i2p:1234")); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/protocol/tor/OnionAddressTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.protocol.tor; import io.xeres.common.protocol.tor.OnionAddress; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import static io.xeres.common.protocol.tor.OnionAddress.isValidAddress; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class OnionAddressTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(OnionAddress.class); } @Test void IsValidAddress_Success() { assertTrue(isValidAddress("answerszuvs3gg2l64e6hmnryudl5zgrmwm3vh65hzszdghblddvfiqd.onion:1234")); } @Test void IsValidAddress_Failure() { assertFalse(isValidAddress("3g2upl4pq6kufc4m.onion:1234")); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/upnp/ControlPointTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import io.xeres.testutils.FakeHttpServer; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import java.net.URI; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class ControlPointTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(ControlPoint.class); } @Test void AddPortMapping_Success() { var fakeHTTPServer = new FakeHttpServer("/control", 200, null); var added = ControlPoint.addPortMapping( URI.create("http://localhost:" + fakeHTTPServer.getPort() + "/control"), "urn:schemas-upnp-org:service:WANIPConnection:1", "192.168.1.78", 2000, 2000, 3600, Protocol.TCP ); assertTrue(added); fakeHTTPServer.shutdown(); } @Test void RemovePortMapping_Success() { var fakeHTTPServer = new FakeHttpServer("/control", 200, null); var removed = ControlPoint.removePortMapping( URI.create("http://localhost:" + fakeHTTPServer.getPort() + "/control"), "urn:schemas-upnp-org:service:WANIPConnection:1", 2000, Protocol.TCP ); assertTrue(removed); fakeHTTPServer.shutdown(); } @Test void GetExternalIPAddress_Success() { var responseBody = "" + "" + "" + "1.1.1.1" + "" + "" + ""; var fakeHTTPServer = new FakeHttpServer("/control", 200, responseBody.getBytes()); var response = ControlPoint.getExternalIpAddress( URI.create("http://localhost:" + fakeHTTPServer.getPort() + "/control"), "urn:schemas-upnp-org:service:WANIPConnection:1" ); assertEquals("1.1.1.1", response); fakeHTTPServer.shutdown(); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/upnp/DeviceTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import io.xeres.testutils.FakeHttpServer; import org.junit.jupiter.api.Test; import org.springframework.util.ResourceUtils; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.file.Files; import static org.junit.jupiter.api.Assertions.*; class DeviceTest { @Test void From_Success() throws IOException { var routerReply = Files.readAllBytes(ResourceUtils.getFile(ResourceUtils.CLASSPATH_URL_PREFIX + "upnp/routers/RT-AC87U.xml").toPath()); var fakeHTTPServer = new FakeHttpServer("/rootDesc.xml", 200, routerReply); var inetSocketAddress = new InetSocketAddress(fakeHTTPServer.getPort()); var httpuReply = "HTTP/1.1 200 OK\n" + "CACHE-CONTROL: max-age=120\n" + "ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\n" + "USN: uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d8::urn:schemas-upnp-org:device:InternetGatewayDevice:1\n" + "EXT:\n" + "SERVER: AsusWRT/384.13 UPnP/1.1 MiniUPnPd/2.1\n" + "LOCATION: http://localhost:" + fakeHTTPServer.getPort() + "/rootDesc.xml\n" + "OPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\n" + "01-NLS: 1594920600\n" + "BOOTID.UPNP.ORG: 1594920600\n" + "CONFIGID.UPNP.ORG: 1337\n" + "\n"; var device = Device.from( inetSocketAddress, ByteBuffer.wrap(httpuReply.getBytes()) ); assertTrue(device.isValid()); assertFalse(device.isInvalid()); assertEquals(inetSocketAddress, device.getInetSocketAddress()); assertTrue(device.hasLocation()); assertEquals("http://localhost:" + fakeHTTPServer.getPort() + "/rootDesc.xml", device.getLocationUrl().toString()); assertTrue(device.hasServer()); assertEquals("AsusWRT/384.13 UPnP/1.1 MiniUPnPd/2.1", device.getServer()); assertTrue(device.hasUsn()); assertEquals("uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d8::urn:schemas-upnp-org:device:InternetGatewayDevice:1", device.getUsn()); device.addControlPoint(); assertTrue(device.hasControlPoint()); assertTrue(device.hasControlUrl()); assertEquals("http://localhost:" + fakeHTTPServer.getPort() + "/ctl/IPConn", device.getControlUrl().toString()); assertTrue(device.hasManufacturer()); assertEquals("ASUSTek", device.getManufacturer()); assertEquals("http://www.asus.com/", device.getManufacturerUrl().toString()); assertTrue(device.hasModelName()); assertEquals("RT-AC87U", device.getModelName()); assertTrue(device.hasPresentationUrl()); assertEquals("http://192.168.1.1:80/", device.getPresentationUrl().toString()); assertTrue(device.hasSerialNumber()); assertEquals("88:d7:f6:44:f8:d8", device.getSerialNumber()); assertEquals("urn:schemas-upnp-org:service:WANIPConnection:1", device.getServiceType()); fakeHTTPServer.shutdown(); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/upnp/PortMappingTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import org.junit.jupiter.api.Test; import static io.xeres.app.net.upnp.Protocol.TCP; import static io.xeres.app.net.upnp.Protocol.UDP; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; class PortMappingTest { @Test void Compare_Success() { var mapping1 = new PortMapping(1025, TCP); var mapping2 = new PortMapping(1025, TCP); assertEquals(mapping1, mapping2); } @Test void Compare_UnequalPort_Failure() { var mapping1 = new PortMapping(1025, TCP); var mapping2 = new PortMapping(1026, TCP); assertNotEquals(mapping1, mapping2); } @Test void Compare_UnequalProtocols_Failure() { var mapping1 = new PortMapping(1025, TCP); var mapping2 = new PortMapping(1025, UDP); assertNotEquals(mapping1, mapping2); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/upnp/SoapTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import io.xeres.testutils.FakeHttpServer; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatusCode; import org.xml.sax.SAXException; import javax.xml.namespace.NamespaceContext; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.xpath.XPathException; import javax.xml.xpath.XPathFactory; import javax.xml.xpath.XPathNodes; import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; class SoapTest { private static final String SERVICE_TYPE = "urn:schemas-upnp-org:service:WANIPConnection:1"; private static final String ACTION = "AddPortMapping"; @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(Soap.class); } @Test void SendRequest_Success() throws IOException, ParserConfigurationException, SAXException, XPathException { String key1 = "NewExternalPort", key2 = "NewProtocol"; String value1 = "1234", value2 = "TCP"; var fakeHTTPServer = new FakeHttpServer("/soaptest.xml", HttpURLConnection.HTTP_OK, "OK".getBytes()); Map args = LinkedHashMap.newLinkedHashMap(2); args.put(key1, value1); args.put(key2, value2); var responseEntity = Soap.sendRequest(URI.create("http://localhost:" + fakeHTTPServer.getPort() + "/soaptest.xml"), SERVICE_TYPE, ACTION, args); assertEquals("OK", responseEntity.getBody()); var documentBuilderFactory = DocumentBuilderFactory.newInstance(); documentBuilderFactory.setNamespaceAware(true); var document = documentBuilderFactory.newDocumentBuilder().parse(new ByteArrayInputStream(fakeHTTPServer.getRequestBody())); assertEquals("1.0", document.getXmlVersion()); var xPath = XPathFactory.newInstance().newXPath(); xPath.setNamespaceContext(createNameSpaceContext(Map.of( "s", "http://schemas.xmlsoap.org/soap/envelope/", "u", SERVICE_TYPE))); var nodes = xPath.evaluateExpression("//s:Envelope//s:Body//u:" + ACTION, document, XPathNodes.class); assertEquals(1, nodes.size()); assertEquals("u:" + ACTION, nodes.get(0).getNodeName()); var childNodes = nodes.get(0).getChildNodes(); assertEquals(key1, childNodes.item(0).getNodeName()); assertEquals(value1, childNodes.item(0).getTextContent()); assertEquals(key2, childNodes.item(1).getNodeName()); assertEquals(value2, childNodes.item(1).getTextContent()); fakeHTTPServer.shutdown(); } @Test void SendRequest_Error() { String key1 = "NewExternalPort", key2 = "NewProtocol"; String value1 = "1234", value2 = "TCP"; var fakeHTTPServer = new FakeHttpServer("/soaptest.xml", HttpURLConnection.HTTP_BAD_REQUEST, "Error".getBytes()); Map args = LinkedHashMap.newLinkedHashMap(2); args.put(key1, value1); args.put(key2, value2); var responseEntity = Soap.sendRequest(URI.create("http://localhost:" + fakeHTTPServer.getPort() + "/soaptest.xml"), SERVICE_TYPE, ACTION, args); assertEquals(HttpStatusCode.valueOf(400), responseEntity.getStatusCode()); assertNull(responseEntity.getBody()); } private NamespaceContext createNameSpaceContext(Map uris) { return new NamespaceContext() { @Override public String getNamespaceURI(String prefix) { return uris.get(prefix); } @Override public String getPrefix(String namespaceURI) { return null; } @Override public Iterator getPrefixes(String namespaceURI) { return null; } }; } } ================================================ FILE: app/src/test/java/io/xeres/app/net/upnp/UPNPServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.upnp; import io.xeres.app.service.notification.status.StatusNotificationService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.Duration; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertFalse; @ExtendWith(MockitoExtension.class) class UPNPServiceTest { @Mock private StatusNotificationService statusNotificationService; @InjectMocks private UPNPService upnpService; @Test void StartStop_Success() { upnpService.start("127.0.0.1", 1901, 0); // nothing should reply in there await().atMost(Duration.ofSeconds(2)).until(() -> upnpService.isRunning()); upnpService.stop(); upnpService.waitForTermination(); assertFalse(upnpService.isRunning()); } } ================================================ FILE: app/src/test/java/io/xeres/app/net/util/NetworkModeTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.net.util; import org.junit.jupiter.api.Test; import static io.xeres.app.net.util.NetworkMode.*; import static org.junit.jupiter.api.Assertions.*; class NetworkModeTest { @Test void Enum_Order_Fixed() { assertEquals(0, PUBLIC.ordinal()); assertEquals(1, PRIVATE.ordinal()); assertEquals(2, INVERTED.ordinal()); assertEquals(3, DARKNET.ordinal()); assertEquals(4, values().length); } @Test void IsDiscoverable() { assertTrue(isDiscoverable(PUBLIC)); assertTrue(isDiscoverable(PRIVATE)); assertFalse(isDiscoverable(INVERTED)); assertFalse(isDiscoverable(DARKNET)); } @Test void HasDht() { assertTrue(hasDht(PUBLIC)); assertTrue(hasDht(INVERTED)); assertFalse(hasDht(PRIVATE)); assertFalse(hasDht(DARKNET)); } @Test void GetNetworkMode() { assertEquals(PUBLIC, getNetworkMode(2, 2)); assertEquals(PRIVATE, getNetworkMode(2, 0)); assertEquals(INVERTED, getNetworkMode(0, 2)); assertEquals(DARKNET, getNetworkMode(0, 0)); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/CapabilityServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.application.autostart.AutoStart; import io.xeres.common.rest.config.Capabilities; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CapabilityServiceTest { @Mock private AutoStart autoStart; @InjectMocks private CapabilityService capabilityService; @Test void GetCapabilities_Success() { when(autoStart.isSupported()).thenReturn(true); var capabilities = capabilityService.getCapabilities(); assertTrue(capabilities.contains(Capabilities.AUTOSTART)); verify(autoStart).isSupported(); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/ContactServiceTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.location.Availability; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ContactServiceTest { @Mock private ProfileService profileService; @Mock private IdentityService identityService; @InjectMocks private ContactService contactService; @Test void getContacts_ShouldReturnCombinedList() { var profile = ProfileFakes.createProfile("Test Profile", 1L); profile.setAccepted(true); var identity = new IdentityGroupItem(); identity.setId(2L); identity.setName("Test Identity"); identity.setProfile(profile); when(profileService.getAllProfiles()).thenReturn(List.of(profile)); when(identityService.getAll()).thenReturn(List.of(identity)); var result = contactService.getContacts(); assertEquals(2, result.size()); assertTrue(result.stream().anyMatch(c -> c.name().equals("Test Profile"))); assertTrue(result.stream().anyMatch(c -> c.name().equals("Test Identity"))); } @Test void toContacts_WithIdentityList_ShouldConvertCorrectly() { var profile = ProfileFakes.createOwnProfile(); profile.setAccepted(true); var identity = new IdentityGroupItem(); identity.setId(2L); identity.setName("Test Identity"); identity.setProfile(profile); var result = contactService.toContacts(List.of(identity)); assertEquals(1, result.size()); assertEquals("Test Identity", result.getFirst().name()); assertEquals(1L, result.getFirst().profileId()); assertEquals(2L, result.getFirst().identityId()); assertTrue(result.getFirst().accepted()); } @Test void toContact_WithProfile_ShouldConvertCorrectly() { var profile = ProfileFakes.createOwnProfile(); profile.setAccepted(true); var result = contactService.toContact(profile); assertEquals(profile.getName(), result.name()); assertEquals(1L, result.profileId()); assertEquals(0L, result.identityId()); assertTrue(result.accepted()); } @Test void getAvailability_WithNullProfile_ShouldReturnOffline() { var identity = new IdentityGroupItem(); identity.setName("Test Identity"); var result = contactService.toContacts(List.of(identity)); assertEquals(Availability.OFFLINE, result.getFirst().availability()); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/ForumMessageServiceTest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.gxs.ForumMessageItemFakes; import io.xeres.app.xrs.service.forum.ForumRsService; import io.xeres.app.xrs.service.forum.item.ForumMessageItem; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.testutils.IdFakes; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import java.util.List; import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ForumMessageServiceTest { @Mock private ForumRsService forumRsService; @Mock private IdentityService identityService; @InjectMocks private ForumMessageService forumMessageService; @Test void getAuthorsMapFromSummaries_ShouldReturnCorrectMap() { var gxsId = IdFakes.createGxsId(); var summary = ForumMessageItemFakes.createForumMessageItemSummary(IdFakes.createMsgId(), gxsId, null); var identityGroupItem = new IdentityGroupItem(); identityGroupItem.setGxsId(gxsId); when(identityService.findAll(Set.of(gxsId))) .thenReturn(List.of(identityGroupItem)); Map result = forumMessageService.getAuthorsMapFromSummaries(new PageImpl<>(List.of(summary))); assertNotNull(result); assertEquals(1, result.size()); assertTrue(result.containsKey(gxsId)); assertEquals(identityGroupItem, result.get(gxsId)); } @Test void getAuthorsMapFromMessages_ShouldReturnCorrectMap() { var gxsId = IdFakes.createGxsId(); var message = ForumMessageItemFakes.createForumMessageItem(); message.setAuthorGxsId(gxsId); var identityGroupItem = new IdentityGroupItem(); identityGroupItem.setGxsId(gxsId); when(identityService.findAll(Set.of(gxsId))) .thenReturn(List.of(identityGroupItem)); Map result = forumMessageService.getAuthorsMapFromMessages(List.of(message)); assertNotNull(result); assertEquals(1, result.size()); assertTrue(result.containsKey(gxsId)); assertEquals(identityGroupItem, result.get(gxsId)); } @Test void getMessagesMapFromSummaries_ShouldReturnCorrectMap() { var msgId = IdFakes.createMsgId(); var parentMsgId = IdFakes.createMsgId(); var groupId = 1L; var summary = ForumMessageItemFakes.createForumMessageItemSummary(msgId, null, parentMsgId); var message = new ForumMessageItem(); message.setMsgId(msgId); when(forumRsService.findAllMessages(groupId, Set.of(msgId, parentMsgId))) .thenReturn(List.of(message)); Map result = forumMessageService.getMessagesMapFromSummaries(groupId, new PageImpl<>(List.of(summary))); assertNotNull(result); assertEquals(1, result.size()); assertTrue(result.containsKey(msgId)); assertEquals(message, result.get(msgId)); } @Test void getMessagesMapFromMessages_WithGroupId_ShouldReturnCorrectMap() { var msgId = IdFakes.createMsgId(); var parentMsgId = IdFakes.createMsgId(); var groupId = 1L; var message = new ForumMessageItem(); message.setMsgId(msgId); message.setParentMsgId(parentMsgId); when(forumRsService.findAllMessages(groupId, Set.of(msgId, parentMsgId))) .thenReturn(List.of(message)); Map result = forumMessageService.getMessagesMapFromMessages(groupId, List.of(message)); assertNotNull(result); assertEquals(1, result.size()); assertTrue(result.containsKey(msgId)); assertEquals(message, result.get(msgId)); } @Test void getMessagesMapFromMessages_WithoutGroupId_ShouldReturnCorrectMap() { var msgId = IdFakes.createMsgId(); var parentMsgId = IdFakes.createMsgId(); var message = new ForumMessageItem(); message.setMsgId(msgId); message.setParentMsgId(parentMsgId); when(forumRsService.findAllMessages(Set.of(msgId, parentMsgId))) .thenReturn(List.of(message)); Map result = forumMessageService.getMessagesMapFromMessages(List.of(message)); assertNotNull(result); assertEquals(1, result.size()); assertTrue(result.containsKey(msgId)); assertEquals(message, result.get(msgId)); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/GeoIpServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import com.maxmind.geoip2.DatabaseReader; import com.maxmind.geoip2.exception.GeoIp2Exception; import com.maxmind.geoip2.model.CountryResponse; import com.maxmind.geoip2.record.Country; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; import java.net.InetAddress; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class GeoIpServiceTest { @Mock private DatabaseReader databaseReader; @InjectMocks private GeoIpService geoIpService; @Test void GetCountry_Success() throws IOException, GeoIp2Exception { var address = "1.1.1.1"; var inetAddress = InetAddress.getByName(address); when(databaseReader.country(inetAddress)).thenReturn(new CountryResponse(null, new Country(null, null, null, false, "CH", null), null, null, null, null)); var country = geoIpService.getCountry(address); assertEquals(io.xeres.common.geoip.Country.CH, country); verify(databaseReader).country(inetAddress); } @Test void GetCountry_Failure() throws IOException, GeoIp2Exception { var address = "1.1.1.1"; var inetAddress = InetAddress.getByName(address); when(databaseReader.country(inetAddress)).thenThrow(new GeoIp2Exception("Country not found")); var country = geoIpService.getCountry(address); assertNull(country); verify(databaseReader).country(inetAddress); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/LocationServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.database.model.connection.ConnectionFakes; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.database.repository.LocationRepository; import io.xeres.common.id.ProfileFingerprint; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKey; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; import java.io.IOException; import java.net.InetSocketAddress; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.Security; import java.security.cert.CertificateException; import java.security.spec.InvalidKeySpecException; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Optional; import static io.xeres.app.net.protocol.PeerAddress.Type.IPV4; import static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LocationServiceTest { @Mock private SettingsService settingsService; @Mock private ProfileService profileService; @Mock private LocationRepository locationRepository; @Mock private ApplicationEventPublisher publisher; @InjectMocks private LocationService locationService; private static PGPSecretKey pgpSecretKey; private static KeyPair keyPair; private static Profile ownProfile; @BeforeAll static void setup() throws PGPException { Security.addProvider(new BouncyCastleProvider()); pgpSecretKey = PGP.generateSecretKey("test", "", 512); keyPair = RSA.generateKeys(512); ownProfile = Profile.createProfile("test", pgpSecretKey.getKeyID(), pgpSecretKey.getPublicKey().getCreationTime().toInstant(), new ProfileFingerprint(pgpSecretKey.getPublicKey().getFingerprint()), pgpSecretKey.getPublicKey()); } @Test void LocationService_GenerateLocationKeys_Success() { when(settingsService.getLocationPrivateKeyData()).thenReturn(null); assertNotNull(locationService.generateLocationKeys()); verify(settingsService).getLocationPrivateKeyData(); } @Test void GenerateLocationKeys_LocationAlreadyExists_Success() { when(settingsService.getLocationPrivateKeyData()).thenReturn(new byte[]{1}); assertNull(locationService.generateLocationKeys()); verify(settingsService, never()).saveLocationKeys(any(KeyPair.class)); } @Test void GenerateLocationCertificate_Success() throws NoSuchAlgorithmException, CertificateException, InvalidKeySpecException, IOException { when(settingsService.getSecretProfileKey()).thenReturn(pgpSecretKey.getEncoded()); when(profileService.getOwnProfile()).thenReturn(ownProfile); assertNotNull(locationService.generateLocationCertificate(keyPair.getPublic().getEncoded())); } @Test void CreateLocation_Success() throws IOException { when(settingsService.isOwnProfilePresent()).thenReturn(true); when(profileService.getOwnProfile()).thenReturn(ownProfile); when(settingsService.getSecretProfileKey()).thenReturn(pgpSecretKey.getEncoded()); when(settingsService.getLocationCertificate()).thenReturn(keyPair.getPublic().getEncoded()); when(profileService.getOwnProfile()).thenReturn(ownProfile); locationService.generateOwnLocation("test"); verify(settingsService, times(1)).isOwnProfilePresent(); verify(profileService, times(2)).getOwnProfile(); } @Test void GetConnectionsToConnectTo_Success() { var now = Instant.now(); // Own location var ownLocation = LocationFakes.createOwnLocation(); ownLocation.addConnection(ConnectionFakes.createConnection(IPV4, "2.3.4.5:1234", true)); // First location with 1 connection var location1 = LocationFakes.createLocation("test1", ownProfile); location1.addConnection(ConnectionFakes.createConnection()); // Second location with 3 connections var location2 = LocationFakes.createLocation("test2", ownProfile); var oldConnection = ConnectionFakes.createConnection(); var recentConnection = ConnectionFakes.createConnection(); var nullConnection = ConnectionFakes.createConnection(); oldConnection.setLastConnected(now.minus(Duration.ofDays(1))); location2.addConnection(oldConnection); recentConnection.setLastConnected(now); location2.addConnection(recentConnection); location2.addConnection(nullConnection); var locations = List.of(location1, location2); Slice slice = new SliceImpl<>(locations); when(locationRepository.findAllByConnectedFalse(any(Pageable.class))).thenReturn(slice); when(locationRepository.findById(OWN_LOCATION_ID)).thenReturn(Optional.of(ownLocation)); // First run var connections = locationService.getConnectionsToConnectTo(10); assertEquals(2, connections.size()); assertEquals(location1.getConnections().getFirst(), connections.get(0)); assertEquals(recentConnection, connections.get(1)); // Second run connections = locationService.getConnectionsToConnectTo(10); assertEquals(2, connections.size()); assertEquals(location1.getConnections().getFirst(), connections.get(0)); assertEquals(oldConnection, connections.get(1)); // Third run connections = locationService.getConnectionsToConnectTo(10); assertEquals(2, connections.size()); assertEquals(location1.getConnections().getFirst(), connections.get(0)); assertEquals(nullConnection, connections.get(1)); } @Test void GetConnectionsToConnectTo_PreferLAN() { var now = Instant.now(); // Own location var ownLocation = LocationFakes.createOwnLocation(); ownLocation.addConnection(ConnectionFakes.createConnection(IPV4, "2.3.4.5:1234", true)); // First location with 1 connection, same address var location1 = LocationFakes.createLocation("test1", ownProfile); location1.addConnection(ConnectionFakes.createConnection(IPV4, "2.3.4.5:1234", true)); // Second location with 2 connections, one same, one LAN var location2 = LocationFakes.createLocation("test2", ownProfile); var wanConnection = ConnectionFakes.createConnection(IPV4, "2.3.4.5:1234", true); var lanConnection = ConnectionFakes.createConnection(IPV4, "192.168.1.25:1234", false); wanConnection.setLastConnected(now); location2.addConnection(wanConnection); lanConnection.setLastConnected(now); location2.addConnection(lanConnection); var locations = List.of(location1, location2); Slice slice = new SliceImpl<>(locations); when(locationRepository.findAllByConnectedFalse(any(Pageable.class))).thenReturn(slice); when(locationRepository.findById(OWN_LOCATION_ID)).thenReturn(Optional.of(ownLocation)); // First run var connections = locationService.getConnectionsToConnectTo(10); assertEquals(2, connections.size()); assertEquals(location1.getConnections().getFirst(), connections.get(0)); assertEquals(lanConnection, connections.get(1)); // Second run connections = locationService.getConnectionsToConnectTo(10); assertEquals(2, connections.size()); assertEquals(location1.getConnections().getFirst(), connections.get(0)); assertEquals(wanConnection, connections.get(1)); } @Test void SetConnected_Success() { var location = LocationFakes.createLocation("foo", ProfileFakes.createProfile("foo", 1)); locationService.setConnected(location, new InetSocketAddress("127.0.0.1", 666)); assertTrue(location.isConnected()); } @Test void SetDisconnected_Success() { var location = LocationFakes.createLocation("foo", ProfileFakes.createProfile("foo", 1)); location.setConnected(true); locationService.setDisconnected(location); assertFalse(location.isConnected()); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/ProfileServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.Profile; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.database.repository.ProfileRepository; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.common.dto.profile.ProfileConstants; import io.xeres.common.id.ProfileFingerprint; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.security.Security; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ProfileServiceTest { @Mock private SettingsService settingsService; @Mock private ProfileRepository profileRepository; @Mock private ContactNotificationService contactNotificationService; @InjectMocks private ProfileService profileService; @BeforeAll static void setup() { Security.addProvider(new BouncyCastleProvider()); } @Test void GenerateProfileKeys_Success() { var name = "test"; assertEquals(ResourceCreationState.CREATED, profileService.generateProfileKeys(name)); var profile = ArgumentCaptor.forClass(Profile.class); verify(profileRepository).save(profile.capture()); assertTrue(profile.getValue().getName().startsWith(name)); verify(settingsService).saveSecretProfileKey(any(byte[].class)); } @Test void GenerateProfileKeys_AlreadyExists_Failure() { var name = "test"; when(profileRepository.findById(ProfileConstants.OWN_PROFILE_ID)).thenReturn(Optional.of(ProfileFakes.createProfile())); assertEquals(ResourceCreationState.ALREADY_EXISTS, profileService.generateProfileKeys(name)); verify(profileRepository, never()).save(any(Profile.class)); verify(settingsService, never()).saveSecretProfileKey(any(byte[].class)); } @Test void GenerateProfileKeys_KeyIdTooShort_Failure() { var name = ""; assertThatThrownBy(() -> profileService.generateProfileKeys(name)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("too short"); verify(profileRepository, never()).save(any(Profile.class)); verify(settingsService, never()).saveSecretProfileKey(any(byte[].class)); } @Test void GenerateProfileKeys_KeyIdTooLong_Failure() { var name = "12345678900987654321123456789098765432120987676543432123456798765"; assertThatThrownBy(() -> profileService.generateProfileKeys(name)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("too long"); verify(profileRepository, never()).save(any(Profile.class)); verify(settingsService, never()).saveSecretProfileKey(any(byte[].class)); } @Test void CreateOrUpdateProfile_Update_Success() { var first = ProfileFakes.createProfile("first", 1); first.addLocation(LocationFakes.createLocation("first location", first)); var second = ProfileFakes.createProfile("first", 1); second.addLocation(LocationFakes.createLocation("second location", second)); when(profileRepository.findByProfileFingerprint(any(ProfileFingerprint.class))).thenReturn(Optional.of(first)); when(profileRepository.save(any(Profile.class))).thenAnswer(mock -> mock.getArguments()[0]); var updated = profileService.createOrUpdateProfile(second); assertEquals(2, updated.getLocations().size()); // XXX: add the case where we "update" an existing location, not just add } } ================================================ FILE: app/src/test/java/io/xeres/app/service/QrCodeServiceTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import com.google.zxing.BinaryBitmap; import com.google.zxing.MultiFormatReader; import com.google.zxing.NotFoundException; import com.google.zxing.client.j2se.BufferedImageLuminanceSource; import com.google.zxing.common.HybridBinarizer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; @ExtendWith(MockitoExtension.class) class QrCodeServiceTest { @InjectMocks private QrCodeService qrCodeService; @Test void GenerateQrCode_Success() throws NotFoundException { var message = "hello world"; var image = qrCodeService.generateQrCode(message); var source = new BufferedImageLuminanceSource(image); var bitmap = new BinaryBitmap(new HybridBinarizer(source)); var result = new MultiFormatReader().decode(bitmap); assertEquals(message, result.getText()); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/ServiceRulesTest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import org.springframework.stereotype.Service; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; @AnalyzeClasses() class ServiceRulesTest { @ArchTest private final ArchRule servicesShouldHaveSuffix = classes() .that().resideInAPackage("..service..") .and().areAnnotatedWith(Service.class) .should().haveSimpleNameEndingWith("Service"); } ================================================ FILE: app/src/test/java/io/xeres/app/service/SettingsServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import io.xeres.app.database.model.settings.Settings; import io.xeres.app.database.repository.SettingsRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class SettingsServiceTest { @Mock private SettingsRepository settingsRepository; @Mock private Settings settings; @InjectMocks private SettingsService settingsService; @Test void SaveSecretProfileKey_Success() { when(settingsRepository.findById((byte) 1)).thenReturn(Optional.of(settings)); settingsService.init(); settingsService.saveSecretProfileKey(new byte[]{1}); verify(settings).setPgpPrivateKeyData(any(byte[].class)); verify(settingsRepository).save(any(Settings.class)); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/UnHtmlServiceTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @ExtendWith(MockitoExtension.class) class UnHtmlServiceTest { @InjectMocks private UnHtmlService unHtmlService; @Test void UnchangedMessage() { var result = unHtmlService.cleanupMessage("foo"); assertEquals("foo", result); } @Test void QuotedMessage() { var result = unHtmlService.cleanupMessage(""" > this is a quote

and that's the reply
"""); assertEquals(""" \\> this is a quote \s \s and that's the reply """, result); } @Test void MultiLevelQuotes() { var result = unHtmlService.cleanupMessage(""" >>> third >> second > first not a quote """); assertEquals(""" >>> third >> second > first not a quote """, result); } @Test void Pre() { var result = unHtmlService.cleanupMessage("""
flatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref
"""); assertEquals("```\nflatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref\n```\n", result); } @Test void Code() { var result = unHtmlService.cleanupMessage(""" flatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref"""); assertEquals("`flatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref`\n", result); } @Test void CodeWithLanguage() { var result = unHtmlService.cleanupMessage("""
						System.out.println("hello world");
					
""" ); assertEquals(""" ```java System.out.println("hello world"); ``` """, result); } // This one is not aesthetically important because it will translate to JavaFX nodes later and is // never visible to the user. @Test void AllTags() { var result = unHtmlService.cleanupMessage("""

header1

header2

header3

header4

header5
header6

Some paragraph


Someone said...
  • First item
  • Second item
  1. First item
  2. Second item
italic, bold, strikethrough, link here """); assertEquals(""" # header1 ## header2 ### header3 #### header4 ##### header5 ###### header6 Some paragraph ___ > Someone said... - First item - Second item\s 1. First item 2. Second item\s *italic*, **bold**, ~strikethrough~, link [here](https://xeres.io "") """, result); } @Test void BrokenHtml() { // Parent of
 must be a block, like 
, but it's an inline tag. Seen in some RS generated posts.
		var result = unHtmlService.cleanupMessage("""
				
				 
				  
foo
""" ); assertTrue(result.startsWith("## Invalid HTML document")); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/file/FileServiceTest.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.file; import io.xeres.app.configuration.DataDirConfiguration; import io.xeres.app.database.model.file.FileFakes; import io.xeres.app.database.model.share.ShareFakes; import io.xeres.app.database.repository.FileRepository; import io.xeres.app.database.repository.ShareRepository; import io.xeres.app.service.notification.file.FileNotificationService; import io.xeres.common.id.Id; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggingSystem; import java.net.URISyntaxException; import java.nio.file.Path; import java.util.Objects; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class FileServiceTest { @Mock private FileNotificationService fileNotificationService; @Mock private HashBloomFilter hashBloomFilter; @Mock private DataDirConfiguration dataDirConfiguration; @Mock private FileRepository fileRepository; @Mock private ShareRepository shareRepository; @InjectMocks private FileService fileService; @BeforeAll static void setErrorLogging() { LoggingSystem.get(ClassLoader.getSystemClassLoader()).setLogLevel("io.xeres", LogLevel.DEBUG); } @Test void HashFile_Success() throws URISyntaxException { var ioBuffer = new byte[FileService.SMALL_FILE_SIZE]; // mmap (> 16 KB file) var hash = fileService.calculateFileHash(Path.of(Objects.requireNonNull(FileServiceTest.class.getResource("/image/leguman.jpg")).toURI()), ioBuffer); assertNotNull(hash); assertEquals("0f02355b1b1e9a22801dddd85ded59fe7301698d", Id.toString(hash.getBytes())); // non mmap (<= 16 KB file) hash = fileService.calculateFileHash(Path.of(Objects.requireNonNull(FileServiceTest.class.getResource("/upnp/routers/RT-AC87U.xml")).toURI()), ioBuffer); assertNotNull(hash); assertEquals("a045c2c987b55e6c29082ded01a9abf33ad4cf9d", Id.toString(hash.getBytes())); } @Test void ScanShare_Success() throws URISyntaxException { var share = ShareFakes.createShare(Path.of(Objects.requireNonNull(FileServiceTest.class.getResource("/image")).toURI())); fileService.scanShare(share); verify(fileNotificationService).startScanning(share); verify(fileNotificationService, times(2)).startScanningFile(any()); verify(fileNotificationService, times(2)).stopScanningFile(); verify(fileNotificationService).stopScanning(); } @Test void DeleteFile_SingleFile_Success() { // Root var fileRoot = FileFakes.createFile("C:\\", null); // Share var fileGreatGrandParent = FileFakes.createFile("share", fileRoot); var share = ShareFakes.createShare(fileGreatGrandParent); var fileGrandParent = FileFakes.createFile("media", fileGreatGrandParent); var fileParent = FileFakes.createFile("images", fileGrandParent); var file = FileFakes.createFile("foobar.jpg", fileParent); // C:\share\media\images\foobar.jpg when(fileRepository.countByParent(fileParent)).thenReturn(1); when(shareRepository.findShareByFile(fileParent)).thenReturn(Optional.empty()); when(fileRepository.countByParent(fileGrandParent)).thenReturn(1); when(shareRepository.findShareByFile(fileGrandParent)).thenReturn(Optional.empty()); when(fileRepository.countByParent(fileGreatGrandParent)).thenReturn(1); when(shareRepository.findShareByFile(fileGreatGrandParent)).thenReturn(Optional.of(share)); fileService.deleteFile(file); verify(fileRepository, never()).countByParent(file); verify(shareRepository, never()).findShareByFile(file); verify(fileRepository, times(1)).countByParent(fileParent); verify(shareRepository, times(1)).findShareByFile(fileParent); verify(fileRepository, times(1)).countByParent(fileGrandParent); verify(shareRepository, times(1)).findShareByFile(fileGrandParent); verify(fileRepository, times(1)).countByParent(fileGreatGrandParent); verify(shareRepository, times(1)).findShareByFile(fileGreatGrandParent); verify(fileRepository, never()).countByParent(fileRoot); verify(shareRepository, never()).findShareByFile(fileRoot); verify(fileRepository, never()).delete(file); verify(fileRepository, times(1)).delete(fileGrandParent); } @Test void DeleteFile_TwoFiles_Success() { // Root var fileRoot = FileFakes.createFile("C:\\", null); // Share var fileGreatGrandParent = FileFakes.createFile("share", fileRoot); var fileGrandParent = FileFakes.createFile("media", fileGreatGrandParent); var fileParent = FileFakes.createFile("images", fileGrandParent); var file = FileFakes.createFile("foobar.jpg", fileParent); // C:\share\media\images\foobar.jpg and plop.jpg when(fileRepository.countByParent(fileParent)).thenReturn(2); fileService.deleteFile(file); verify(fileRepository, never()).countByParent(file); verify(shareRepository, never()).findShareByFile(file); verify(fileRepository, times(1)).countByParent(fileParent); verify(shareRepository, never()).findShareByFile(fileParent); verify(fileRepository, never()).countByParent(fileGrandParent); verify(shareRepository, never()).findShareByFile(fileGrandParent); verify(fileRepository, never()).countByParent(fileGreatGrandParent); verify(shareRepository, never()).findShareByFile(fileGreatGrandParent); verify(fileRepository, never()).countByParent(fileRoot); verify(shareRepository, never()).findShareByFile(fileRoot); verify(fileRepository, times(1)).delete(file); verify(fileRepository, never()).delete(fileGrandParent); } @Test void DeleteFile_SingleFileButAnotherUpper_Success() { // Root var fileRoot = FileFakes.createFile("C:\\", null); // Share var fileGreatGrandParent = FileFakes.createFile("share", fileRoot); var fileGrandParent = FileFakes.createFile("media", fileGreatGrandParent); var fileParent = FileFakes.createFile("images", fileGrandParent); FileFakes.createFile("videos", fileGrandParent); var file = FileFakes.createFile("foobar.jpg", fileParent); // C:\share\media\images\foobar.jpg and plop.avi is in media\videos when(fileRepository.countByParent(fileParent)).thenReturn(1); when(shareRepository.findShareByFile(fileParent)).thenReturn(Optional.empty()); when(fileRepository.countByParent(fileGrandParent)).thenReturn(2); fileService.deleteFile(file); verify(fileRepository, never()).countByParent(file); verify(shareRepository, never()).findShareByFile(file); verify(fileRepository, times(1)).countByParent(fileParent); verify(shareRepository, times(1)).findShareByFile(fileParent); verify(fileRepository, times(1)).countByParent(fileGrandParent); verify(shareRepository, never()).findShareByFile(fileGrandParent); verify(fileRepository, never()).countByParent(fileGreatGrandParent); verify(shareRepository, never()).findShareByFile(fileGreatGrandParent); verify(fileRepository, never()).countByParent(fileRoot); verify(shareRepository, never()).findShareByFile(fileRoot); verify(fileRepository, never()).delete(file); verify(fileRepository, times(1)).delete(fileParent); verify(fileRepository, never()).delete(fileGrandParent); } @Test void DeleteFile_SingleFileButNotShare_Success() { // Root var fileRoot = FileFakes.createFile("C:\\", null); // Share var fileParent = FileFakes.createFile("share", fileRoot); var share = ShareFakes.createShare(fileParent); var file = FileFakes.createFile("foobar.jpg", fileParent); // C:\share\foobar.jpg when(fileRepository.countByParent(fileParent)).thenReturn(1); when(shareRepository.findShareByFile(fileParent)).thenReturn(Optional.of(share)); fileService.deleteFile(file); verify(fileRepository, never()).countByParent(file); verify(shareRepository, never()).findShareByFile(file); verify(fileRepository, times(1)).countByParent(fileParent); verify(shareRepository, times(1)).findShareByFile(fileParent); verify(fileRepository, never()).countByParent(fileRoot); verify(shareRepository, never()).findShareByFile(fileRoot); verify(fileRepository, times(1)).delete(file); verify(fileRepository, never()).delete(fileParent); verify(fileRepository, never()).delete(fileRoot); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/shell/HistoryTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.shell; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; class HistoryTest { @Test void Add_And_Navigate() { var history = new History(20); history.addCommand("foo"); history.addCommand("bar"); assertEquals("bar", history.getPrevious()); assertEquals("foo", history.getPrevious()); assertEquals("foo", history.getPrevious()); assertEquals("bar", history.getNext()); assertNull(history.getNext()); assertEquals("bar", history.getPrevious()); } } ================================================ FILE: app/src/test/java/io/xeres/app/service/shell/ShellServiceTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.service.shell; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; import static io.xeres.common.mui.ShellAction.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @ExtendWith(MockitoExtension.class) class ShellServiceTest { @InjectMocks private ShellService shellService; @Test void translateCommandLine_OK() { var res = ShellService.translateCommandline("hello world"); assertEquals("hello", res[0]); assertEquals("world", res[1]); } @Test void sendCommand_Cls() { var res = shellService.sendCommand("cls"); assertEquals(CLS, res.getAction()); } @Test void sendCommand_Alias_Clear() { var res = shellService.sendCommand("clear"); assertEquals(CLS, res.getAction()); } @Test void sendCommand_Exit() { var res = shellService.sendCommand("exit"); assertEquals(EXIT, res.getAction()); } @Test void sendCommand_Help() { var res = shellService.sendCommand("help"); assertEquals(SUCCESS, res.getAction()); assertTrue(res.getOutput().contains("Available commands:")); } @Test void sendCommand_Unknown() { var res = shellService.sendCommand("yabadabadoo"); assertEquals(UNKNOWN_COMMAND, res.getAction()); } @Test void sendCommand_NoOp() { var res = shellService.sendCommand(""); assertEquals(NO_OP, res.getAction()); } } ================================================ FILE: app/src/test/java/io/xeres/app/util/OsUtilsTest.java ================================================ package io.xeres.app.util; import io.xeres.common.util.OsUtils; import org.apache.commons.lang3.SystemUtils; import org.junit.jupiter.api.Test; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class OsUtilsTest { @Test void IsFileSystemCaseSensitive_Success() { var tempDir = System.getProperty("java.io.tmpdir"); var isCaseSensitive = OsUtils.isFileSystemCaseSensitive(Path.of(tempDir)); if (SystemUtils.IS_OS_WINDOWS) { assertFalse(isCaseSensitive); } else if (SystemUtils.IS_OS_LINUX) { assertTrue(isCaseSensitive); } else if (SystemUtils.IS_OS_MAC) { assertFalse(isCaseSensitive); } // Don't care on other operating systems } } ================================================ FILE: app/src/test/java/io/xeres/app/util/expression/ExpressionCriteriaTest.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.FileFakes; import io.xeres.app.database.repository.FileRepository; import io.xeres.app.service.file.FileService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.web.WebAppConfiguration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.boot.test.context.SpringBootTest.UseMainMethod.ALWAYS; @SpringBootTest(args = "--no-gui", useMainMethod = ALWAYS) @WebAppConfiguration // see https://stackoverflow.com/questions/73575360/attribute-javax-websocket-server-servercontainer-not-found-in-servletcontext-w class ExpressionCriteriaTest { @Autowired private FileService fileService; @Autowired private FileRepository fileRepository; @Test void Name() { var file = FileFakes.createFile("The Great Race.mkv"); fileRepository.save(file); var expressionEqualsOk = new NameExpression(StringExpression.Operator.EQUALS, "The Great Race.mkv", true); var expressionEqualsNoCaseOk = new NameExpression(StringExpression.Operator.EQUALS, "the great race.mkv", false); var expressionEqualsCaseFail = new NameExpression(StringExpression.Operator.EQUALS, "the great race.mkv", true); var expressionEqualsFail = new NameExpression(StringExpression.Operator.EQUALS, "The Great Race", false); var expressionAllOk = new NameExpression(StringExpression.Operator.CONTAINS_ALL, "Race Great", true); var expressionAllNoCaseOk = new NameExpression(StringExpression.Operator.CONTAINS_ALL, "race great", false); var expressionAllCaseFail = new NameExpression(StringExpression.Operator.CONTAINS_ALL, "race great", true); var expressionAllFail = new NameExpression(StringExpression.Operator.CONTAINS_ALL, "Race Great Foo", false); var expressionAnyOk = new NameExpression(StringExpression.Operator.CONTAINS_ANY, "Race Stuff", true); var expressionAnyNoCaseOk = new NameExpression(StringExpression.Operator.CONTAINS_ANY, "race stuff", false); var expressionAnyCaseFail = new NameExpression(StringExpression.Operator.CONTAINS_ANY, "race stuff", true); var expressionAnyFail = new NameExpression(StringExpression.Operator.CONTAINS_ANY, "Foo Bar Plop", false); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsNoCaseOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionEqualsCaseFail)).isEmpty()); assertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionAllOk)).getFirst().getName()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionAllNoCaseOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionAllCaseFail)).isEmpty()); assertTrue(fileService.searchFiles(List.of(expressionAllFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionAnyOk)).getFirst().getName()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionAnyNoCaseOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionAnyCaseFail)).isEmpty()); assertTrue(fileService.searchFiles(List.of(expressionAnyFail)).isEmpty()); fileRepository.delete(file); } @Test void Path() { var parent = FileFakes.createFile("Movies"); fileRepository.save(parent); var file = FileFakes.createFile("The Great Race.mkv", parent); fileRepository.save(file); var expressionNotSupported = new PathExpression(StringExpression.Operator.EQUALS, "Movies", false); assertTrue(fileService.searchFiles(List.of(expressionNotSupported)).isEmpty()); fileRepository.delete(parent); fileRepository.delete(file); } @Test void Extension() { var file = FileFakes.createFile("The Empty Bin.EXE"); fileRepository.save(file); var expressionEqualsOk = new ExtensionExpression(StringExpression.Operator.EQUALS, "EXE", true); var expressionEqualsNoCaseOk = new ExtensionExpression(StringExpression.Operator.EQUALS, "exe", false); var expressionEqualsCaseFail = new ExtensionExpression(StringExpression.Operator.EQUALS, "exe", true); var expressionEqualsFail = new ExtensionExpression(StringExpression.Operator.EQUALS, "bin", false); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsNoCaseOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionEqualsCaseFail)).isEmpty()); assertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty()); fileRepository.delete(file); } // @Test // void ExpressionCriteria_Hash() // { // var file = FileFakes.createFile("Stuff", 1024, Instant.now(), Sha1SumFakes.createSha1Sum()); // fileRepository.save(file); // // var expressionEqualsOk = new HashExpression(StringExpression.Operator.EQUALS, file.getHash().toString()); // // assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName()); // // fileRepository.delete(file); // } @Test void Date() { var file = FileFakes.createFile("Foobar", 1024, Instant.now().truncatedTo(ChronoUnit.SECONDS)); fileRepository.save(file); var expressionEqualsOk = new DateExpression(RelationalExpression.Operator.EQUALS, (int) file.getModified().getEpochSecond(), 0); var expressionInRange = new DateExpression(RelationalExpression.Operator.IN_RANGE, (int) file.getModified().getEpochSecond() - 1, (int) file.getModified().getEpochSecond() + 1); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionInRange)).getFirst().getName()); fileRepository.delete(file); } @Test void Size() { var file = FileFakes.createFile("foobar", 1024); fileRepository.save(file); var expressionEqualsOk = new SizeExpression(RelationalExpression.Operator.EQUALS, 1024, 0); var expressionEqualsFail = new SizeExpression(RelationalExpression.Operator.EQUALS, 1025, 0); var expressionGreaterThanOrEqualsOk = new SizeExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, 1024, 0); var expressionGreaterThanOrEqualsFail = new SizeExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, 1023, 0); var expressionGreaterThanOk = new SizeExpression(RelationalExpression.Operator.GREATER_THAN, 1025, 0); var expressionGreaterThanFail = new SizeExpression(RelationalExpression.Operator.GREATER_THAN, 1024, 0); var expressionLesserThanOrEqualsOk = new SizeExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, 1024, 0); var expressionLesserThanOrEqualsFail = new SizeExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, 1025, 0); var expressionLesserThanOk = new SizeExpression(RelationalExpression.Operator.LESSER_THAN, 1023, 0); var expressionLesserThanFail = new SizeExpression(RelationalExpression.Operator.LESSER_THAN, 1024, 0); var expressionInRangeOk = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1023, 1025); var expressionInRangeFail = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1025, 1026); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOrEqualsOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionGreaterThanOrEqualsFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionGreaterThanFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOrEqualsOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionLesserThanOrEqualsFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionLesserThanFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionInRangeOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionInRangeFail)).isEmpty()); fileRepository.delete(file); } @Test void SizeMb() { var file = FileFakes.createFile("foobar", 1_000_000_000_000L); fileRepository.save(file); var expressionEqualsOk = new SizeMbExpression(RelationalExpression.Operator.EQUALS, (int) (1_000_000_000_000L >> 20), 0); var expressionEqualsFail = new SizeMbExpression(RelationalExpression.Operator.EQUALS, (int) (1_000_001_000_000L >> 20), 0); var expressionGreaterThanOrEqualsOk = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, (int) (1_000_000_000_000L >> 20), 0); var expressionGreaterThanOrEqualsFail = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, (int) (900_000_000_000L >> 20), 0); var expressionGreaterThanOk = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN, (int) (1_000_001_000_000L >> 20), 0); var expressionGreaterThanFail = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN, (int) (999_000_000_000L >> 20), 0); // Note that 1_000_000_000_000 should fail, but it doesn't because of the lost precision var expressionLesserThanOrEqualsOk = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, (int) (1_000_000_000_000L >> 20), 0); var expressionLesserThanOrEqualsFail = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, (int) (1_000_001_000_000L >> 20), 0); var expressionLesserThanOk = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN, (int) (1_000_000_000_000L >> 20), 0); var expressionLesserThanFail = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN, (int) (1_000_001_000_000L >> 20), 0); var expressionInRangeOk = new SizeMbExpression(RelationalExpression.Operator.IN_RANGE, (int) (900_000_000_000L >> 20), (int) (1_000_001_000_000L >> 20)); var expressionInRangeFail = new SizeMbExpression(RelationalExpression.Operator.IN_RANGE, (int) (1_000_001_000_000L >> 20), (int) (2_000_000_000_000L >> 20)); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOrEqualsOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionGreaterThanOrEqualsFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionGreaterThanFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOrEqualsOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionLesserThanOrEqualsFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionLesserThanFail)).isEmpty()); assertEquals(file.getName(), fileService.searchFiles(List.of(expressionInRangeOk)).getFirst().getName()); assertTrue(fileService.searchFiles(List.of(expressionInRangeFail)).isEmpty()); fileRepository.delete(file); } @Test void Popularity_NotSupported() { var file = FileFakes.createFile("foobar"); fileRepository.save(file); var expressionEqualsNotSupported = new PopularityExpression(RelationalExpression.Operator.EQUALS, 1, 0); assertTrue(fileService.searchFiles(List.of(expressionEqualsNotSupported)).isEmpty()); fileRepository.delete(file); } } ================================================ FILE: app/src/test/java/io/xeres/app/util/expression/ExpressionMapperTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.FileFakes; import io.xeres.app.xrs.service.turtle.item.TurtleRegExpSearchRequestItem; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.*; class ExpressionMapperTest { @Test void Name() { List tokens = new ArrayList<>(); List ints = new ArrayList<>(); List strings = new ArrayList<>(); tokens.add((byte) 4); // Name ints.add(1); // Contains all ints.add(1); // Case-insensitive ints.add(2); // 2 words strings.add("foo"); // word 1 strings.add("bar"); // word 2 var item = new TurtleRegExpSearchRequestItem(tokens, ints, strings); var expressions = ExpressionMapper.toExpressions(item); assertEquals(1, expressions.size()); var expression = expressions.getFirst(); assertInstanceOf(NameExpression.class, expression); var fileValid = FileFakes.createFile("foo bar"); var fileInvalid = FileFakes.createFile("foo"); assertTrue(expression.evaluate(fileValid)); assertFalse(expression.evaluate(fileInvalid)); } @Test void Compound_NameAndSize() { List tokens = new ArrayList<>(); List ints = new ArrayList<>(); List strings = new ArrayList<>(); tokens.add((byte) 7); // Compound ints.add(0); // And tokens.add((byte) 4); // Name ints.add(2); // Equals ints.add(1); // Case-insensitive ints.add(1); // 1 word strings.add("foo"); // word 1 tokens.add((byte) 2); // Size ints.add(5); // In range ints.add(1024); // Min value ints.add(2048); // Max value var item = new TurtleRegExpSearchRequestItem(tokens, ints, strings); var expressions = ExpressionMapper.toExpressions(item); assertEquals(1, expressions.size()); var expression = expressions.getFirst(); assertInstanceOf(CompoundExpression.class, expression); var fileEntryValid = FileFakes.createFile("foo", 1500); var fileEntryInvalid1 = FileFakes.createFile("bar", 1500); var fileEntryInvalid2 = FileFakes.createFile("foo", 3000); var fileEntryInvalid3 = FileFakes.createFile("bar", 3000); assertTrue(expression.evaluate(fileEntryValid)); assertFalse(expression.evaluate(fileEntryInvalid1)); assertFalse(expression.evaluate(fileEntryInvalid2)); assertFalse(expression.evaluate(fileEntryInvalid3)); } @Test void Linearize() { var nameExpression = new NameExpression(StringExpression.Operator.EQUALS, "foo", false); var sizeExpression = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1024, 2048); var compoundExpression = new CompoundExpression(CompoundExpression.Operator.AND, nameExpression, sizeExpression); List tokens = new ArrayList<>(); List ints = new ArrayList<>(); List strings = new ArrayList<>(); compoundExpression.linearize(tokens, ints, strings); assertEquals(3, tokens.size()); assertEquals((byte) 7, tokens.getFirst()); // Compound assertEquals((byte) 4, tokens.get(1)); // Name assertEquals((byte) 2, tokens.get(2)); // Size assertEquals(7, ints.size()); assertEquals(0, ints.getFirst()); // AND assertEquals(2, ints.get(1)); // Equals assertEquals(1, ints.get(2)); // Ignore case assertEquals(1, ints.get(3)); // 1 string assertEquals(5, ints.get(4)); // In range assertEquals(1024, ints.get(5)); // low value assertEquals(2048, ints.get(6)); // high value assertEquals(1, strings.size()); assertEquals("foo", strings.getFirst()); // 1 string } } ================================================ FILE: app/src/test/java/io/xeres/app/util/expression/ExpressionTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.util.expression; import io.xeres.app.database.model.file.FileFakes; import io.xeres.testutils.Sha1SumFakes; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class ExpressionTest { @Test void Name_Equals() { var expression = new NameExpression(StringExpression.Operator.EQUALS, "foobar", false); var fileCorrect = FileFakes.createFile("foobar"); var fileWrong = FileFakes.createFile("blahblah"); assertTrue(expression.evaluate(fileCorrect)); assertFalse(expression.evaluate(fileWrong)); } @Test void Name_Equals_CaseSensitive() { var expression = new NameExpression(StringExpression.Operator.EQUALS, "foobar", true); var fileCorrect = FileFakes.createFile("foobar"); var fileWrong = FileFakes.createFile("FooBar"); assertTrue(expression.evaluate(fileCorrect)); assertFalse(expression.evaluate(fileWrong)); } @Test void Name_ContainsAll() { var expression = new NameExpression(StringExpression.Operator.CONTAINS_ALL, "foo bar plop", false); var fileCorrect = FileFakes.createFile("foo bar plop"); var fileWrong = FileFakes.createFile("foo bar"); assertTrue(expression.evaluate(fileCorrect)); assertFalse(expression.evaluate(fileWrong)); } @Test void Name_ContainsAll_CaseSensitive() { var expression = new NameExpression(StringExpression.Operator.CONTAINS_ALL, "foo bar plop", true); var fileCorrect = FileFakes.createFile("foo bar plop"); var fileWrong = FileFakes.createFile("Foo bar plop"); assertTrue(expression.evaluate(fileCorrect)); assertFalse(expression.evaluate(fileWrong)); } @Test void Name_ContainsAny() { var expression = new NameExpression(StringExpression.Operator.CONTAINS_ANY, "foo bar plop", false); var fileCorrect1 = FileFakes.createFile("foo"); var fileCorrect2 = FileFakes.createFile("bar"); var fileCorrect3 = FileFakes.createFile("plop"); var fileWrong = FileFakes.createFile("none niet nada"); assertTrue(expression.evaluate(fileCorrect1)); assertTrue(expression.evaluate(fileCorrect2)); assertTrue(expression.evaluate(fileCorrect3)); assertFalse(expression.evaluate(fileWrong)); } @Test void Name_ContainsAny_CaseSensitive() { var expression = new NameExpression(StringExpression.Operator.CONTAINS_ANY, "foo bar plop", true); var fileCorrect1 = FileFakes.createFile("foo"); var fileWrong1 = FileFakes.createFile("Bar"); var fileWrong2 = FileFakes.createFile("Plop"); var fileWrong3 = FileFakes.createFile("none niet nada"); assertTrue(expression.evaluate(fileCorrect1)); assertFalse(expression.evaluate(fileWrong1)); assertFalse(expression.evaluate(fileWrong2)); assertFalse(expression.evaluate(fileWrong3)); } @Test void Size_Equals() { var expression = new SizeExpression(RelationalExpression.Operator.EQUALS, 1024, 0); var fileCorrect = FileFakes.createFile("foo", 1024); var fileWrong = FileFakes.createFile("foo", 512); assertTrue(expression.evaluate(fileCorrect)); assertFalse(expression.evaluate(fileWrong)); } @Test void Size_GreaterThanOrEquals() { var expression = new SizeExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, 1024, 0); var fileCorrect1 = FileFakes.createFile("foo", 1024); var fileCorrect2 = FileFakes.createFile("foo", 1023); var fileWrong = FileFakes.createFile("foo", 1025); assertTrue(expression.evaluate(fileCorrect1)); assertTrue(expression.evaluate(fileCorrect2)); assertFalse(expression.evaluate(fileWrong)); } @Test void Size_GreaterThan() { var expression = new SizeExpression(RelationalExpression.Operator.GREATER_THAN, 1024, 0); var fileCorrect1 = FileFakes.createFile("foo", 1023); var fileWrong1 = FileFakes.createFile("foo", 1024); var fileWrong2 = FileFakes.createFile("foo", 1025); assertTrue(expression.evaluate(fileCorrect1)); assertFalse(expression.evaluate(fileWrong1)); assertFalse(expression.evaluate(fileWrong2)); } @Test void Size_LesserThanOrEquals() { var expression = new SizeExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, 1024, 0); var fileCorrect1 = FileFakes.createFile("foo", 1024); var fileCorrect2 = FileFakes.createFile("foo", 1025); var fileWrong = FileFakes.createFile("foo", 512); assertTrue(expression.evaluate(fileCorrect1)); assertTrue(expression.evaluate(fileCorrect2)); assertFalse(expression.evaluate(fileWrong)); } @Test void Size_LesserThan() { var expression = new SizeExpression(RelationalExpression.Operator.LESSER_THAN, 1024, 0); var fileCorrect1 = FileFakes.createFile("foo", 1025); var fileWrong1 = FileFakes.createFile("foo", 1024); var fileWrong2 = FileFakes.createFile("foo", 512); assertTrue(expression.evaluate(fileCorrect1)); assertFalse(expression.evaluate(fileWrong1)); assertFalse(expression.evaluate(fileWrong2)); } @Test void Size_InRange() { var expression = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1024, 2048); var fileCorrect1 = FileFakes.createFile("foo", 1024); var fileCorrect2 = FileFakes.createFile("foo", 2048); var fileCorrect3 = FileFakes.createFile("foo", 1536); var fileWrong1 = FileFakes.createFile("foo", 1023); var fileWrong2 = FileFakes.createFile("foo", 2049); assertTrue(expression.evaluate(fileCorrect1)); assertTrue(expression.evaluate(fileCorrect2)); assertTrue(expression.evaluate(fileCorrect3)); assertFalse(expression.evaluate(fileWrong1)); assertFalse(expression.evaluate(fileWrong2)); } @Test void Date() { var expression = new DateExpression(RelationalExpression.Operator.EQUALS, 1000, 0); var fileCorrect = FileFakes.createFile("foo", 1024, Instant.ofEpochSecond(1000)); var fileWrong = FileFakes.createFile("foo", 1024, Instant.ofEpochSecond(2000)); assertTrue(expression.evaluate(fileCorrect)); assertFalse(expression.evaluate(fileWrong)); } @Test void Popularity() { // Popularity is not implemented (there's no "popularity" in a local file), so it's always zero var expression1 = new PopularityExpression(RelationalExpression.Operator.EQUALS, 1, 0); var expression2 = new PopularityExpression(RelationalExpression.Operator.EQUALS, 0, 0); var file = FileFakes.createFile("foo"); assertFalse(expression1.evaluate(file)); assertTrue(expression2.evaluate(file)); } @Test void SizeMb() { var expression = new SizeMbExpression(RelationalExpression.Operator.EQUALS, (int) (1_000_000_000_000L >> 20), 0); var fileCorrect1 = FileFakes.createFile("foo", 1_000_000_000_000L); var fileCorrect2 = FileFakes.createFile("foo", 1_000_000_000_001L); var fileWrong = FileFakes.createFile("foo", 1_000_001_000_000L); assertTrue(expression.evaluate(fileCorrect1)); assertTrue(expression.evaluate(fileCorrect2)); assertFalse(expression.evaluate(fileWrong)); } @Test void Path() { // Path is not implemented because it's very difficult to do for no real gain var expression = new PathExpression(StringExpression.Operator.CONTAINS_ANY, "coolstuff", false); var file = FileFakes.createFile("foo"); assertFalse(expression.evaluate(file)); } @Test void Extension() { var expression = new ExtensionExpression(StringExpression.Operator.CONTAINS_ANY, "exe com", false); var fileCorrect1 = FileFakes.createFile("foobar.exe"); var fileCorrect2 = FileFakes.createFile("foobar.com"); var fileWrong1 = FileFakes.createFile("foobar.bin"); var fileWrong2 = FileFakes.createFile("The.Exe.bin"); assertTrue(expression.evaluate(fileCorrect1)); assertTrue(expression.evaluate(fileCorrect2)); assertFalse(expression.evaluate(fileWrong1)); assertFalse(expression.evaluate(fileWrong2)); } @Test void Hash() { var hash1 = Sha1SumFakes.createSha1Sum(); var hash2 = Sha1SumFakes.createSha1Sum(); var expression = new HashExpression(StringExpression.Operator.EQUALS, hash1.toString()); var fileCorrect = FileFakes.createFile("foobar", 0, null, hash1); var fileWrong = FileFakes.createFile("foobar", 0, null, hash2); assertTrue(expression.evaluate(fileCorrect)); assertFalse(expression.evaluate(fileWrong)); } @Test void Compound_AND() { var left = new NameExpression(StringExpression.Operator.EQUALS, "foo", false); var right = new SizeExpression(RelationalExpression.Operator.EQUALS, 1000, 0); var compound = new CompoundExpression(CompoundExpression.Operator.AND, left, right); var fileCorrect = FileFakes.createFile("foo", 1000); var fileWrong = FileFakes.createFile("foo", 1001); assertTrue(compound.evaluate(fileCorrect)); assertFalse(compound.evaluate(fileWrong)); } @Test void Compound_OR() { var left = new NameExpression(StringExpression.Operator.EQUALS, "foo", false); var right = new SizeExpression(RelationalExpression.Operator.EQUALS, 1000, 0); var compound = new CompoundExpression(CompoundExpression.Operator.OR, left, right); var fileCorrectAnd = FileFakes.createFile("foo", 1000); var fileCorrectOr = FileFakes.createFile("foo", 1001); var fileWrong = FileFakes.createFile("bar", 1001); assertTrue(compound.evaluate(fileCorrectAnd)); assertTrue(compound.evaluate(fileCorrectOr)); assertFalse(compound.evaluate(fileWrong)); } @Test void Compound_XOR() { var left = new NameExpression(StringExpression.Operator.EQUALS, "foo", false); var right = new SizeExpression(RelationalExpression.Operator.EQUALS, 1000, 0); var compound = new CompoundExpression(CompoundExpression.Operator.XOR, left, right); var fileCorrectOr = FileFakes.createFile("foo", 1001); var fileWrongAnd = FileFakes.createFile("foo", 1000); var fileWrongBoth = FileFakes.createFile("bar", 1001); assertTrue(compound.evaluate(fileCorrectOr)); assertFalse(compound.evaluate(fileWrongAnd)); assertFalse(compound.evaluate(fileWrongBoth)); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/common/SecurityKeyTest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.common; import io.xeres.common.id.GxsId; import io.xeres.common.id.Id; import org.junit.jupiter.api.Test; import java.util.EnumSet; import java.util.List; import static io.xeres.app.xrs.common.SecurityKey.Flags.DISTRIBUTION_ADMIN; import static io.xeres.app.xrs.common.SecurityKey.Flags.TYPE_PUBLIC_ONLY; import static org.junit.jupiter.api.Assertions.assertEquals; class SecurityKeyTest { @Test void CompareTo_Success() { var securityKey1 = new SecurityKey(new GxsId(Id.toBytes("11111111111111111111111111111111")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]); var securityKey2 = new SecurityKey(new GxsId(Id.toBytes("22222222222222222222222222222222")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]); var securityKey3 = new SecurityKey(new GxsId(Id.toBytes("33333333333333333333333333333333")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]); var securityKey4 = new SecurityKey(new GxsId(Id.toBytes("44444444444444444444444444444444")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]); var securityKey5 = new SecurityKey(new GxsId(Id.toBytes("55555555555555555555555555555555")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]); var unorderedList = List.of(securityKey3, securityKey1, securityKey4, securityKey2, securityKey5); var orderedList = unorderedList.stream() .sorted() .toList(); assertEquals(securityKey1, orderedList.get(0)); assertEquals(securityKey2, orderedList.get(1)); assertEquals(securityKey3, orderedList.get(2)); assertEquals(securityKey4, orderedList.get(3)); assertEquals(securityKey5, orderedList.get(4)); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/item/ItemHeaderTest.java ================================================ package io.xeres.app.xrs.item; import io.netty.buffer.Unpooled; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; class ItemHeaderTest { @Test void ReadHeader_Success() { var buf = Unpooled.wrappedBuffer(new byte[]{2, 8, 8, 3, 0, 0, 0, 1}); assertDoesNotThrow(() -> ItemHeader.readHeader(buf, 0x808, 3)); } @Test void ReadHeader_WrongVersion() { var buf = Unpooled.wrappedBuffer(new byte[]{1, 8, 8, 3, 0, 0, 0, 1}); assertThrows(IllegalArgumentException.class, () -> ItemHeader.readHeader(buf, 0x808, 3), "Packet version is not 0x2"); } @Test void ReadHeader_WrongType() { var buf = Unpooled.wrappedBuffer(new byte[]{2, 8, 8, 3, 0, 0, 0, 1}); assertThrows(IllegalArgumentException.class, () -> ItemHeader.readHeader(buf, 0x807, 3), "Packet type is not 2055"); } @Test void ReadHeader_WrongSubtype() { var buf = Unpooled.wrappedBuffer(new byte[]{2, 8, 8, 3, 0, 0, 0, 1}); assertThrows(IllegalArgumentException.class, () -> ItemHeader.readHeader(buf, 0x808, 4), "Packet subtype is not 4"); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/item/ItemPriorityTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.item; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.item.ItemPriority.*; import static org.junit.jupiter.api.Assertions.assertEquals; class ItemPriorityTest { @Test void Enum_Value_Fixed() { assertEquals(2, BACKGROUND.getPriority()); assertEquals(3, DEFAULT.getPriority()); assertEquals(5, NORMAL.getPriority()); assertEquals(6, HIGH.getPriority()); assertEquals(7, INTERACTIVE.getPriority()); assertEquals(8, IMPORTANT.getPriority()); assertEquals(9, REALTIME.getPriority()); assertEquals(7, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/item/ItemTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.item; import io.xeres.app.xrs.service.chat.item.ChatRoomMessageItem; import io.xeres.app.xrs.service.filetransfer.item.TurtleChunkCrcItem; import io.xeres.app.xrs.service.filetransfer.item.TurtleFileDataItem; import io.xeres.testutils.Sha1SumFakes; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; class ItemTest { @Test void Bounce_Clone() { var bounce = new ChatRoomMessageItem("Test"); var bounceClone = bounce.clone(); assertEquals(bounce.getMessage(), bounceClone.getMessage()); assertEquals(bounce.getFlags(), bounceClone.getFlags()); } @Test void TurtleChunkCrcItem_Clone() { var sha1Sum = Sha1SumFakes.createSha1Sum(); var crcItem = new TurtleChunkCrcItem(1, sha1Sum); var crcClone = crcItem.clone(); assertEquals(crcItem.getChunkNumber(), crcClone.getChunkNumber()); assertArrayEquals(crcItem.getChecksum().getBytes(), crcClone.getChecksum().getBytes()); } @Test void TurtleFileDataItem_Clone() { byte[] data = {1, 2, 3}; var turtleFileDataItem = new TurtleFileDataItem(1, data); var turtleFileDataItemClone = turtleFileDataItem.clone(); assertEquals(turtleFileDataItem.getChunkOffset(), turtleFileDataItemClone.getChunkOffset()); assertArrayEquals(turtleFileDataItem.getChunkData(), turtleFileDataItemClone.getChunkData()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/serialization/SerialAll.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.xeres.common.id.LocationIdentifier; import java.math.BigInteger; import java.util.EnumSet; import java.util.List; import java.util.Map; public class SerialAll { @RsSerialized private int intPrimitiveField; @RsSerialized private Integer integerField; @RsSerialized private short shortPrimitiveField; @RsSerialized private Short shortField; @RsSerialized private byte bytePrimitiveField; @RsSerialized private Byte byteField; @RsSerialized private long longPrimitiveField; @RsSerialized private Long longField; @RsSerialized private float floatPrimitiveField; @RsSerialized private Float floatField; @RsSerialized private double doublePrimitiveField; @RsSerialized private Double doubleField; @RsSerialized private boolean booleanPrimitiveField; @RsSerialized private Boolean booleanField; @RsSerialized private byte[] bytes; @RsSerialized private BigInteger bigInteger; @RsSerialized private LocationIdentifier locationIdentifier; @RsSerialized private List stringList; @RsSerialized private Map stringMap; @RsSerialized private SerialEnum serialEnum; @RsSerialized private EnumSet enumSet; @RsSerialized(fieldSize = FieldSize.SHORT) private EnumSet enumSetShort; @RsSerialized(fieldSize = FieldSize.BYTE) private EnumSet enumSetByte; @RsSerialized(tlvType = TlvType.STR_NAME) private String tlvName; public int getIntPrimitiveField() { return intPrimitiveField; } public void setIntPrimitiveField(int intPrimitiveField) { this.intPrimitiveField = intPrimitiveField; } public Integer getIntegerField() { return integerField; } public void setIntegerField(Integer integerField) { this.integerField = integerField; } public short getShortPrimitiveField() { return shortPrimitiveField; } public void setShortPrimitiveField(short shortPrimitiveField) { this.shortPrimitiveField = shortPrimitiveField; } public Short getShortField() { return shortField; } public void setShortField(Short shortField) { this.shortField = shortField; } public byte getBytePrimitiveField() { return bytePrimitiveField; } public void setBytePrimitiveField(byte bytePrimitiveField) { this.bytePrimitiveField = bytePrimitiveField; } public Byte getByteField() { return byteField; } public void setByteField(Byte byteField) { this.byteField = byteField; } public long getLongPrimitiveField() { return longPrimitiveField; } public void setLongPrimitiveField(long longPrimitiveField) { this.longPrimitiveField = longPrimitiveField; } public Long getLongField() { return longField; } public void setLongField(Long longField) { this.longField = longField; } public float getFloatPrimitiveField() { return floatPrimitiveField; } public void setFloatPrimitiveField(float floatPrimitiveField) { this.floatPrimitiveField = floatPrimitiveField; } public Float getFloatField() { return floatField; } public void setFloatField(Float floatField) { this.floatField = floatField; } public double getDoublePrimitiveField() { return doublePrimitiveField; } public void setDoublePrimitiveField(double doublePrimitiveField) { this.doublePrimitiveField = doublePrimitiveField; } public Double getDoubleField() { return doubleField; } public void setDoubleField(Double doubleField) { this.doubleField = doubleField; } public boolean isBooleanPrimitiveField() { return booleanPrimitiveField; } public void setBooleanPrimitiveField(boolean booleanPrimitiveField) { this.booleanPrimitiveField = booleanPrimitiveField; } public Boolean getBooleanField() { return booleanField; } public void setBooleanField(Boolean booleanField) { this.booleanField = booleanField; } public byte[] getBytes() { return bytes; } public void setBytes(byte[] bytes) { this.bytes = bytes; } public BigInteger getBigInteger() { return bigInteger; } public void setBigInteger(BigInteger bigInteger) { this.bigInteger = bigInteger; } public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } public void setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; } public List getStringList() { return stringList; } public void setStringList(List stringList) { this.stringList = stringList; } public Map getStringMap() { return stringMap; } public void setStringMap(Map stringMap) { this.stringMap = stringMap; } public SerialEnum getSerialEnum() { return serialEnum; } public void setSerialEnum(SerialEnum serialEnum) { this.serialEnum = serialEnum; } public EnumSet getEnumSet() { return enumSet; } public void setEnumSet(EnumSet enumSet) { this.enumSet = enumSet; } public String getTlvName() { return tlvName; } public void setTlvName(String tlvName) { this.tlvName = tlvName; } public EnumSet getEnumSetShort() { return enumSetShort; } public void setEnumSetShort(EnumSet enumSetShort) { this.enumSetShort = enumSetShort; } public EnumSet getEnumSetByte() { return enumSetByte; } public void setEnumSetByte(EnumSet enumSetByte) { this.enumSetByte = enumSetByte; } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/serialization/SerialEnum.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; public enum SerialEnum { ONE, TWO, THREE, FOUR } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/serialization/SerialList.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import java.util.List; public class SerialList { @RsSerialized private List list; public List getList() { return list; } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/serialization/SerialMap.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import java.util.Map; public class SerialMap { @RsSerialized private Map map; public Map getMap() { return map; } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/serialization/SerializerTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.netty.buffer.Unpooled; import io.xeres.app.database.model.gxs.ForumGroupItemFakes; import io.xeres.app.database.model.gxs.ForumMessageItemFakes; import io.xeres.app.database.model.gxs.IdentityGroupItemFakes; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.net.protocol.PeerAddress; import io.xeres.app.xrs.common.Signature; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.MsgId; import io.xeres.common.id.ProfileFingerprint; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import java.math.BigInteger; import java.util.*; import static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE; import static org.junit.jupiter.api.Assertions.*; class SerializerTest { @ParameterizedTest @ValueSource(ints = {Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 5}) void Serialize_Int(int input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); assertEquals(4, size); assertEquals(input, buf.getInt(0)); var result = Serializer.deserializeInt(buf); assertEquals(input, result); buf.release(); } @ParameterizedTest @ValueSource(shorts = {Short.MIN_VALUE, Short.MAX_VALUE, 0, 5}) void Serialize_Short(short input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); assertEquals(2, size); assertEquals(input, buf.getShort(0)); var result = Serializer.deserializeShort(buf); assertEquals(input, result); buf.release(); } @ParameterizedTest @ValueSource(bytes = {Byte.MIN_VALUE, Byte.MAX_VALUE, 0, 5}) void Serialize_Byte(byte input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); assertEquals(1, size); assertEquals(input, buf.getByte(0)); var result = Serializer.deserializeByte(buf); assertEquals(input, result); buf.release(); } @ParameterizedTest @ValueSource(longs = {Long.MIN_VALUE, Long.MAX_VALUE, 0L, 5L}) void Serialize_Long(long input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); assertEquals(8, size); assertEquals(input, buf.getLong(0)); var result = Serializer.deserializeLong(buf); assertEquals(input, result); buf.release(); } @ParameterizedTest @ValueSource(floats = {Float.MIN_VALUE, Float.MAX_VALUE, 0f, 5f}) void Serialize_Float(float input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); assertEquals(4, size); assertEquals(input, buf.getFloat(0)); var result = Serializer.deserializeFloat(buf); assertEquals(input, result); buf.release(); } @ParameterizedTest @ValueSource(doubles = {Double.MIN_VALUE, Double.MAX_VALUE, 0.0, 5.0}) void Serialize_Double(double input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); assertEquals(8, size); assertEquals(input, buf.getDouble(0)); var result = Serializer.deserializeDouble(buf); assertEquals(input, result); buf.release(); } @ParameterizedTest @ValueSource(booleans = {true, false}) void Serialize_Boolean(boolean input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); assertEquals(1, size); assertEquals(input, buf.getBoolean(0)); var result = Serializer.deserializeBoolean(buf); assertEquals(input, result); buf.release(); } @ParameterizedTest @ValueSource(strings = {"", "hello", "hello world", " "}) void Serialize_String(String input) { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, input); var stringBytes = input.getBytes(); assertEquals(stringBytes.length + 4, size); var output = new byte[stringBytes.length]; buf.getBytes(4, output); assertArrayEquals(stringBytes, output); var result = Serializer.deserializeString(buf); assertEquals(input, result); buf.release(); } @Test void Serialize_String_Null() { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, (String) null); assertEquals(4, size); buf.release(); } @Test void Serialize_ByteArray() { var buf = Unpooled.buffer(); var input = new byte[]{1, 2, 3}; var size = Serializer.serialize(buf, input); assertEquals(4 + input.length, size); var output = new byte[input.length]; buf.getBytes(4, output); assertArrayEquals(input, output); var result = Serializer.deserializeByteArray(buf); assertArrayEquals(input, result); buf.release(); } @Test void Serialize_ByteArray_Null() { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, (byte[]) null); assertEquals(4, size); buf.release(); } @Test void Serialize_Identifier() { var buf = Unpooled.buffer(); var input = LocationFakes.createLocation().getLocationIdentifier(); var size = Serializer.serialize(buf, input, LocationIdentifier.class); assertEquals(input.getLength(), size); var output = new byte[input.getLength()]; buf.getBytes(0, output); assertArrayEquals(input.getBytes(), output); var result = (LocationIdentifier) Serializer.deserialize(buf, LocationIdentifier.class); assertEquals(input, result); buf.release(); } @Test void Serialize_Identifier_Null() { var buf = Unpooled.buffer(); var size = IdentifierSerializer.serialize(buf, GxsId.class, null); assertEquals(GxsId.LENGTH, size); var result = (GxsId) Serializer.deserialize(buf, GxsId.class); assertNull(result); buf.release(); } @Test void Serialize_Identifier_Null_Dynamic() { var buf = Unpooled.buffer(); assertThrows(IllegalStateException.class, () -> IdentifierSerializer.serialize(buf, ProfileFingerprint.class, null)); buf.release(); } @Test void Serialize_List() { var buf = Unpooled.buffer(); var input = List.of("hello", "dude"); var size = Serializer.serialize(buf, input.getClass(), input, null); var listObject = new SerialList(); var result = Serializer.deserializeAnnotatedFields(buf, listObject); assertTrue(result); assertEquals(input.size(), listObject.getList().size()); assertArrayEquals(input.get(0).getBytes(), listObject.getList().get(0).getBytes()); assertArrayEquals(input.get(1).getBytes(), listObject.getList().get(1).getBytes()); assertEquals(4 + 4 + input.get(0).getBytes().length + 4 + input.get(1).getBytes().length, size); assertEquals(input.size(), buf.getInt(0)); buf.release(); } @Test void Serialize_List_Null() { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, List.class, null, null); assertEquals(4, size); buf.release(); } @Test void Serialize_Map() { var buf = Unpooled.buffer(); var input = Map.of(1, "foo", 2, "barbaz"); var size = Serializer.serialize(buf, input.getClass(), input, null); var mapObject = new SerialMap(); var result = Serializer.deserializeAnnotatedFields(buf, mapObject); assertTrue(result); assertEquals(input.size(), mapObject.getMap().size()); assertArrayEquals(input.get(1).getBytes(), mapObject.getMap().get(1).getBytes()); assertArrayEquals(input.get(2).getBytes(), mapObject.getMap().get(2).getBytes()); assertEquals(67, size); buf.release(); } @Test void Serialize_Map_Null() { var buf = Unpooled.buffer(); var size = Serializer.serialize(buf, Map.class, null, null); assertEquals(6, size); buf.release(); } @Test void Serialize_Enum() { var buf = Unpooled.buffer(); var input = SerialEnum.TWO; var size = Serializer.serialize(buf, input); assertEquals(4, size); assertEquals(1, buf.getInt(0)); var result = Serializer.deserializeEnum(buf, SerialEnum.class); assertEquals(input, result); buf.release(); } @Test void Serialize_Enum_Null() { var buf = Unpooled.buffer(); assertThrows(NullPointerException.class, () -> Serializer.serialize(buf, (Enum) null)); buf.release(); } @Test void Serialize_EnumSet() { var buf = Unpooled.buffer(); var input = EnumSet.of(SerialEnum.TWO, SerialEnum.FOUR); var size = Serializer.serialize(buf, input, FieldSize.INTEGER); assertEquals(4, size); assertEquals(1 << 1 | 1 << 3, buf.getInt(0)); var result = Serializer.deserializeEnumSet(buf, SerialEnum.class, FieldSize.INTEGER); assertEquals(input, result); buf.release(); } @Test void Serialize_EnumSet_Null() { var buf = Unpooled.buffer(); assertThrows(NullPointerException.class, () -> Serializer.serialize(buf, (EnumSet) null, FieldSize.INTEGER)); buf.release(); } @Test void Serialize_TlvString() { var buf = Unpooled.buffer(); var input = "foobar"; var size = Serializer.serialize(buf, TlvType.STR_NAME, input); assertEquals(6 + input.getBytes().length, size); var result = Serializer.deserialize(buf, TlvType.STR_NAME); assertEquals(input, result); buf.release(); } @Test void Serialize_TlvKeySignature() { var buf = Unpooled.buffer(); var key = RandomUtils.insecure().randomBytes(30); var input = new Signature(IdFakes.createGxsId(), key); var size = Serializer.serialize(buf, TlvType.SIGNATURE, input); assertEquals(6 + 6 + 38 + key.length, size); var result = (Signature) Serializer.deserialize(buf, TlvType.SIGNATURE); assertEquals(input.getGxsId(), result.getGxsId()); assertArrayEquals(input.getData(), result.getData()); buf.release(); } @Test void Serialize_TlvKeySignatureSet() { var buf = Unpooled.buffer(); Set input = new HashSet<>(); var gxsId = IdFakes.createGxsId(); var signature = RandomUtils.insecure().randomBytes(20); var keySignature = new Signature(Signature.Type.ADMIN, gxsId, signature); input.add(keySignature); var size = Serializer.serialize(buf, TlvType.SIGNATURE_SET, input); assertEquals(TLV_HEADER_SIZE + TLV_HEADER_SIZE + 4 + TLV_HEADER_SIZE + TLV_HEADER_SIZE + GxsId.LENGTH * 2 + TLV_HEADER_SIZE + signature.length, size); @SuppressWarnings("unchecked") var result = (Set) Serializer.deserialize(buf, TlvType.SIGNATURE_SET); assertEquals(input.stream().findFirst().orElseThrow().getGxsId(), result.stream().findFirst().orElseThrow().getGxsId()); assertArrayEquals(input.stream().findFirst().orElseThrow().getData(), result.stream().findFirst().orElseThrow().getData()); buf.release(); } @Test void Serialize_TlvImage() { var buf = Unpooled.buffer(); var input = new byte[2]; var size = Serializer.serialize(buf, TlvType.IMAGE, input); assertEquals(6 + 6 + 4 + input.length, size); var result = (byte[]) Serializer.deserialize(buf, TlvType.IMAGE); assertArrayEquals(input, result); buf.release(); } @Test void Serialize_TlvImage_Empty_Array() { var buf = Unpooled.buffer(); var input = new byte[0]; var size = Serializer.serialize(buf, TlvType.IMAGE, input); assertEquals(6 + 6 + 4 + input.length, size); var result = (byte[]) Serializer.deserialize(buf, TlvType.IMAGE); assertArrayEquals(input, result); buf.release(); } @Test void Serialize_TlvSet_GxsId() { var buf = Unpooled.buffer(); var gxsId1 = IdFakes.createGxsId(); var gxsId2 = IdFakes.createGxsId(); Set input = new HashSet<>(); input.add(gxsId1); input.add(gxsId2); var size = Serializer.serialize(buf, TlvType.SET_GXS_ID, input); assertEquals(TLV_HEADER_SIZE + GxsId.LENGTH * input.size(), size); @SuppressWarnings("unchecked") var result = (Set) Serializer.deserialize(buf, TlvType.SET_GXS_ID); assertEquals(2, result.size()); assertTrue(result.contains(gxsId1)); assertTrue(result.contains(gxsId2)); buf.release(); } @Test void Serialize_TlvSet_MsgId() { var buf = Unpooled.buffer(); var msgId1 = new MsgId(RandomUtils.insecure().randomBytes(MsgId.LENGTH)); var msgId2 = new MsgId(RandomUtils.insecure().randomBytes(MsgId.LENGTH)); Set input = new HashSet<>(); input.add(msgId1); input.add(msgId2); var size = Serializer.serialize(buf, TlvType.SET_GXS_MSG_ID, input); assertEquals(TLV_HEADER_SIZE + MsgId.LENGTH * input.size(), size); @SuppressWarnings("unchecked") var result = (Set) Serializer.deserialize(buf, TlvType.SET_GXS_MSG_ID); assertEquals(2, result.size()); assertTrue(result.contains(msgId1)); assertTrue(result.contains(msgId2)); buf.release(); } @Test void Serialize_TlvAddress() { var buf = Unpooled.buffer(); var peerAddress = PeerAddress.fromAddress("192.168.1.1:1234"); var size = Serializer.serialize(buf, TlvType.ADDRESS, peerAddress); assertEquals(TLV_HEADER_SIZE * 2 + 6, size); var result = (PeerAddress) Serializer.deserialize(buf, TlvType.ADDRESS); assertEquals(PeerAddress.Type.IPV4, result.getType()); assertTrue(result.isValid()); assertTrue(result.getAddress().isPresent()); assertEquals("192.168.1.1:1234", result.getAddress().get()); buf.release(); } @Test void Serialize_IdentityGroupItem() { var buf = Unpooled.buffer(); var identityGroupItem = IdentityGroupItemFakes.createIdentityGroupItem(); var result = new GxsMetaAndDataResult(); var size = Serializer.serializeGxsMetaAndDataItem(buf, identityGroupItem, EnumSet.noneOf(SerializationFlags.class), result); assertEquals(194, size); buf.release(); } @Test void Serialize_ForumGroupItem() { var buf = Unpooled.buffer(); var forumGroupItem = ForumGroupItemFakes.createForumGroupItem(); var result = new GxsMetaAndDataResult(); var size = Serializer.serializeGxsMetaAndDataItem(buf, forumGroupItem, EnumSet.noneOf(SerializationFlags.class), result); assertEquals(172, size); buf.release(); } @Test void Serialize_ForumMessageItem() { var buf = Unpooled.buffer(); var forumMessageItem = ForumMessageItemFakes.createForumMessageItem(); var result = new GxsMetaAndDataResult(); var size = Serializer.serializeGxsMetaAndDataItem(buf, forumMessageItem, EnumSet.noneOf(SerializationFlags.class), result); assertEquals(154, size); buf.release(); } @Test void Serialize_ComplexObject() { var buf = Unpooled.buffer(); var input = new SerialAll(); input.setIntPrimitiveField(5); input.setIntegerField(5); input.setShortPrimitiveField((short) 8); input.setShortField((short) 8); input.setBytePrimitiveField((byte) 10); input.setByteField((byte) 10); input.setLongPrimitiveField(12L); input.setLongField(12L); input.setFloatPrimitiveField(14f); input.setFloatField(14f); input.setDoublePrimitiveField(16.0); input.setDoubleField(16.0); input.setBooleanPrimitiveField(true); input.setBooleanField(true); input.setBytes(new byte[]{1, 2, 3}); input.setBigInteger(new BigInteger("123456789")); input.setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier()); input.setStringList(List.of("foo", "bar")); input.setStringMap(Map.of(1, "bleh", 2, "plop")); input.setSerialEnum(SerialEnum.THREE); input.setEnumSet(EnumSet.of(SerialEnum.ONE, SerialEnum.TWO)); input.setEnumSetByte(EnumSet.of(SerialEnum.ONE, SerialEnum.TWO)); input.setEnumSetShort(EnumSet.of(SerialEnum.ONE, SerialEnum.TWO)); input.setTlvName("foobar"); var size = Serializer.serialize(buf, input.getClass(), input, null); assertTrue(size > 0); var result = (SerialAll) Serializer.deserialize(buf, SerialAll.class); assertEquals(input.getIntPrimitiveField(), result.getIntPrimitiveField()); assertEquals(input.getIntegerField(), result.getIntegerField()); assertEquals(input.getShortPrimitiveField(), result.getShortPrimitiveField()); assertEquals(input.getShortField(), result.getShortField()); assertEquals(input.getBytePrimitiveField(), result.getBytePrimitiveField()); assertEquals(input.getByteField(), result.getByteField()); assertEquals(input.getLongPrimitiveField(), result.getLongPrimitiveField()); assertEquals(input.getLongField(), result.getLongField()); assertEquals(input.getFloatPrimitiveField(), result.getFloatPrimitiveField()); assertEquals(input.getFloatField(), result.getFloatField()); assertEquals(input.getDoublePrimitiveField(), result.getDoublePrimitiveField()); assertEquals(input.getDoubleField(), result.getDoubleField()); assertEquals(input.isBooleanPrimitiveField(), result.isBooleanPrimitiveField()); assertEquals(input.getBooleanField(), result.getBooleanField()); assertArrayEquals(input.getBytes(), result.getBytes()); assertEquals(input.getBigInteger(), result.getBigInteger()); assertEquals(input.getLocationIdentifier().getLength(), result.getLocationIdentifier().getLength()); assertArrayEquals(input.getLocationIdentifier().getBytes(), result.getLocationIdentifier().getBytes()); assertEquals(input.getStringList().size(), result.getStringList().size()); assertIterableEquals(input.getStringList(), result.getStringList()); assertEquals(input.getStringMap().size(), result.getStringMap().size()); assertEquals(input.getStringMap(), result.getStringMap()); assertEquals(input.getSerialEnum(), result.getSerialEnum()); assertEquals(input.getEnumSet(), result.getEnumSet()); assertEquals(input.getEnumSetByte(), result.getEnumSetByte()); assertEquals(input.getEnumSetShort(), result.getEnumSetShort()); assertEquals(input.getTlvName(), result.getTlvName()); buf.release(); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/serialization/TlvImageSerializerTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.serialization.TlvImageSerializer.ImageType.*; import static org.junit.jupiter.api.Assertions.assertEquals; class TlvImageSerializerTest { @Test void Enum_Order_Fixed() { assertEquals(0, AUTO_DETECT.ordinal()); assertEquals(1, PNG.ordinal()); assertEquals(2, JPEG.ordinal()); assertEquals(3, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/serialization/TlvUtilsTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.serialization; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; class TlvUtilsTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(TlvUtils.class); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/RsServiceInitPriorityTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.RsServiceInitPriority.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class RsServiceInitPriorityTest { @Test void NoTimeOverlap_Success() { assertTrue(IMMEDIATE.getMaxTime() < HIGH.getMinTime()); assertTrue(HIGH.getMaxTime() < NORMAL.getMinTime()); assertTrue(NORMAL.getMaxTime() < LOW.getMinTime()); assertEquals(0, OFF.getMinTime()); assertEquals(0, OFF.getMaxTime()); } @Test void MinMax_Success() { assertTrue(IMMEDIATE.getMinTime() <= IMMEDIATE.getMaxTime()); assertTrue(HIGH.getMinTime() <= HIGH.getMaxTime()); assertTrue(NORMAL.getMinTime() <= NORMAL.getMaxTime()); assertTrue(LOW.getMinTime() <= LOW.getMaxTime()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/RsServiceRulesTest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; @AnalyzeClasses(packagesOf = RsService.class) class RsServiceRulesTest { @ArchTest void rs_service_naming(JavaClasses classes) { classes().that().areAssignableTo(RsService.class) .should().haveSimpleNameEndingWith("RsService").check(classes); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/bandwidth/BandwidthUtilsTest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.bandwidth; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class BandwidthUtilsTest { @Test void findBandwidthOnWindowsAnti() { // This one has an unplugged ethernet interface with a secondary connection // The correct USB Wi-Fi dongle interface // And an incorrect XBox adapter var input = """ 0 0 1300000000 600000000 """; assertEquals(1_300_000_000L, BandwidthUtils.searchBandwidthOnWindows(input)); } @Test void findBandwidthOnWindowsZapek() { // Mine only has one default ethernet interface var input = """ 2500000000 """; assertEquals(2_500_000_000L, BandwidthUtils.searchBandwidthOnWindows(input)); } @Test void findBandwidthOnWindowsNotANumber() { var input = """ woohoo """; assertEquals(0L, BandwidthUtils.searchBandwidthOnWindows(input)); } @Test void findBandwidthOnLinux() { var input = """ 1000 """; assertEquals(1_000_000_000L, BandwidthUtils.searchBandwidthOnLinux(input)); } @Test void findBandwidthOnMac() { var input = """ en0: flags=8863 mtu 1500 options=40b ether 00:0c:29:da:8c:2a\s inet6 fe80::184a:e26f:63c5:df33%en0 prefixlen 64 secured scopeid 0x4\s inet 192.168.136.128 netmask 0xffffff00 broadcast 192.168.136.255 nd6 options=201 media: autoselect (1000baseT ) status: active """; assertEquals(1_000_000_000L, BandwidthUtils.searchBandwidthOnMac(input)); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/chat/ChatFlagsTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.chat.ChatFlags.*; import static org.junit.jupiter.api.Assertions.assertEquals; class ChatFlagsTest { @Test void Enum_Order_Fixed() { assertEquals(0, PRIVATE.ordinal()); assertEquals(1, REQUEST_AVATAR.ordinal()); assertEquals(2, CONTAINS_AVATAR.ordinal()); assertEquals(3, AVATAR_AVAILABLE.ordinal()); assertEquals(4, CUSTOM_STATE.ordinal()); assertEquals(5, PUBLIC.ordinal()); assertEquals(6, REQUEST_CUSTOM_STATE.ordinal()); assertEquals(7, CUSTOM_STATE_AVAILABLE.ordinal()); assertEquals(8, PARTIAL_MESSAGE.ordinal()); assertEquals(9, LOBBY.ordinal()); assertEquals(10, CLOSING_DISTANT_CONNECTION.ordinal()); assertEquals(11, ACK_DISTANT_CONNECTION.ordinal()); assertEquals(12, KEEP_ALIVE.ordinal()); assertEquals(13, CONNECTION_REFUSED.ordinal()); assertEquals(14, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/chat/ChatRoomEventTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.chat.item.ChatRoomEvent.*; import static org.junit.jupiter.api.Assertions.assertEquals; class ChatRoomEventTest { @Test void Enum_Values() { assertEquals(1, PEER_LEFT.getCode()); assertEquals(2, PEER_STATUS.getCode()); assertEquals(3, PEER_JOINED.getCode()); assertEquals(4, PEER_CHANGE_NICKNAME.getCode()); assertEquals(5, KEEP_ALIVE.getCode()); assertEquals(5, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/chat/ChatRoomServiceTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.app.database.model.chat.ChatRoom; import io.xeres.app.database.model.chat.ChatRoomFakes; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.database.repository.ChatRoomRepository; import io.xeres.common.message.chat.RoomType; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ChatRoomServiceTest { @Mock private ChatRoomRepository chatRoomRepository; @InjectMocks private ChatRoomService chatRoomService; @Test void CreateChatRoom_Success() { chatRoomService.createChatRoom(createSignedChatRoom(), IdentityFakes.createOwn()); verify(chatRoomRepository).save(any(ChatRoom.class)); } @Test void SubscribeToChatRoomAndJoin_Success() { var serviceChatRoom = createSignedChatRoom(); var identity = IdentityFakes.createOwn(); var chatRoom = ChatRoomFakes.createChatRoomEntity(serviceChatRoom.getId(), identity, serviceChatRoom.getName(), serviceChatRoom.getTopic(), 0); when(chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity)).thenReturn(Optional.of(chatRoom)); var subscribedChatRoom = chatRoomService.subscribeToChatRoomAndJoin(serviceChatRoom, identity); assertTrue(subscribedChatRoom.isSubscribed()); assertTrue(subscribedChatRoom.isJoined()); verify(chatRoomRepository).findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity); } @Test void UnsubscribeFromChatRoomAndLeave_Success() { var serviceChatRoom = createSignedChatRoom(); var identity = IdentityFakes.createOwn(); var chatRoom = ChatRoomFakes.createChatRoomEntity(serviceChatRoom.getId(), identity, serviceChatRoom.getName(), serviceChatRoom.getTopic(), 0); when(chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity)).thenReturn(Optional.of(chatRoom)); var unsubscribedChatRoom = chatRoomService.unsubscribeFromChatRoomAndLeave(serviceChatRoom.getId(), identity); assertFalse(unsubscribedChatRoom.isSubscribed()); assertFalse(unsubscribedChatRoom.isJoined()); verify(chatRoomRepository).findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity); } private io.xeres.app.xrs.service.chat.ChatRoom createSignedChatRoom() { return new io.xeres.app.xrs.service.chat.ChatRoom(1L, "test", "something", RoomType.PUBLIC, 1, true); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/chat/ChatRsServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.IdentityService; import io.xeres.app.service.MessageService; import io.xeres.app.service.UnHtmlService; import io.xeres.app.service.script.ScriptService; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.chat.item.ChatMessageItem; import io.xeres.app.xrs.service.chat.item.ChatRoomListItem; import io.xeres.app.xrs.service.chat.item.ChatRoomListRequestItem; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.ChatMessage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.EnumSet; import static io.xeres.common.message.MessagePath.chatPrivateDestination; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @SuppressWarnings("unused") @ExtendWith(MockitoExtension.class) class ChatRsServiceTest { @Mock private PeerConnectionManager peerConnectionManager; @Mock private DatabaseSessionManager databaseSessionManager; @Mock private IdentityService identityService; @Mock private ChatRoomService chatRoomService; @Mock private ChatBacklogService chatBacklogService; @Mock private MessageService messageService; @Mock private UnHtmlService unHtmlService; @Mock private ScriptService scriptService; @InjectMocks private ChatRsService chatRsService; @Test void HandleChatMessageItem_Success() { var message = "hello"; var peerConnection = new PeerConnection(LocationFakes.createLocation(), null); var item = new ChatMessageItem(message, EnumSet.of(ChatFlags.PRIVATE)); when(unHtmlService.cleanupMessage(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); chatRsService.handleItem(peerConnection, item); verify(messageService).sendToConsumers(eq(chatPrivateDestination()), eq(MessageType.CHAT_PRIVATE_MESSAGE), eq(peerConnection.getLocation().getLocationIdentifier()), argThat(chatMessage -> { assertNotNull(chatMessage); assertEquals(message, ((ChatMessage) (chatMessage)).getContent()); return true; })); } @Test void HandleChatMessageItem_Partial_Success() { var message1 = "hello, "; var message2 = "world"; var peerConnection = new PeerConnection(LocationFakes.createLocation(), null); var item1 = new ChatMessageItem(message1, EnumSet.of(ChatFlags.PRIVATE, ChatFlags.PARTIAL_MESSAGE)); var item2 = new ChatMessageItem(message2, EnumSet.of(ChatFlags.PRIVATE)); when(unHtmlService.cleanupMessage(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); chatRsService.handleItem(peerConnection, item1); chatRsService.handleItem(peerConnection, item2); verify(messageService).sendToConsumers(eq(chatPrivateDestination()), eq(MessageType.CHAT_PRIVATE_MESSAGE), eq(peerConnection.getLocation().getLocationIdentifier()), argThat(chatMessage -> { assertNotNull(chatMessage); assertEquals(message1 + message2, ((ChatMessage) (chatMessage)).getContent()); return true; })); } @Test void HandleChatRoomListRequestItem_Empty_Success() { var peerConnection = new PeerConnection(LocationFakes.createLocation(), null); var item = new ChatRoomListRequestItem(); chatRsService.handleItem(peerConnection, item); verify(peerConnectionManager).writeItem(eq(peerConnection), argThat(chatRoomListItem -> { assertNotNull(chatRoomListItem); assertTrue(((ChatRoomListItem) chatRoomListItem).getChatRooms().isEmpty()); return true; }), any(RsService.class)); } @Test void HandleChatRoomListRequestItem_Success() { var roomName = "test"; var roomTopic = "test topic"; var roomFlags = EnumSet.of(RoomFlags.PUBLIC); var ownIdentity = IdentityFakes.createOwn(); var peerConnection = new PeerConnection(LocationFakes.createLocation(), null); var item = new ChatRoomListRequestItem(); when(identityService.getOwnIdentity()).thenReturn(ownIdentity); var roomId = chatRsService.createChatRoom(roomName, roomTopic, roomFlags, false); chatRsService.handleItem(peerConnection, item); verify(peerConnectionManager).writeItem(eq(peerConnection), argThat(chatRoomListItem -> { assertNotNull(chatRoomListItem); assertFalse(((ChatRoomListItem) chatRoomListItem).getChatRooms().isEmpty()); assertEquals(roomId, ((ChatRoomListItem) chatRoomListItem).getChatRooms().getFirst().getId()); return true; }), any(RsService.class)); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/chat/RoomFlagsTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.chat; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.chat.RoomFlags.*; import static org.junit.jupiter.api.Assertions.assertEquals; class RoomFlagsTest { @Test void Enum_Order_Fixed() { assertEquals(0, AUTO_SUBSCRIBE.ordinal()); assertEquals(1, UNUSED.ordinal()); assertEquals(2, PUBLIC.ordinal()); assertEquals(3, CHALLENGE.ordinal()); assertEquals(4, PGP_SIGNED.ordinal()); assertEquals(5, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/discovery/DiscoveryPgpListItemTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.discovery; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.discovery.item.DiscoveryPgpListItem.Mode.*; import static org.junit.jupiter.api.Assertions.assertEquals; class DiscoveryPgpListItemTest { @Test void Mode_Enum_Order_Fixed() { assertEquals(0, NONE.ordinal()); assertEquals(1, FRIENDS.ordinal()); assertEquals(2, GET_CERT.ordinal()); assertEquals(3, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/discovery/DiscoveryRsServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.discovery; import io.xeres.app.database.model.location.Location; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.LocationService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.notification.status.StatusNotificationService; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.discovery.item.DiscoveryContactItem; import io.xeres.app.xrs.service.discovery.item.DiscoveryPgpListItem; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.protocol.NetMode; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class DiscoveryRsServiceTest { @Mock private PeerConnectionManager peerConnectionManager; @Mock private ProfileService profileService; @Mock private LocationService locationService; @SuppressWarnings("unused") @Mock private StatusNotificationService statusNotificationService; @InjectMocks private DiscoveryRsService discoveryRsService; /** * This is a case that is handled by RS but that I think is never actually sent. * We ignore it, just in case. */ @Test void HandleDiscoveryContactItem_NewLocation_FriendOfFriend_Known_Ignore() { var peerConnection = new PeerConnection(LocationFakes.createLocation(), null); when(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.empty()); when(profileService.findProfileByPgpIdentifier(anyLong())).thenReturn(Optional.empty()); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(LocationFakes.createLocation())); verify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class)); } /** * This is a case that shouldn't happen either. */ @Test void HandleDiscoveryContactItem_NewLocation_FriendOfFriend_Unknown_Ignore() { var peerConnection = new PeerConnection(LocationFakes.createLocation(), null); when(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.empty()); when(profileService.findProfileByPgpIdentifier(anyLong())).thenReturn(Optional.of(ProfileFakes.createProfile())); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(LocationFakes.createLocation())); verify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class)); } /** * The peer sends the new location of a common friend. We keep that new location. */ @Test void HandleDiscoveryContactItem_NewLocation_Friend_Success() { var peerLocation = LocationFakes.createLocation(); var peerConnection = new PeerConnection(peerLocation, null); var profile = ProfileFakes.createProfile(); profile.setAccepted(true); var newLocation = LocationFakes.createLocation("foo", profile); when(profileService.findProfileByPgpIdentifier(profile.getPgpIdentifier())).thenReturn(Optional.of(profile)); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(newLocation)); verify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class)); verify(locationService).update(eq(newLocation), anyString(), any(NetMode.class), anyString(), any(), anyList()); } /** * The peer sends an updated location of a common friend. We update * the location. */ @Test void HandleDiscoveryContactItem_UpdateLocation_Friend_Success() { var peerLocation = LocationFakes.createLocation(); var peerConnection = new PeerConnection(peerLocation, null); var profile = ProfileFakes.createProfile(); profile.setAccepted(true); var friendLocation = LocationFakes.createLocation("foo", profile); when(locationService.findLocationByLocationIdentifier(friendLocation.getLocationIdentifier())).thenReturn(Optional.of(friendLocation)); when(locationService.findOwnLocation()).thenReturn(Optional.of(LocationFakes.createOwnLocation())); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(friendLocation)); verify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class)); verify(locationService).update(eq(friendLocation), anyString(), any(NetMode.class), anyString(), any(), anyList()); } /** * The peer sends our own location. We do nothing (could be used to help find out our external * IP address). */ @Test void HandleDiscoveryContactItem_UpdateLocation_Own_Ignore() { var peerLocation = LocationFakes.createLocation(); var peerConnection = new PeerConnection(peerLocation, null); var profile = ProfileFakes.createProfile(); profile.setAccepted(true); var friendLocation = LocationFakes.createLocation("foo", profile); when(locationService.findLocationByLocationIdentifier(friendLocation.getLocationIdentifier())).thenReturn(Optional.of(friendLocation)); when(locationService.findOwnLocation()).thenReturn(Optional.of(friendLocation)); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(friendLocation)); verify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class)); } /** * The peer sends his location. We update its location and send our list * of friends. */ @Test void HandleDiscoveryContactItem_UpdateLocation_Peer_Success() { var peerLocation = LocationFakes.createLocation(); var peerConnection = new PeerConnection(peerLocation, null); var ownLocation = LocationFakes.createLocation(); var profile = ProfileFakes.createProfile(); profile.setAccepted(true); when(locationService.findLocationByLocationIdentifier(peerLocation.getLocationIdentifier())).thenReturn(Optional.of(peerLocation)); when(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation)); when(profileService.getAllDiscoverableProfiles()).thenReturn(List.of(profile)); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(peerLocation)); verify(locationService).update(eq(peerLocation), anyString(), any(NetMode.class), anyString(), any(), anyList()); var discoveryPgpListItem = ArgumentCaptor.forClass(DiscoveryPgpListItem.class); verify(peerConnectionManager).writeItem(eq(peerConnection), discoveryPgpListItem.capture(), any(RsService.class)); assertEquals(DiscoveryPgpListItem.Mode.FRIENDS, discoveryPgpListItem.getValue().getMode()); assertTrue(discoveryPgpListItem.getValue().getPgpIds().contains(profile.getPgpIdentifier())); } /** * The peer sends his location. We update its location but don't send our list of * friends because we're not discoverable. */ @Test void HandleDiscoveryContactItem_UpdateLocation_Peer_OurLocation_NotDiscoverable_Success() { var peerLocation = LocationFakes.createLocation(); var peerConnection = new PeerConnection(peerLocation, null); var ownLocation = LocationFakes.createLocation(); ownLocation.setDiscoverable(false); when(locationService.findLocationByLocationIdentifier(peerLocation.getLocationIdentifier())).thenReturn(Optional.of(peerLocation)); when(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation)); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(peerLocation)); verify(locationService).findLocationByLocationIdentifier(peerLocation.getLocationIdentifier()); verify(locationService).findOwnLocation(); verify(locationService).update(eq(peerLocation), anyString(), any(NetMode.class), anyString(), any(), anyList()); verify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class)); } /** * The peer sends his location. We update its location and since it's a partial profile (added through * ShortInvites) we ask for its PGP key. */ @Test void HandleDiscoveryContactItem_UpdateLocation_Peer_Partial_Success() { var peerLocation = LocationFakes.createLocation(); var peerConnection = new PeerConnection(peerLocation, null); var peerProfile = ProfileFakes.createProfile(); peerProfile.setAccepted(true); peerProfile.setPgpPublicKeyData(null); // partial profile peerLocation.setProfile(peerProfile); when(locationService.findLocationByLocationIdentifier(peerLocation.getLocationIdentifier())).thenReturn(Optional.of(peerLocation)); discoveryRsService.handleItem(peerConnection, createDiscoveryContact(peerLocation)); verify(locationService).update(eq(peerLocation), anyString(), any(NetMode.class), anyString(), any(), anyList()); var discoveryPgpListItem = ArgumentCaptor.forClass(DiscoveryPgpListItem.class); verify(peerConnectionManager).writeItem(eq(peerConnection), discoveryPgpListItem.capture(), any(RsService.class)); assertEquals(DiscoveryPgpListItem.Mode.GET_CERT, discoveryPgpListItem.getValue().getMode()); assertTrue(discoveryPgpListItem.getValue().getPgpIds().contains(peerProfile.getPgpIdentifier())); } private DiscoveryContactItem createDiscoveryContact(Location location) { var builder = DiscoveryContactItem.builder(); builder.setPgpIdentifier(location.getProfile().getPgpIdentifier()); builder.setLocationIdentifier(location.getLocationIdentifier()); builder.setLocationName(location.getName()); builder.setHostname("foobar.com"); // XXX: no hostname support in location yet builder.setNetMode(location.getNetMode()); builder.setVersion(location.getVersion()); return builder.build(); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/filetransfer/ChunkDistributorTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import org.junit.jupiter.api.Test; import java.util.BitSet; import java.util.Optional; import java.util.Set; import static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.LINEAR; import static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.RANDOM; import static org.junit.jupiter.api.Assertions.assertEquals; class ChunkDistributorTest { @Test void Linear_Given() { var availableChunkMap = new BitSet(4); availableChunkMap.set(0, 4); var chunkMap = new BitSet(4); var chunkDistributor = new ChunkDistributor(chunkMap, 4, LINEAR); assertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(2, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(3, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap)); } @Test void Linear_GivenAndUsed() { var availableChunkMap = new BitSet(4); availableChunkMap.set(0, 4); var chunkMap = new BitSet(4); var chunkDistributor = new ChunkDistributor(chunkMap, 4, LINEAR); assertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); chunkMap.set(0); assertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); chunkMap.set(1); chunkMap.set(2); assertEquals(3, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); chunkMap.set(3); assertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap)); } @Test void GivenAndUsed2() { var availableChunkMap = new BitSet(8); availableChunkMap.set(0, 8); var chunkMap = new BitSet(8); var chunkDistributor = new ChunkDistributor(chunkMap, 8, LINEAR); assertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); chunkMap.set(0); assertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(2, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(3, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(4, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(5, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); chunkMap.set(1); chunkMap.set(2); chunkMap.set(3); chunkMap.set(4); assertEquals(6, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); chunkMap.set(5); chunkMap.set(6); assertEquals(7, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap)); } @Test void Random_Given() { var availableChunkMap = new BitSet(4); availableChunkMap.set(0, 4); var chunkMap = new BitSet(4); var chunkDistributor = new ChunkDistributor(chunkMap, 4, RANDOM); var chunk1 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow(); var chunk2 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow(); var chunk3 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow(); var chunk4 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow(); assertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap)); var all = Set.of(chunk1, chunk2, chunk3, chunk4); assertEquals(4, all.size()); } @Test void Linear_Given_NotAllAvailable() { var availableChunkMap = new BitSet(4); availableChunkMap.set(0, 2); var chunkMap = new BitSet(4); var chunkDistributor = new ChunkDistributor(chunkMap, 4, LINEAR); assertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow()); assertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap)); assertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap)); assertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap)); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/filetransfer/ChunkMapUtilsTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import java.util.BitSet; import java.util.List; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; class ChunkMapUtilsTest { @Test void Instance_ThrowException() throws NoSuchMethodException { TestUtils.assertUtilityClass(ChunkMapUtils.class); } @Test void ToCompressedChunkMap() { var input = new BitSet(4); input.set(0); input.set(1); input.set(31); input.set(32); input.set(33); input.set(64); var output = ChunkMapUtils.toCompressedChunkMap(input); assertEquals(-2147483645, output.getFirst()); assertEquals(3, output.get(1)); assertEquals(1, output.get(2)); } @Test void Transform() { List input = List.of(0x1, 0xaabbccdd, 0x8844aa23); var bitSet = ChunkMapUtils.toBitSet(input); var output = ChunkMapUtils.toCompressedChunkMap(bitSet); assertArrayEquals(input.toArray(), output.toArray()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/filetransfer/ChunkTest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE; import static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class ChunkTest { @Test void fillFullChunk() { var chunk = new Chunk(CHUNK_SIZE); for (int i = 0; i < CHUNK_SIZE - BLOCK_SIZE; i += BLOCK_SIZE) { chunk.setBlocksAsWritten(i, BLOCK_SIZE); assertFalse(chunk.isComplete()); } chunk.setBlocksAsWritten(CHUNK_SIZE - BLOCK_SIZE, BLOCK_SIZE); assertTrue(chunk.isComplete()); } @Test void fillPartialChunk() { var chunk = new Chunk(CHUNK_SIZE - 5000); for (int i = 0; i < CHUNK_SIZE - BLOCK_SIZE; i += BLOCK_SIZE) { chunk.setBlocksAsWritten(i, BLOCK_SIZE); assertFalse(chunk.isComplete()); } chunk.setBlocksAsWritten(CHUNK_SIZE - BLOCK_SIZE, BLOCK_SIZE); assertTrue(chunk.isComplete()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/filetransfer/FileDownloadTest.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.common.util.OsUtils; import org.apache.commons.lang3.SystemUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.IOException; import java.nio.file.Paths; import static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.LINEAR; import static org.junit.jupiter.api.Assertions.*; class FileDownloadTest { private static String tempDir; @BeforeAll static void setup() { tempDir = System.getProperty("java.io.tmpdir"); } @Test void Sparse_Success() { var file = Paths.get(tempDir, "sparsefile.tmp").toFile(); var fileLeecher = new FileDownload(0L, file, 16384, null, LINEAR); fileLeecher.open(); assertEquals(16384, fileLeecher.getFileSize()); fileLeecher.close(); if (SystemUtils.IS_OS_WINDOWS) { assertEquals("This file is set as sparse\n", OsUtils.shellExecute("fsutil", "sparse", "queryflag", file.getAbsolutePath())); } else if (SystemUtils.IS_OS_LINUX || SystemUtils.IS_OS_MAC) { var result = OsUtils.shellExecute("ls", "-lsk", file.getAbsolutePath()); var s = result.split(" "); var storageSize = Integer.parseInt(s[0]) * 1024; var fileSize = Integer.parseInt(s[5]); assertTrue(storageSize < fileSize); } //noinspection ResultOfMethodCallIgnored file.delete(); } @Test void Read_NotAvailable() { var file = Paths.get(tempDir, "filesize.tmp").toFile(); var fileLeecher = new FileDownload(0L, file, 256, null, LINEAR); fileLeecher.open(); assertThrows(IOException.class, () -> fileLeecher.read(0, 256)); fileLeecher.close(); //noinspection ResultOfMethodCallIgnored file.delete(); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/filetransfer/FileTransferAgentTest.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.testutils.Sha1SumFakes; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class FileTransferAgentTest { @Mock private FileTransferRsService fileTransferRsService; @Mock private FileProvider fileProvider; @Test void processLeecher() throws IOException { var leecher = LocationFakes.createLocation(); var hash = Sha1SumFakes.createSha1Sum(); var agent = new FileTransferAgent(fileTransferRsService, "foo", hash, fileProvider); when(fileProvider.getFileSize()).thenReturn(1024L); // Same file size when(fileProvider.read(0L, 1024)).thenReturn(new byte[1024]); agent.addLeecher(leecher, 0, 1024); assertTrue(agent.process()); verify(fileTransferRsService).sendData(eq(leecher), eq(hash), eq(1024L), eq(0L), any()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/filetransfer/FileUploadTest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.filetransfer; import org.apache.commons.lang3.RandomUtils; import org.junit.jupiter.api.Test; import java.io.File; import java.io.IOException; import java.nio.file.Files; import static org.junit.jupiter.api.Assertions.*; class FileUploadTest { private static final int TEMP_FILE_SIZE = 256; private static File createTempFile(int size) throws IOException { var tempFile = Files.createTempFile("fileseeder", ".tmp").toFile(); if (size > 0) { Files.write(tempFile.toPath(), RandomUtils.insecure().randomBytes(size)); } return tempFile; } private static void deleteTempFile(File file) throws IOException { Files.deleteIfExists(file.toPath()); } @Test void GetFileSize_NotInitialized() throws IOException { var tempFile = createTempFile(0); var fileSeeder = new FileUpload(tempFile); assertThrows(IllegalStateException.class, fileSeeder::getFileSize); deleteTempFile(tempFile); } @Test void GetFileSize_Success() throws IOException { var tempFile = createTempFile(TEMP_FILE_SIZE); var fileSeeder = new FileUpload(tempFile); fileSeeder.open(); assertEquals(TEMP_FILE_SIZE, fileSeeder.getFileSize()); fileSeeder.close(); deleteTempFile(tempFile); } @Test void Write_Illegal() throws IOException { var tempFile = createTempFile(TEMP_FILE_SIZE); var fileSeeder = new FileUpload(tempFile); fileSeeder.open(); assertThrows(IllegalArgumentException.class, () -> fileSeeder.write(0, new byte[]{1, 2, 3})); fileSeeder.close(); deleteTempFile(tempFile); } @Test void Read_Success() throws IOException { var tempFile = createTempFile(TEMP_FILE_SIZE); var fileSeeder = new FileUpload(tempFile); fileSeeder.open(); assertArrayEquals(Files.readAllBytes(tempFile.toPath()), fileSeeder.read(0, TEMP_FILE_SIZE)); fileSeeder.close(); deleteTempFile(tempFile); } @Test void GetCompressedChunkMap_Success() throws IOException { var tempFile = createTempFile(TEMP_FILE_SIZE); var fileSeeder = new FileUpload(tempFile); fileSeeder.open(); assertTrue(fileSeeder.getChunkMap().get(0)); fileSeeder.close(); deleteTempFile(tempFile); } @Test void IsComplete_Success() throws IOException { var tempFile = createTempFile(0); var fileSeeder = new FileUpload(tempFile); fileSeeder.isComplete(); assertTrue(fileSeeder.isComplete()); deleteTempFile(tempFile); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/gxs/GxsRequestTypeTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.gxs.item.RequestType.*; import static org.junit.jupiter.api.Assertions.assertEquals; class GxsRequestTypeTest { @Test void Enum_Order_Fixed() { assertEquals(0, NONE.ordinal()); assertEquals(1, REQUEST.ordinal()); assertEquals(2, RESPONSE.ordinal()); assertEquals(3, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/gxs/GxsSignatureTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import io.netty.buffer.Unpooled; import io.xeres.app.crypto.rsa.RSA; import io.xeres.app.database.model.gxs.IdentityGroupItemFakes; import io.xeres.app.xrs.item.Item; import io.xeres.app.xrs.item.RawItem; import io.xeres.app.xrs.serialization.SerializationFlags; import io.xeres.app.xrs.service.identity.IdentityRsService; import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.EnumSet; import static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE; import static org.junit.jupiter.api.Assertions.assertNotNull; class GxsSignatureTest { @Test void Create_And_Verify_Success() { var gxsIdGroupItem = IdentityGroupItemFakes.createIdentityGroupItem(); var keyPair = RSA.generateKeys(512); gxsIdGroupItem.setAdminKeys(keyPair.getPrivate(), keyPair.getPublic(), Instant.now(), null); var data = serializeItemForSignature(gxsIdGroupItem); var signature = RSA.sign(gxsIdGroupItem.getAdminPrivateKey(), data); gxsIdGroupItem.setAdminSignature(signature); var rawItem = serializeItem(gxsIdGroupItem); assertNotNull(rawItem); rawItem.getBuffer().release(); } private RawItem serializeItem(Item item) { item.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null, null)); return item.serializeItem(EnumSet.noneOf(SerializationFlags.class)); } private byte[] serializeItemForSignature(Item item) { item.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null, null)); var buf = item.serializeItem(EnumSet.of(SerializationFlags.SIGNATURE)).getBuffer(); var data = new byte[buf.writerIndex() - HEADER_SIZE]; buf.getBytes(HEADER_SIZE, data); buf.release(); return data; } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/gxs/TransactionFlagsTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.gxs.item.TransactionFlags.*; import static org.junit.jupiter.api.Assertions.assertEquals; class TransactionFlagsTest { @Test void Enum_Order_Fixed() { assertEquals(0, START.ordinal()); assertEquals(1, START_ACKNOWLEDGE.ordinal()); assertEquals(2, END_SUCCESS.ordinal()); assertEquals(3, CANCEL.ordinal()); assertEquals(4, END_FAIL_NUM.ordinal()); assertEquals(5, END_FAIL_TIMEOUT.ordinal()); assertEquals(6, END_FAIL_FULL.ordinal()); assertEquals(8, TYPE_GROUP_LIST_RESPONSE.ordinal()); assertEquals(9, TYPE_MESSAGE_LIST_RESPONSE.ordinal()); assertEquals(10, TYPE_GROUP_LIST_REQUEST.ordinal()); assertEquals(11, TYPE_MESSAGE_LIST_REQUEST.ordinal()); assertEquals(12, TYPE_GROUPS.ordinal()); assertEquals(13, TYPE_MESSAGES.ordinal()); assertEquals(14, TYPE_ENCRYPTED_DATA.ordinal()); assertEquals(15, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/gxs/TransactionTest.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs; import io.xeres.app.xrs.service.gxs.item.GxsSyncGroupItem; import io.xeres.app.xrs.service.gxs.item.TransactionFlags; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.EnumSet; import static org.junit.jupiter.api.Assertions.*; class TransactionTest { @Test void AddItems_Success() { var transaction = new Transaction(1, EnumSet.noneOf(TransactionFlags.class), new ArrayList<>(), 2, null, Transaction.Direction.INCOMING); transaction.addItem(new GxsSyncGroupItem()); transaction.addItem(new GxsSyncGroupItem()); assertEquals(1, transaction.getId()); assertFalse(transaction.hasTimedOut()); assertTrue(transaction.hasAllItems()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/gxs/item/GxsSyncMessageRequestItemTest.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxs.item; import io.xeres.common.id.GxsId; import org.junit.jupiter.api.Test; import java.time.Duration; import java.time.Instant; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class GxsSyncMessageRequestItemTest { @Test void testGxsSyncMessageRequestItem() { var gxsId = GxsId.fromString("11111111111111111111111111111111"); var now = Instant.now(); var lastUpdated = now.minus(Duration.ofDays(30)); var syncLimit = Duration.ofDays(365); var request = new GxsSyncMessageRequestItem(gxsId, lastUpdated, syncLimit); assertEquals(lastUpdated.getEpochSecond(), request.getLastUpdated()); assertTrue(Math.abs(now.minus(syncLimit).getEpochSecond() - request.getLimit()) <= 1); // GxsSyncMessageRequestItem uses Instant.now() internally so we have to give some slack } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/gxstunnel/TunnelPeerInfoTest.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.gxstunnel; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class TunnelPeerInfoTest { @Test void checkIfMessageAlreadyReceivedAndRecord() { var tunnelPeerInfo = new TunnelPeerInfo(); assertFalse(tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(1L)); assertFalse(tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(2L)); assertTrue(tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(1L)); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/heartbeat/HeartbeatTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.heartbeat; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.xrs.service.heartbeat.item.HeartbeatItem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class HeartbeatTest { @InjectMocks private HeartbeatRsService heartbeatRsService; @Test @SuppressWarnings("java:S2699") void HandleHeartbeat_Success() { var peerConnection = new PeerConnection(Location.createLocation("foo"), null); heartbeatRsService.handleItem(peerConnection, new HeartbeatItem()); // The service does nothing } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/identity/IdentityManagerTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity; import io.xeres.app.database.model.gxs.IdentityGroupItemFakes; import io.xeres.app.net.peer.PeerConnectionFakes; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.IdentityService; import io.xeres.common.id.GxsId; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class IdentityManagerTest { @Mock private IdentityRsService identityRsService; @Mock private IdentityService identityService; @Mock private PeerConnectionManager peerConnectionManager; @InjectMocks private IdentityManager identityManager; @Test void AddOneAndRequest_Success() { var gxsId = IdentityGroupItemFakes.createIdentityGroupItem(); var peerConnection = PeerConnectionFakes.createPeerConnection(); when(identityService.findByGxsId(gxsId.getGxsId())).thenReturn(Optional.empty()); when(peerConnectionManager.getPeerByLocation(peerConnection.getLocation().getId())).thenReturn(peerConnection); identityManager.getGxsGroup(peerConnection, gxsId.getGxsId()); identityManager.requestGxsIds(); verify(identityRsService).requestGxsGroups(peerConnection, List.of(gxsId.getGxsId())); } @Test @SuppressWarnings("unchecked") void AddSixAndRequest_Success() { var gxsId1 = IdentityGroupItemFakes.createIdentityGroupItem(); var gxsId2 = IdentityGroupItemFakes.createIdentityGroupItem(); var gxsId3 = IdentityGroupItemFakes.createIdentityGroupItem(); var gxsId4 = IdentityGroupItemFakes.createIdentityGroupItem(); var gxsId5 = IdentityGroupItemFakes.createIdentityGroupItem(); var gxsId6 = IdentityGroupItemFakes.createIdentityGroupItem(); var peerConnection = PeerConnectionFakes.createPeerConnection(); when(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty()); when(peerConnectionManager.getPeerByLocation(anyLong())).thenReturn(peerConnection); identityManager.getGxsGroup(peerConnection, gxsId1.getGxsId()); identityManager.getGxsGroup(peerConnection, gxsId2.getGxsId()); identityManager.getGxsGroup(peerConnection, gxsId3.getGxsId()); identityManager.getGxsGroup(peerConnection, gxsId4.getGxsId()); identityManager.getGxsGroup(peerConnection, gxsId5.getGxsId()); identityManager.getGxsGroup(peerConnection, gxsId6.getGxsId()); identityManager.requestGxsIds(); ArgumentCaptor> ids = ArgumentCaptor.forClass(List.class); verify(identityRsService).requestGxsGroups(eq(peerConnection), ids.capture()); assertEquals(5, ids.getValue().size()); Set allGxsIds = new HashSet<>(); allGxsIds.add(gxsId1.getGxsId()); allGxsIds.add(gxsId2.getGxsId()); allGxsIds.add(gxsId3.getGxsId()); allGxsIds.add(gxsId4.getGxsId()); allGxsIds.add(gxsId5.getGxsId()); allGxsIds.add(gxsId6.getGxsId()); ids.getValue().forEach(allGxsIds::remove); assertEquals(1, allGxsIds.size()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/identity/IdentityRsServiceTest.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.identity; import io.xeres.app.crypto.pgp.PGP; import io.xeres.app.database.model.gxs.GxsMessageItem; import io.xeres.app.database.model.identity.IdentityFakes; import io.xeres.app.database.model.profile.ProfileFakes; import io.xeres.app.service.IdentityService; import io.xeres.app.service.ProfileService; import io.xeres.app.service.SettingsService; import io.xeres.app.service.notification.contact.ContactNotificationService; import io.xeres.app.xrs.service.gxs.GxsHelperService; import io.xeres.app.xrs.service.identity.item.IdentityGroupItem; import io.xeres.common.id.GxsId; import io.xeres.common.id.Id; import io.xeres.common.id.ProfileFingerprint; import jakarta.persistence.EntityNotFoundException; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.security.Security; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class IdentityRsServiceTest { @Mock private SettingsService settingsService; @Mock private ProfileService profileService; @Mock private IdentityService identityService; @Mock private GxsHelperService gxsHelperService; @Mock private ContactNotificationService contactNotificationService; @InjectMocks private IdentityRsService identityRsService; @BeforeAll static void setup() { Security.addProvider(new BouncyCastleProvider()); } @Test void CreateOwnIdentity_Anonymous_Success() { var name = "test"; when(settingsService.isOwnProfilePresent()).thenReturn(true); when(settingsService.hasOwnLocation()).thenReturn(true); when(identityService.save(any(IdentityGroupItem.class))).thenAnswer(invocation -> invocation.getArguments()[0]); identityRsService.generateOwnIdentity(name, false); var gxsIdGroupItem = ArgumentCaptor.forClass(IdentityGroupItem.class); verify(identityService).save(gxsIdGroupItem.capture()); assertEquals(name, gxsIdGroupItem.getValue().getName()); } @Test void CreateOwnIdentity_Signed_Success() throws IOException { var name = "test"; var encodedKey = new byte[]{-107, 1, 30, 4, 96, -83, 89, -119, 1, 2, 0, -124, 36, -16, 89, 77, 70, 111, 82, 42, 104, 115, 27, 52, -67, 56, -116, 80, 71, 109, -9, 78, -113, 115, -22, -35, 97, 121, 34, -118, 90, -6, -68, 113, 78, -58, -120, -4, -123, -1, 46, 10, -19, 122, -84, 21, -24, 118, 82, 12, -1, 45, -56, -94, -21, -25, -3, -68, 17, 45, 9, -26, -33, 86, -53, 0, 17, 1, 0, 1, -2, 3, 3, 2, 120, 82, -62, 47, -20, 15, -47, -114, 96, -60, -67, 67, 56, -82, 79, -17, 82, -40, 17, 72, 39, -53, -72, 25, 52, -94, 103, -31, 92, -51, 53, -29, 119, -26, 20, 81, 94, -29, -20, 104, 103, 56, -53, -53, 28, 6, -82, -33, 92, -31, -18, -4, 73, 55, 97, -89, 38, -21, 123, 30, -28, 76, -122, 20, 89, -28, -112, -29, 32, -116, -75, -19, -113, 123, -23, -42, 122, 13, 1, -46, -70, -69, 87, -41, -104, -49, 101, 22, 79, -63, -112, -120, 79, 25, 16, -2, -77, 118, 110, -109, -33, -100, -11, -126, -73, -64, 125, 56, 101, 49, -89, 19, -61, 125, 103, 121, 82, -15, 109, 2, 105, -103, -11, 31, -68, -117, -81, -14, 7, -9, 98, 18, 96, -26, 70, 66, -64, 108, -2, -6, 114, -13, 44, -103, 81, -28, 80, 115, 124, 74, -28, -53, 53, -44, -118, 20, -94, -113, -43, 109, 111, 82, -21, 34, 80, -50, 62, 127, -38, -10, 108, -49, -123, 44, -39, 116, -90, 61, 41, -40, -127, -84, 111, -127, -68, -75, 106, -9, -81, 37, -40, -120, 36, 62, 12, 45, 15, -88, 9, -51, -24, -96, 68, -38, 125, -76, 4, 116, 101, 115, 116, -120, 92, 4, 16, 1, 2, 0, 6, 5, 2, 96, -83, 89, -119, 0, 10, 9, 16, -119, -55, 33, -4, 60, -108, 116, -23, -92, -19, 1, -4, 10, -89, 1, 44, 82, -29, 24, 104, -128, -73, -96, 122, -38, 67, -120, 18, 62, 10, 3, 95, 27, -51, -45, -114, -113, -93, 118, 13, -20, 3, -35, 8, 15, 97, 27, 76, 20, 9, 78, 74, -24, 27, -99, -58, -125, -69, -103, -13, 50, -83, -117, -115, -123, 25, 52, 39, -122, -22, 81, 46, 84, 22, -52, 17}; var secretKey = PGP.getPGPSecretKey(encodedKey); var publicKey = secretKey.getPublicKey(); var fingerprint = publicKey.getFingerprint(); var ownProfile = ProfileFakes.createProfile(name, PGP.getPGPIdentifierFromFingerprint(fingerprint), fingerprint, publicKey.getEncoded()); ownProfile.setProfileFingerprint(new ProfileFingerprint(secretKey.getPublicKey().getFingerprint())); ownProfile.setPgpPublicKeyData(secretKey.getPublicKey().getEncoded()); when(settingsService.isOwnProfilePresent()).thenReturn(true); when(settingsService.hasOwnLocation()).thenReturn(true); when(profileService.getOwnProfile()).thenReturn(ownProfile); when(settingsService.getSecretProfileKey()).thenReturn(encodedKey); when(identityService.save(any(IdentityGroupItem.class))).thenAnswer(invocation -> invocation.getArguments()[0]); identityRsService.generateOwnIdentity(name, true); var gxsIdGroupItem = ArgumentCaptor.forClass(IdentityGroupItem.class); verify(identityService).save(gxsIdGroupItem.capture()); assertEquals(name, gxsIdGroupItem.getValue().getName()); assertNotNull(gxsIdGroupItem.getValue().getProfileHash()); assertNotNull(gxsIdGroupItem.getValue().getProfileSignature()); } @Test void SaveIdentityImage_Success() throws IOException { var id = 1L; var identity = IdentityFakes.createOwn(); var file = new MockMultipartFile("file", IdentityRsServiceTest.class.getResourceAsStream("/image/leguman.jpg")); when(identityService.findById(id)).thenReturn(Optional.of(identity)); when(identityService.save(identity)).thenReturn(identity); identityRsService.saveOwnIdentityImage(id, file); assertNotNull(identity.getImage()); verify(identityService).findById(id); verify(identityService).save(identity); } @Test void SaveIdentityImage_NotOwn_Error() { var id = 2L; var file = mock(MultipartFile.class); assertThrows(EntityNotFoundException.class, () -> identityRsService.saveOwnIdentityImage(id, file)); } @Test void SaveIdentityImage_EmptyImage_Error() { var id = 1L; assertThrows(IllegalArgumentException.class, () -> identityRsService.saveOwnIdentityImage(id, null)); } @Test void SaveIdentityImage_ImageTooBig_Error() { var id = 1L; var file = mock(MultipartFile.class); when(file.getSize()).thenReturn(1024 * 1024 * 11L); when(identityService.findById(id)).thenReturn(Optional.of(IdentityFakes.createOwn())); assertThrows(IllegalArgumentException.class, () -> identityRsService.saveOwnIdentityImage(id, file), "Avatar image size is bigger than " + (1024 * 1024 * 10) + " bytes"); } @Test void DeleteIdentityImage_Success() { var id = 1L; var identity = IdentityFakes.createOwn(); identity.setImage(new byte[1]); when(identityService.findById(id)).thenReturn(Optional.of(identity)); when(identityService.save(identity)).thenReturn(identity); identityRsService.deleteOwnIdentityImage(id); assertNull(identity.getImage()); verify(identityService).findById(id); verify(identityService).save(identity); } @Test void DeleteIdentityImage_NotOwn_Error() { var id = 2L; assertThrows(EntityNotFoundException.class, () -> identityRsService.deleteOwnIdentityImage(id)); } @Test void MakeProfileHash_Success() { var computedHash = IdentityRsService.makeProfileHash(GxsId.fromString("bb3851c00134a29f921cb3643a4525a9"), new ProfileFingerprint(Id.toBytes("C984CC1237437B5983A2031070DC1676FA60F825"))); assertEquals("778db3511ba29027dd85f324c58717d05c4e3f30", computedHash.toString()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/rtt/RttRsServiceTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.rtt; import io.xeres.app.database.model.location.Location; import io.xeres.app.net.peer.PeerConnection; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.xrs.service.RsService; import io.xeres.app.xrs.service.rtt.item.RttPingItem; import io.xeres.app.xrs.service.rtt.item.RttPongItem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class RttRsServiceTest { @Mock private PeerConnectionManager peerConnectionManager; @InjectMocks private RttRsService rttRsService; @Test void HandlePing_Success() { var sequence = 1; var timestamp = 2L; var peerConnection = new PeerConnection(Location.createLocation("foo"), null); rttRsService.handleItem(peerConnection, new RttPingItem(sequence, timestamp)); var rttPongItem = ArgumentCaptor.forClass(RttPongItem.class); verify(peerConnectionManager).writeItem(eq(peerConnection), rttPongItem.capture(), any(RsService.class)); assertEquals(timestamp, rttPongItem.getValue().getPingTimestamp()); assertNotEquals(0, rttPongItem.getValue().getPongTimestamp()); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/status/IdleCheckerTest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class IdleCheckerTest { @Mock private GetIdleTime getIdleTime; @InjectMocks IdleChecker idleChecker; @Test void GetIdleTime() { var idleTime = 1; when(getIdleTime.getIdleTime()).thenReturn(idleTime); var result = idleChecker.getIdleTime(); assertEquals(idleTime, result); verify(getIdleTime).getIdleTime(); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/status/StatusRsServiceTest.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.net.peer.PeerConnectionManager; import io.xeres.app.service.LocationService; import io.xeres.app.service.notification.availability.AvailabilityNotificationService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; import static io.xeres.common.location.Availability.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class StatusRsServiceTest { @Mock private PeerConnectionManager peerConnectionManager; @Mock private LocationService locationService; @Mock private AvailabilityNotificationService availabilityNotificationService; @Mock private DatabaseSessionManager databaseSessionManager; @InjectMocks private StatusRsService statusRsService; @Test void Change_Availability_All_Success() { var ownLocation = LocationFakes.createOwnLocation(); when(locationService.findOwnLocation()).thenReturn(Optional.of((ownLocation))); statusRsService.changeAvailability(BUSY); statusRsService.changeAvailability(AWAY); statusRsService.changeAvailability(AVAILABLE); verify(availabilityNotificationService).changeAvailability(ownLocation, BUSY); verify(availabilityNotificationService).changeAvailability(ownLocation, AWAY); verify(availabilityNotificationService).changeAvailability(ownLocation, AVAILABLE); } @Test void Change_Availability_Away_And_Back_Success() { var ownLocation = LocationFakes.createOwnLocation(); when(locationService.findOwnLocation()).thenReturn(Optional.of((ownLocation))); statusRsService.changeAvailability(AWAY); statusRsService.changeAvailability(AVAILABLE); statusRsService.changeAvailability(AWAY); verify(availabilityNotificationService).changeAvailability(ownLocation, AVAILABLE); verify(availabilityNotificationService, times(2)).changeAvailability(ownLocation, AWAY); } @Test void Manual_Prevents_Automatic() { var ownLocation = LocationFakes.createOwnLocation(); when(locationService.findOwnLocation()).thenReturn(Optional.of((ownLocation))); statusRsService.changeAvailabilityAutomatically(AWAY); statusRsService.changeAvailabilityAutomatically(AVAILABLE); // Lock statusRsService.changeAvailability(BUSY); statusRsService.changeAvailabilityAutomatically(AWAY); // Unlock statusRsService.changeAvailability(AVAILABLE); statusRsService.changeAvailabilityAutomatically(AWAY); verify(availabilityNotificationService, times(2)).changeAvailability(ownLocation, AVAILABLE); verify(availabilityNotificationService, times(2)).changeAvailability(ownLocation, AWAY); verify(availabilityNotificationService).changeAvailability(ownLocation, BUSY); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/status/StatusTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.status; import org.junit.jupiter.api.Test; import static io.xeres.app.xrs.service.status.ChatStatus.*; import static org.junit.jupiter.api.Assertions.assertEquals; class StatusTest { @Test void Enum_Order_Fixed() { assertEquals(0, OFFLINE.ordinal()); assertEquals(1, AWAY.ordinal()); assertEquals(2, BUSY.ordinal()); assertEquals(3, ONLINE.ordinal()); assertEquals(4, INACTIVE.ordinal()); assertEquals(5, values().length); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/turtle/HashBloomFilterTest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.service.file.HashBloomFilter; import io.xeres.testutils.Sha1SumFakes; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class HashBloomFilterTest { @Test void Add_Success() { var filter = new HashBloomFilter(null, 10_000, 0.01d); var s1 = Sha1SumFakes.createSha1Sum(); var s2 = Sha1SumFakes.createSha1Sum(); filter.add(s1); filter.add(s2); assertTrue(filter.mightContain(s1)); assertTrue(filter.mightContain(s2)); filter.clear(); assertFalse(filter.mightContain(s1)); assertFalse(filter.mightContain(s2)); } @Test void Add_Multiple_Success() { var filter = new HashBloomFilter(null, 10_000, 0.01d); var s1 = Sha1SumFakes.createSha1Sum(); var s2 = Sha1SumFakes.createSha1Sum(); var s3 = Sha1SumFakes.createSha1Sum(); var s4 = Sha1SumFakes.createSha1Sum(); var s5 = Sha1SumFakes.createSha1Sum(); var s6 = Sha1SumFakes.createSha1Sum(); var in = List.of(s1, s2, s3); var out = List.of(s4, s5, s6); filter.addAll(in); assertTrue(filter.mightContainAll(in)); assertFalse(filter.mightContainAll(out)); } } ================================================ FILE: app/src/test/java/io/xeres/app/xrs/service/turtle/TurtleRsServiceTest.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.app.xrs.service.turtle; import io.xeres.app.database.DatabaseSessionManager; import io.xeres.app.database.model.location.LocationFakes; import io.xeres.app.service.LocationService; import io.xeres.app.xrs.service.turtle.item.TurtleTunnelRequestItem; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class TurtleRsServiceTest { @Mock private LocationService locationService; @Mock private DatabaseSessionManager databaseSessionManager; @InjectMocks private TurtleRsService turtleRsService; @Test void GeneratePersonalFilePrint_Success() { // Values have been taken directly from Retroshare to make sure there's no signed/unsigned bugs var ownLocation = LocationFakes.createLocation("Test", null, LocationIdentifier.fromString("d3b9c7ceb75c7c68b5e3c6446259c8e7")); when(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation)); turtleRsService.initialize(); var item = mock(TurtleTunnelRequestItem.class); when(item.getHash()).thenReturn(new Sha1Sum(Id.toBytes("ac39b8f761465b1460948973e8fe754f4e101700"))); var result = turtleRsService.generatePersonalFilePrint(item.getHash(), 1_833_303_450, true); assertEquals(3_280_770_886L, Integer.toUnsignedLong(result)); } } ================================================ FILE: app/src/test/java/io/xeres/testutils/FakeHttpServer.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.BindException; import java.net.InetSocketAddress; import java.util.concurrent.ThreadLocalRandom; public class FakeHttpServer { private int port; private final HttpServer httpServer; private byte[] requestBody; public FakeHttpServer(String path, int responseCode, byte[] responseBody) { port = ThreadLocalRandom.current().nextInt(2048, 28000); httpServer = createHttpServer(); var handler = (HttpHandler) exchange -> { requestBody = exchange.getRequestBody().readAllBytes(); exchange.sendResponseHeaders(responseCode, responseBody != null ? responseBody.length : -1); if (responseBody != null) { exchange.getResponseBody().write(responseBody); } exchange.close(); }; httpServer.createContext(path, handler); httpServer.start(); } public byte[] getRequestBody() { return requestBody; } public void shutdown() { httpServer.stop(0); } public int getPort() { return port; } private HttpServer createHttpServer() { var address = new InetSocketAddress(port); try { return HttpServer.create(address, 0); } catch (BindException _) { port++; return createHttpServer(); } catch (IOException e) { throw new RuntimeException("I/O error: " + e.getMessage()); } } } ================================================ FILE: app/src/test/java/io/xeres/testutils/ResourceUtils.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; public final class ResourceUtils { private ResourceUtils() { throw new UnsupportedOperationException("Utility class"); } public static File getResourceAsFile(String resourcePath) { try (InputStream in = ResourceUtils.class.getClassLoader().getResourceAsStream(resourcePath)) { if (in == null) return null; File tempFile = File.createTempFile("xeres_test_resource_", ".tmp"); tempFile.deleteOnExit(); try (FileOutputStream out = new FileOutputStream(tempFile)) { byte[] buffer = new byte[1024]; int bytesRead; while ((bytesRead = in.read(buffer)) != -1) { out.write(buffer, 0, bytesRead); } } return tempFile; } catch (IOException e) { throw new RuntimeException("Couldn't copy test resource: " + e.getMessage()); } } } ================================================ FILE: app/src/test/resources/application-default.properties ================================================ # # Copyright (c) 2019-2023 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # spring.datasource.url=jdbc:h2:mem:userdata server.port=0 server.ssl.enabled=false ================================================ FILE: app/src/test/resources/upnp/routers/RT-AC87U.xml ================================================ 1 1 urn:schemas-upnp-org:device:InternetGatewayDevice:1 RT-AC87U ASUSTek http://www.asus.com/ ASUS Wireless Router RT-AC87U 384.13 http://www.asus.com/ 88:d7:f6:44:f8:d8 uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d8 urn:schemas-upnp-org:service:Layer3Forwarding:1 urn:upnp-org:serviceId:L3Forwarding1 /L3F.xml /ctl/L3F /evt/L3F urn:schemas-upnp-org:device:WANDevice:1 WANDevice MiniUPnP http://miniupnp.free.fr/ WAN Device WAN Device 20200628 http://miniupnp.free.fr/ 88:d7:f6:44:f8:d8 uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d9 000000000000 urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 urn:upnp-org:serviceId:WANCommonIFC1 /WANCfg.xml /ctl/CmnIfCfg /evt/CmnIfCfg urn:schemas-upnp-org:device:WANConnectionDevice:1 WANConnectionDevice MiniUPnP http://miniupnp.free.fr/ MiniUPnP daemon MiniUPnPd 20200628 http://miniupnp.free.fr/ 88:d7:f6:44:f8:d8 uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8da 000000000000 urn:schemas-upnp-org:service:WANIPConnection:1 urn:upnp-org:serviceId:WANIPConn1 /WANIPCn.xml /ctl/IPConn /evt/IPConn http://192.168.1.1:80/ ================================================ FILE: build.gradle ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ buildscript { ext { apacheCommonsCollectionsVersion = "4.5.0" apacheCommonsLangVersion = "3.20.0" appDirsVersion = "1.5.0" archunitVersion = "1.4.2" bouncycastleVersion = "1.84" commonMarkVersion = "0.28.0" flywayDbVersion = "11.7.2" // Only used by the plugin, keep in sync with spring-boot from time to time graalvmVersion = "25.0.3" jnaVersion = "5.18.1" jpackageVersion = "2.0.1" jsoupVersion = "1.22.2" junitVersion = "6.0.3" sonarqubeVersion = "7.3.0.8198" springBootVersion = "4.0.6" springOpenApiVersion = "3.0.3" twelveMonkeysVersion = "3.13.1" zxingVersion = "3.5.4" } } plugins { id 'org.springframework.boot' version "$springBootVersion" apply false id 'org.flywaydb.flyway' version "$flywayDbVersion" apply false id 'org.panteleyev.jpackageplugin' version "$jpackageVersion" apply false id 'org.sonarqube' version "$sonarqubeVersion" id 'com.bakdata.mockito' version "2.2.0" apply false // This plugin loads mockito as a java agent to avoid warnings } // This gives a git-like version for git builds but a proper version // when the release is built with a tag def getVersionName = providers.exec { commandLine("git", "describe", "--tags") }.standardOutput.asText.get().substring(1).trim() subprojects { group = 'io.xeres' version = "${getVersionName}" apply plugin: 'java' apply plugin: 'jacoco' java { sourceCompatibility = '25' } compileJava { options.encoding = 'UTF-8' } compileTestJava { options.encoding = 'UTF-8' } repositories { mavenCentral() } } sonarqube { properties { property "sonar.projectKey", "zapek_Xeres" property "sonar.organization", "zapek" property "sonar.host.url", "https://sonarcloud.io" } } ================================================ FILE: common/build.gradle ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { id 'java-test-fixtures' id 'com.bakdata.mockito' } test { useJUnitPlatform() test.jvmArgs "-ea", "-Djava.net.preferIPv4Stack=true", "-Dfile.encoding=UTF-8" } jacocoTestReport { reports { xml.required = true html.required = false } } javadoc { options.overview = "src/main/javadoc/overview.html" } dependencies { implementation(platform(SpringBootPlugin.BOM_COORDINATES)) testFixturesImplementation(platform(SpringBootPlugin.BOM_COORDINATES)) implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-jackson' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation "org.apache.commons:commons-lang3:$apacheCommonsLangVersion" implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:$springOpenApiVersion" implementation "net.harawata:appdirs:$appDirsVersion" implementation 'dev.mccue:imgscalr:2023.09.03' implementation 'com.github.depsypher:pngtastic:1.8' testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: "com.vaadin.external.google", module: "android-json" } testImplementation "com.tngtech.archunit:archunit-junit5:$archunitVersion" testFixturesImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: "com.vaadin.external.google", module: "android-json" } testFixturesImplementation "org.apache.commons:commons-lang3:$apacheCommonsLangVersion" } ================================================ FILE: common/src/main/java/io/xeres/common/AppName.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common; public final class AppName { public static final String NAME = "Xeres"; private AppName() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/Features.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common; public final class Features { /** * Enable experimental generation of Elliptic Curve keys. */ public static final boolean EXPERIMENTAL_EC = false; /** * Use patch for the settings. Should always be enabled * unless the patch support breaks. It currently relies on a Jackson * module and a default JSON-P implementation. */ public static final boolean USE_PATCH_SETTINGS = true; private Features() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/annotation/RsDeprecated.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.annotation; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; /** * Marks a program element as deprecated. It's the same as the {@link Deprecated} annotation, except * there's no compiler warnings about it and, hence, no urgency to remove them. *

* Old Retroshare clients can indeed stay in the network for a long time. */ @Documented @Retention(RetentionPolicy.SOURCE) @Target(value = {CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, MODULE, PARAMETER, TYPE}) public @interface RsDeprecated { /** * Returns the version of Retroshare in which the annotated element became deprecated. * The version string is in the same format as the Retroshare release version (for example 0.6.7). * * @return the version string */ String since() default ""; } ================================================ FILE: common/src/main/java/io/xeres/common/condition/OnLinuxCondition.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.condition; import org.apache.commons.lang3.SystemUtils; import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.type.AnnotatedTypeMetadata; public class OnLinuxCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { return SystemUtils.IS_OS_LINUX; } } ================================================ FILE: common/src/main/java/io/xeres/common/condition/OnMacCondition.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.condition; import org.apache.commons.lang3.SystemUtils; import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.type.AnnotatedTypeMetadata; public class OnMacCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { return SystemUtils.IS_OS_MAC; } } ================================================ FILE: common/src/main/java/io/xeres/common/condition/OnWindowsCondition.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.condition; import org.apache.commons.lang3.SystemUtils; import org.springframework.context.annotation.Condition; import org.springframework.context.annotation.ConditionContext; import org.springframework.core.type.AnnotatedTypeMetadata; public class OnWindowsCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { return SystemUtils.IS_OS_WINDOWS; } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/board/BoardGroupDTO.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.board; import io.xeres.common.id.GxsId; import java.time.Instant; public record BoardGroupDTO( long id, GxsId gxsId, String name, String description, boolean hasImage, boolean subscribed, boolean external, int visibleMessageCount, Instant lastActivity ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/board/BoardMessageDTO.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.board; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import java.time.Instant; public record BoardMessageDTO( long id, GxsId gxsId, MsgId msgId, long originalId, long parentId, GxsId authorGxsId, String authorName, String name, Instant published, String link, String content, boolean hasImage, int imageWidth, int imageHeight, boolean read ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/channel/ChannelFileDTO.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.channel; import io.xeres.common.id.Sha1Sum; import jakarta.validation.constraints.NotNull; public record ChannelFileDTO( long size, Sha1Sum hash, @NotNull(message = "Name is mandatory") String name, String path, int age ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/channel/ChannelGroupDTO.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.channel; import io.xeres.common.id.GxsId; import java.time.Instant; public record ChannelGroupDTO( long id, GxsId gxsId, String name, String description, boolean hasImage, boolean subscribed, boolean external, int visibleMessageCount, Instant lastActivity ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/channel/ChannelMessageDTO.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.channel; import com.fasterxml.jackson.annotation.JsonInclude; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Objects; import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY; public record ChannelMessageDTO( long id, GxsId gxsId, MsgId msgId, long originalId, long parentId, GxsId authorGxsId, String authorName, String name, Instant published, String content, boolean hasImage, int imageWidth, int imageHeight, boolean hasFiles, @JsonInclude(NON_EMPTY) List files, boolean read ) { public ChannelMessageDTO { if (files == null) { files = new ArrayList<>(); } } @Override public boolean equals(Object o) { if (!(o instanceof ChannelMessageDTO that)) { return false; } return Objects.equals(gxsId, that.gxsId); } @Override public int hashCode() { return Objects.hashCode(gxsId); } @Override public String toString() { return "ChannelMessageDTO{" + "gxsId=" + gxsId + ", name='" + name + '\'' + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/chat/ChatBacklogDTO.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import java.time.Instant; public record ChatBacklogDTO(Instant created, boolean own, String message) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/chat/ChatIdentityDTO.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import io.xeres.common.id.GxsId; public record ChatIdentityDTO( String nickname, GxsId gxsId, long identityId ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/chat/ChatRoomBacklogDTO.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import io.xeres.common.id.GxsId; import java.time.Instant; public record ChatRoomBacklogDTO(Instant created, GxsId gxsId, String nickname, String message) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/chat/ChatRoomContextDTO.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; public record ChatRoomContextDTO( ChatRoomsDTO chatRooms, ChatIdentityDTO identity ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/chat/ChatRoomDTO.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import io.xeres.common.message.chat.RoomType; public record ChatRoomDTO( long id, String name, RoomType roomType, String topic, int count, boolean isSigned ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/chat/ChatRoomsDTO.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import java.util.List; public record ChatRoomsDTO( List subscribed, List available ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/connection/ConnectionDTO.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.connection; import java.time.Instant; // XXX: missing PeerAddress in the DTO public record ConnectionDTO( long id, String address, Instant lastConnected, boolean external ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/forum/ForumGroupDTO.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.forum; import io.xeres.common.id.GxsId; import java.time.Instant; public record ForumGroupDTO( long id, GxsId gxsId, String name, String description, boolean subscribed, boolean external, int visibleMessageCount, Instant lastActivity ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/forum/ForumMessageDTO.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.forum; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import java.time.Instant; public record ForumMessageDTO( long id, GxsId gxsId, MsgId msgId, long originalId, long parentId, GxsId authorGxsId, String authorName, String name, Instant published, String content, boolean read ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/identity/IdentityConstants.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.identity; public final class IdentityConstants { // Those must be like the profile, because the name is derived from it public static final int NAME_LENGTH_MIN = 2; public static final int NAME_LENGTH_MAX = 30; public static final long NO_IDENTITY_ID = 0L; public static final long OWN_IDENTITY_ID = 1L; private IdentityConstants() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/identity/IdentityDTO.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.identity; import io.xeres.common.id.GxsId; import io.xeres.common.identity.Type; import java.time.Instant; import java.util.Objects; public record IdentityDTO( long id, String name, GxsId gxsId, Instant updated, Type type, boolean hasImage ) { @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (IdentityDTO) o; return name.equals(that.name) && gxsId.equals(that.gxsId); } @Override public int hashCode() { return Objects.hash(name, gxsId); } @Override public String toString() { return "IdentityDTO{" + "name='" + name + '\'' + ", gxsId=" + gxsId + ", type=" + type + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/location/LocationConstants.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.location; public final class LocationConstants { public static final int NAME_LENGTH_MIN = 1; public static final int NAME_LENGTH_MAX = 64; public static final long OWN_LOCATION_ID = 1L; private LocationConstants() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/location/LocationDTO.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.location; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.xeres.common.dto.connection.ConnectionDTO; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.location.Availability; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY; public record LocationDTO( long id, @NotNull(message = "Name is mandatory") @JsonProperty("name") String name, @Size(min = LocationIdentifier.LENGTH, max = LocationIdentifier.LENGTH) byte @NotNull(message = "Location identifier is mandatory") [] locationIdentifier, String hostname, @JsonInclude(NON_EMPTY) List connections, boolean connected, Instant lastConnected, Availability availability, String version ) { public LocationDTO { if (connections == null) { connections = new ArrayList<>(); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (LocationDTO) o; return name.equals(that.name) && Arrays.equals(locationIdentifier, that.locationIdentifier); } @Override public int hashCode() { var result = Objects.hash(name); result = 31 * result + Arrays.hashCode(locationIdentifier); return result; } @Override public String toString() { return "LocationDTO{" + "name='" + name + '\'' + ", locationIdentifier=" + Arrays.toString(locationIdentifier) + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/profile/ProfileConstants.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.profile; public final class ProfileConstants { public static final int NAME_LENGTH_MIN = 2; public static final int NAME_LENGTH_MAX = 30; public static final long NO_PROFILE_ID = 0L; public static final long OWN_PROFILE_ID = 1L; private ProfileConstants() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/profile/ProfileDTO.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.profile; import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.id.ProfileFingerprint; import io.xeres.common.pgp.Trust; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY; import static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MAX; import static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MIN; public record ProfileDTO( long id, @NotNull(message = "Name is mandatory") @Size(message = "Name length must be between " + NAME_LENGTH_MIN + " and " + NAME_LENGTH_MAX + " characters", min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX) String name, String pgpIdentifier, // a string is used instead of a long because JS is limited to 53-bits Instant created, @Size(min = ProfileFingerprint.V4_LENGTH, max = ProfileFingerprint.LENGTH) @Schema(example = "nhgF6ITwm/LLqchhpwJ91KFfAxg=") byte[] pgpFingerprint, byte[] pgpPublicKeyData, boolean accepted, Trust trust, @JsonInclude(NON_EMPTY) List locations ) { public ProfileDTO { if (locations == null) { locations = new ArrayList<>(); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (ProfileDTO) o; return name.equals(that.name) && Objects.equals(pgpIdentifier, that.pgpIdentifier) && Arrays.equals(pgpFingerprint, that.pgpFingerprint) && Arrays.equals(pgpPublicKeyData, that.pgpPublicKeyData); } @Override public int hashCode() { var result = Objects.hash(name, pgpIdentifier); result = 31 * result + Arrays.hashCode(pgpFingerprint); result = 31 * result + Arrays.hashCode(pgpPublicKeyData); return result; } @Override public String toString() { return "ProfileDTO{" + "name='" + name + '\'' + ", pgpIdentifier='" + pgpIdentifier + '\'' + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/settings/SettingsDTO.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.settings; public record SettingsDTO( String torSocksHost, int torSocksPort, String i2pSocksHost, int i2pSocksPort, boolean upnpEnabled, boolean broadcastDiscoveryEnabled, boolean dhtEnabled, boolean autoStartEnabled, String incomingDirectory, String remotePassword, boolean remoteEnabled, boolean upnpRemoteEnabled, int remotePort ) { } ================================================ FILE: common/src/main/java/io/xeres/common/dto/share/ShareConstants.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.share; public final class ShareConstants { public static final int NAME_LENGTH_MIN = 1; public static final int NAME_LENGTH_MAX = 64; public static final long INCOMING_SHARE = 1L; private ShareConstants() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/dto/share/ShareDTO.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.share; import io.xeres.common.pgp.Trust; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.Instant; import java.util.Objects; import static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MAX; import static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MIN; public record ShareDTO( long id, @NotNull(message = "Name is mandatory") @Size(message = "Name length must be between " + NAME_LENGTH_MIN + " and " + NAME_LENGTH_MAX + " characters", min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX) String name, @NotNull(message = "Path is mandatory") @Size(message = "Path length must be between 1 and 255 characters", min = 1, max = 255) String path, boolean searchable, Trust browsable, Instant lastScanned ) { @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var shareDTO = (ShareDTO) o; return id == shareDTO.id && searchable == shareDTO.searchable && Objects.equals(name, shareDTO.name) && Objects.equals(path, shareDTO.path) && browsable == shareDTO.browsable; } @Override public int hashCode() { return Objects.hash(name); } @Override public String toString() { return "ShareDTO{" + "name='" + name + '\'' + ", path='" + path + '\'' + ", searchable=" + searchable + ", browsable=" + browsable + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/events/ConnectWebSocketsEvent.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.events; /** * This event is sent when it's time to perform the WebSockets connections. */ public record ConnectWebSocketsEvent() { } ================================================ FILE: common/src/main/java/io/xeres/common/events/StartupEvent.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.events; /** * First event that is sent when the application is starting. */ public record StartupEvent() implements SynchronousEvent { } ================================================ FILE: common/src/main/java/io/xeres/common/events/SynchronousEvent.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.events; /** * This interface marker is applied to events that should be sent synchronously. */ public interface SynchronousEvent { } ================================================ FILE: common/src/main/java/io/xeres/common/file/FileType.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.file; import io.xeres.common.i18n.I18nEnum; import io.xeres.common.i18n.I18nUtils; import java.util.Locale; import java.util.ResourceBundle; import java.util.Set; public enum FileType implements I18nEnum { ANY(Set.of()), AUDIO(Set.of( "3ga", // Adaptive Multi-Rate Audio Codec "8svx", // Amiga IFF-8SVX File "aac", // Advanced Audio Coding "ac3", // Dolby Digital "aif", // Audio Interchange File Format "aifc", // Audio Interchange File Format "aiff", // Audio Interchange File Format "amr", // Adaptive Multi-Rate Audio Codec "ape", // Monkey's Audio Lossless Audio File "au", // Audio File (Sun Microsystems) "aud", // General Audio File "audio", // General Audio File "cda", // CD Audio Track "dmf", // D-Lusion Music Format Module "dsm", // Digital Sound Interface Kit Module "dts", // DTS Encoded Audio File "far", // Farandole Composer Module "flac", // Free Lossless Audio Codec File "it", // Impulse Tracker Module "m1a", // MPEG-1 Audio File "m2a", // MPEG-2 Audio File "m3u", // Multimedia Playlist File "m3u8", // Multimedia Playlist File (UTF-8) "m4a", // MPEG-4 Audio File "mdl", // DigiTrakker Module "med", // OctaMED Module "mid", // MIDI File "midi", // MIDI File "mka", // Matroska Audio File "mod", // Amiga Music Module (SoundTracker, ProTracker, etc...) "mp1", // MPEG Audio Layer I File "mp2", // MPEG Audio Layer II File "mp3", // MPEG Audio Layer III File "mpa", // MPEG Audio File "mpc", // Musepack "mtm", // MultiTracker Module "ogg", // Ogg Vorbis Audio File "psm", // ProTracker Studio Module "ptm", // PolyTracker Module "ra", // Real Audio File "ram", // Real Audio Meta File "rmi", // RIFF MIDI Music File "s3m", // ScreamTracker 3 Module "snd", // Audio File (Sun Microsystems) "stm", // ScreamTracker 2 Module "umx", // Unreal Engine 1 Music Format "wav", // Waveform Audio File Format "weba", // WebM Audio "wma", // Windows Media Audio "xm" // FastTracker 2 Extended Module )), ARCHIVE(Set.of( "7z", // 7-Zip "ace", // WinAce "adf", // Amiga Disk File "adz", // Amiga Disk File, GZipped "alz", // ALZip "arc", // ARC "arj", // ARJ "bin", // CD Image "br", // Brotli "bwa", // BlindWrite Disk Information File "bwi", // BlindWrite CD/DVD Disc Image "bws", // BlindWrite Sub Code File "bwt", // BlindWrite 4 Disk Image "bz2", // Bzip "cab", // Microsoft's Cabinet File "ccd", // CloneCD Disk Image "cif", // Easy CD Creator "cue", // CDWrite Cue Sheet File "dmg", // MacOS Disk Image "dms", // The DiskMasher System Amiga Disk Archiver "dsk", // Floppy Disk Archiving "gb", // Game Boy ROMs "gba", // GBA ROMs "gz", // GNU Zip "hdf", // UAE HardFile "hqx", // BinHex 4.0 "img", // Disk Image Data File "iso", // Disc Image File "lha", // LHA "lzh", // LZH "mdf", // Media Disc Image File "mds", // Daemon Tools "nrg", // Nero Burning Rom CD/DVD Image File "pak", // PAK "par", // Parchive Index "par2", // Parchive 2 Index "rar", // WinRAR "ratdvd", // RatDVD Disk Image "rom", // Emulator ROMs "sea", // StuffIt Archive "sfc", // SNES ROMs "sit", // StuffIt Archive "sitx", // Stuffit X Archive "tar", // Tape Archive (Unix) "tbz2", // BZip2-ed Tar File "tgz", // GZipped Tar File "toast", // Toast Disc Image "vhdx", // Hyper-V Virtual Hard Disk "z", // Unix Compress "zip", // Zipped Archive "zst" // Zstandard )), DOCUMENT(Set.of( "adoc", // AsciiDoc "asc", // Text File "cbr", // Comic Book RAR Archive "cbz", // Comic Book ZIP Archive "chm", // Microsoft's Compiled HTML "css", // Cascading Style Sheet "csv", // Comma Separated Values "diz", // Description in Zip file "doc", // Microsoft Word Document "dot", // Microsoft Word Template "epub", // E-Books "hlp", // Microsoft Help File "htm", // HTML File "html", // HTML File "log", // Log File "md", // Markdown File "msg", // Outlook Mail Message File "nfo", // Warez Information File "ods", // Open Document Spreadsheet "odt", // Open Document Document "ott", // Open Document Template "pdf", // Portable Document Format "pps", // PowerPoint Slide Show "ppt", // PowerPoint Template "ps", // PostScript File "rtf", // Rich Text Format File "text", // Text File "txt", // Text File "wpd", // WordPerfect "wps", // Microsoft Works "wri", // Windows Write "xls", // Microsoft Excel Spreadsheet "xml" // eXtended Markup Language )), PICTURE(Set.of( "3dm", // OpenNURBS Initiative 3D Model "3dmf", // QuickDraw 3D Metafile "ai", // Adobe Illustrator "avif", // AV1 Image File Format "bmp", // Bitmap Image File "drw", // CADS Planner Drawing "dxf", // AutoCAD "emf", // Enhanced Windows Metafile "eps", // Encapsulated PostScript "gif", // Graphical Interchange Format File "heic", // High Efficiency Image Format "heif", // High Efficiency Image Format "ico", // Windows Icon file "iff", // Interchange File Format (Amiga) "indd", // Adobe InDesign "jfif", // JPEG File Interchange Format "jpe", // JPEG Image File "jpeg", // JPEG Image File "jpg", // JPEG Image File "lbm", // IFF Interleaved Bitmap "mng", // Multiple-Image Network Graphics Bitmap "pct", // PICT Picture File "pcx", // Paintbrush Bitmap Image File "pgm", // Portable GrayMap Bitmap File "pic", // PICT Picture File "pict", // PICT Picture File "pix", // Alias PIX Bitmap "png", // Portable Network Graphic "psd", // Photoshop Document "psp", // Paint Shop Pro Image File "qxd", // QuarkXPress "qxp", // QuarkXPress "rgb", // ColorViewSquash Bitmap "sgi", // Silicon Graphics Bitmap "svg", // Scalable Vector Graphics "tga", // Targa Graphic "tif", // Tagged Image File "tiff", // Tagged Image File "webp", // WebP Image File "wmf", // Windows Metafile "wmp", // Windows Media Photo File "xbm", // X Bitmap File "xcf", // GIMP Image "xif" // ScanSoft Pagis Extended Image Format File )), PROGRAM(Set.of( "apk", // Android Package "app", // MacOS Application Bundle "appimage", // AppImage "cmd", // Command File "com", // DOS executable "deb", // Debian Package "exe", // Executable File "flatpak", // Linux Flatpak Application Bundle "jar", // Java Archive "msi", // Microsoft Installer "pkg", // MacOS Installer "rpm", // RedHat Package "snap", // Canonical Snap Linux "xpi" // Mozilla Installer )), VIDEO(Set.of( "3g2", // 3GPP Multimedia File "3gp", // 3GPP Multimedia File "3gp2", // 3GPP Multimedia File "3gpp", // 3GPP Multimedia File "amv", // Anime Music Video File "asf", // Advanced Systems Format File "asx", // Advanced Stream Redirector File "avi", // Audio Video Interleave File "bik", // BINK Video File "divx", // DivX Movie File "dvr-ms", // Microsoft Digital Video Recording "flc", // FLIC Video File "fli", // FLIC Video File "flic", // FLIC Video File "flv", // Flash Video File "hdmov", // High-Definition QuickTime Movie "ifo", // DVD-Video Disc Information File "m1v", // MPEG-1 Video File "m2t", // MPEG-2 Video Transport Stream "m2ts", // MPEG-2 Video Transport Stream "m2v", // MPEG-2 Video File "m4b", // MPEG-4 Video File "m4v", // MPEG-4 Video File "mkv", // Matroska Video File "mov", // QuickTime Movie File "movie", // QuickTime Movie File "mp1v", // MPEG-1 Video File "mp2v", // MPEG-2 Video File "mp4", // MPEG-4 Video File "mpe", // MPEG Video File "mpeg", // MPEG Video File "mpg", // MPEG Video File "mpv", // MPEG Video File "mpv1", // MPEG-1 Video File "mpv2", // MPEG-2 Video File "ogm", // Ogg Media File "pva", // MPEG Video File "qt", // QuickTime Movie "rm", // Real Media File "rmm", // Real Media File "rmvb", // Real Video Variable Bit Rate File "rv", // Real Video File "smil", // SMIL Presentation File "smk", // Smacker Compressed Movie File "swf", // Macromedia Flash Movie "tp", // Video Transport Stream File "ts", // Video Transport Stream File "vid", // General Video File "video", // General Video File "vob", // DVD Video Object File "vp6", // TrueMotion VP6 Video File "webm", // WebM "wm", // Windows Media Video File "wmv", // Windows Media Video File "xvid" // Xvid-Encoded Video File )), SUBTITLES(Set.of( "srt", // SubRip "sub" )), COLLECTION(Set.of( "emulecollection", // Emule "rscollection", // Retroshare "torrent" // Torrent )), DIRECTORY(Set.of()); private final Set extensions; private final ResourceBundle bundle = I18nUtils.getBundle(); FileType(Set extensions) { this.extensions = extensions; } public Set getExtensions() { return extensions; } @Override public String toString() { return bundle.getString(getMessageKey(this)); } public static FileType getTypeByExtension(String filename) { var index = filename.lastIndexOf("."); if (index == -1) { return ANY; } var extension = filename.substring(index + 1); if (extension.isEmpty()) { return ANY; } for (var value : values()) { if (value.getExtensions().contains(extension.toLowerCase(Locale.ROOT))) { return value; } } return ANY; } } ================================================ FILE: common/src/main/java/io/xeres/common/geoip/Country.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.geoip; import io.xeres.common.i18n.I18nEnum; import io.xeres.common.i18n.I18nUtils; import java.util.ResourceBundle; /** * The list of country codes. * @see Wikipedia ISO-3166-1 Alpha 2 */ public enum Country implements I18nEnum { AF, AL, DZ, AS, AD, AO, AI, AQ, AG, AR, AM, AW, AU, AT, AZ, BS, BH, BD, BB, BY, BE, BZ, BJ, BM, BT, BO, BA, BW, BV, BR, IO, BN, BG, BF, BI, KH, CM, CA, CV, KY, CF, TD, CL, CN, CX, CC, CO, KM, CG, CD, CK, CR, CI, HR, CU, CY, CZ, DK, DJ, DM, DO, EC, EG, SV, GQ, ER, EE, ET, FK, FO, FJ, FI, FR, GF, PF, TF, GA, GM, GE, DE, GH, GI, GR, GL, GD, GP, GU, GT, GG, GN, GW, GY, HT, HM, VA, HN, HK, HU, IS, IN, ID, IR, IQ, IE, IM, IL, IT, JM, JP, JE, JO, KZ, KE, KI, KP, KR, KW, KG, LA, LV, LB, LS, LR, LY, LI, LT, LU, MO, MK, MG, MW, MY, MV, ML, MT, MH, MQ, MR, MU, YT, MX, FM, MD, MC, MN, ME, MS, MA, MZ, MM, NA, NR, NP, NL, AN, NC, NZ, NI, NE, NG, NU, NF, MP, NO, OM, PK, PW, PS, PA, PG, PY, PE, PH, PN, PL, PT, PR, QA, RE, RO, RU, RW, SH, KN, LC, PM, VC, WS, SM, ST, SA, SN, RS, SC, SL, SG, SK, SI, SB, SO, ZA, GS, SS, ES, LK, SD, SR, SJ, SZ, SE, CH, SY, TW, TJ, TZ, TH, TL, TG, TK, TO, TT, TN, TR, TM, TC, TV, UG, UA, AE, GB, US, UM, UY, UZ, VU, VE, VN, VG, VI, WF, EH, YE, ZM, ZW, LAN, TOR, I2P; private final ResourceBundle bundle = I18nUtils.getBundle(); @Override public String toString() { return bundle.getString(getMessageKey(this)); } } ================================================ FILE: common/src/main/java/io/xeres/common/gxs/GxsGroupConstants.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.gxs; public final class GxsGroupConstants { public static final int IMAGE_SIDE_SIZE = 128; // GXS groups are squared private GxsGroupConstants() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/i18n/I18nEnum.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.i18n; import org.apache.commons.lang3.StringUtils; import java.util.Locale; public interface I18nEnum { /** * Returns the message key for an enum. The format is: * {@code enum.(.).} all in lower case. * * @param e the enum * @return the enum message key */ default String getMessageKey(Enum e) { var enumClass = e.getClass(); var sb = new StringBuilder("enum."); if (enumClass.getEnclosingClass() != null) { sb.append(getAsKebabCase(enumClass.getEnclosingClass().getSimpleName())); sb.append("."); } sb.append(getAsKebabCase(enumClass.getSimpleName())); sb.append("."); sb.append(e.name().toLowerCase(Locale.ROOT)); return sb.toString(); } private static String getAsKebabCase(String input) { return StringUtils.join(StringUtils.splitByCharacterTypeCamelCase(input), "-").toLowerCase(Locale.ROOT); } } ================================================ FILE: common/src/main/java/io/xeres/common/i18n/I18nUtils.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.i18n; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.IllformedLocaleException; import java.util.Locale; import java.util.ResourceBundle; public final class I18nUtils { private static final Logger log = LoggerFactory.getLogger(I18nUtils.class); private static final String BUNDLE = "i18n.messages"; private static final ResourceBundle RESOURCE_BUNDLE_INSTANCE = createBundle(); private I18nUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Gets the ResourceBundle. *

* Note: prefer using the ResourceBundle bean for spring components. * * @return the resource bundle */ public static ResourceBundle getBundle() { return RESOURCE_BUNDLE_INSTANCE; } private static ResourceBundle createBundle() { var envLanguage = System.getenv("XERES_LANGUAGE"); if (envLanguage != null) { try { Locale.setDefault(new Locale.Builder().setLanguage(envLanguage).build()); } catch (IllformedLocaleException e) { log.error("Locale {} is ill formed: {}", envLanguage, e.getMessage()); } } return ResourceBundle.getBundle(BUNDLE); } } ================================================ FILE: common/src/main/java/io/xeres/common/id/GxsId.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Embeddable; import java.util.Arrays; import java.util.Objects; @Embeddable public class GxsId implements Identifier, Comparable { public static final int LENGTH = 16; public static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR private byte[] identifier; public GxsId() { } public GxsId(byte[] identifier) { Objects.requireNonNull(identifier, "Null identifier"); if (identifier.length != LENGTH) { throw new IllegalArgumentException("Bad identifier length, expected " + LENGTH + ", got " + identifier.length); } this.identifier = identifier; } /** * Creates a {@link GxsId} from a string. * * @param from a string representing the GxsId in hexadecimal form (lowercase, no prefix) * @return the GxsId or an empty GxsId if the string was invalid */ public static GxsId fromString(String from) { return new GxsId(Identifier.parseString(from, LENGTH)); } @Override public byte[] getBytes() { return identifier; } // This is used for serialization (for example passing a GxsId in a STOMP message) public void setBytes(byte[] identifier) { this.identifier = identifier; } @JsonIgnore @Override public int getLength() { return LENGTH; } @JsonIgnore @Override public byte[] getNullIdentifier() { return NULL_IDENTIFIER; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var gxsId = (GxsId) o; return Arrays.equals(identifier, gxsId.identifier); } @Override public int hashCode() { return Arrays.hashCode(identifier); } @Override public String toString() { return Id.toString(identifier); } @Override public int compareTo(GxsId o) { return Arrays.compareUnsigned(identifier, o.identifier); } } ================================================ FILE: common/src/main/java/io/xeres/common/id/Id.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import org.apache.commons.lang3.ArrayUtils; import java.util.HexFormat; import java.util.Locale; import static org.apache.commons.lang3.StringUtils.isEmpty; /** * Contains ID conversion and representation methods. Used for locations, PGP identifiers, identities and so on. */ public final class Id { private Id() { throw new UnsupportedOperationException("Utility class"); } /** * Converts a series of bytes into its hexadecimal representation. For example if the * byte array contains 2 bytes like 28 then 3, the result is "1c03". * * @param id the id as a stream of bytes * @return the lowercase hexadecimal representation of those bytes, without any prefix or an empty string if the id is null or empty */ public static String toString(byte[] id) { if (ArrayUtils.isEmpty(id)) { return ""; } var sb = new StringBuilder(id.length * 2); for (var b : id) { HexFormat.of().toHexDigits(sb, b); } return sb.toString(); } /** * Converts a hexadecimal string into an array of bytes. For example * if the input contains "1c03", then the result is an array of 2 bytes with 28 then 3. * * @param id the values as a lowercase hexadecimal series of bytes, without prefix * @return an array of bytes containing those values or an empty array if the id is null or empty * @throws NumberFormatException if this is not a hexadecimal number */ public static byte[] toBytes(String id) { if (isEmpty(id)) { return new byte[0]; } if (id.length() % 2 != 0) { throw new NumberFormatException("Odd number of bytes"); } var out = new byte[id.length() / 2]; for (var i = 0; i < out.length; i++) { var index = i * 2; out[i] = (byte) Integer.parseUnsignedInt(id.substring(index, index + 2), 16); } return out; } /** * Converts an id into its hexadecimal representation. * * @param id the id * @return a hexadecimal uppercase representation of the id, without prefix */ public static String toString(long id) { return toStringLowerCase(id).toUpperCase(Locale.ROOT); } /** * Converts an id into its hexadecimal representation. * * @param id the id * @return a hexadecimal lowercase representation of the id, without prefix */ public static String toStringLowerCase(long id) { return HexFormat.of().toHexDigits(id, 16); } /** * Converts an identifier into its hexadecimal representation. * * @param identifier the identifier * @return a hexadecimal lowercase representation of the identifier, without prefix, or an empty string if the identifier is empty */ public static String toString(Identifier identifier) { if (identifier == null) { return ""; } return toString(identifier.getBytes()); } /** * Converts a string containing a hexadecimal ASCII representation of bytes into an array of * the corresponding byte values. For example, if the string contains "3133" (0x31 ('1') and 0x33 ('3')) * which represents 0x13, the result is an array of bytes which is { 0x13 }. * * @param id a string of hexadecimal ASCII values * @return an array of corresponding values */ public static byte[] asciiStringToBytes(String id) { return asciiToBytes(id.getBytes()); } /** * Converts an array containing a hexadecimal ASCII representation of bytes into an array of * the corresponding byte values. For example, if the array contains 0x31 ('1') and 0x33 ('3') * which represents 0x13, the result is an array of bytes which is { 0x13 }. * * @param id an array of hexadecimal ASCII values * @return an array of corresponding values */ public static byte[] asciiToBytes(byte[] id) { if (ArrayUtils.isEmpty(id)) { throw new IllegalArgumentException("id is null or empty"); } if (id.length % 2 == 1) { throw new IllegalArgumentException("id is not even"); } var result = new byte[id.length / 2]; byte number; var accumulator = 0; for (var i = 0; i < id.length; i++) { number = id[i]; if (number >= 'a') { if (number > 'f') { throw new IllegalArgumentException("id has an invalid ascii value: " + number); } number -= 'a'; number += (byte) 10; } else if (number >= '0') { number -= '0'; } else { throw new IllegalArgumentException("id has an invalid ascii value: " + number); } if (i % 2 == 1) { result[i / 2] = (byte) (accumulator * 16 + number); } else { accumulator = number; } } return result; } /** * Converts an identifier to its ASCII representation. For example if the identifier is 0x12, then its * ASCII representation will be { 0x31, 0x32 } ('1' and '2'). * * @param identifier an identifier * @return the byte array containing the ASCII values of each number of the identifier. The array is twice as long as the input */ public static byte[] toAsciiBytes(Identifier identifier) { return Id.toString(identifier).getBytes(); } /** * Same as {@link #toAsciiBytes(Identifier)} but in upper case. * * @param identifier an identifier * @return the byte array containing the ASCII values of each number of the identifier in upper case. The array * is twice as long as the input */ public static byte[] toAsciiBytesUpperCase(Identifier identifier) { return Id.toString(identifier).toUpperCase(Locale.ROOT).getBytes(); } } ================================================ FILE: common/src/main/java/io/xeres/common/id/Identifier.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import com.fasterxml.jackson.annotation.JsonIgnore; import java.util.Arrays; /** * Interface that represents an identifier of an object in the Retroshare protocol that doesn't fit * in a primitive type. *
* Note that, unlike Retroshare, there's no identifier full of zeroes, they are null instead. */ public interface Identifier { String LENGTH_FIELD_NAME = "LENGTH"; String NULL_FIELD_NAME = "NULL_IDENTIFIER"; /** * Gets a byte representation of the identifier. * * @return an array of bytes containing the identifier */ byte[] getBytes(); /** * Gets how many bytes are needed to store the identifier. * * @return the length of the identifier */ int getLength(); /** * Gets the representation of the identifier. To be used every time the identity is needed * as a string (UI, headers, etc...). * * @return a string representation */ @Override String toString(); @JsonIgnore default byte[] getNullIdentifier() { return createNullIdentifier(getLength()); } default boolean isNullIdentifier() { return Arrays.equals(getNullIdentifier(), getBytes()); } static byte[] createNullIdentifier(int length) { var a = new byte[length]; Arrays.fill(a, (byte) 0); return a; } static byte[] parseString(String s, int length) { byte[] bytes; if (s == null || s.length() != length * 2) { bytes = createNullIdentifier(length); } else { try { bytes = Id.toBytes(s); } catch (NumberFormatException _) { bytes = createNullIdentifier(length); } } return bytes; } } ================================================ FILE: common/src/main/java/io/xeres/common/id/LocationIdentifier.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Embeddable; import java.util.Arrays; import java.util.Objects; @Embeddable public class LocationIdentifier implements Identifier, Comparable { public static final int LENGTH = 16; public static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR private byte[] identifier; public LocationIdentifier() { } public LocationIdentifier(byte[] identifier) { Objects.requireNonNull(identifier, "Null identifier"); if (identifier.length != LENGTH) { throw new IllegalArgumentException("Bad identifier length, expected " + LENGTH + ", got " + identifier.length); } this.identifier = identifier; } /** * Creates a {@link LocationIdentifier} from a string. * * @param from the string representing the Location identifier in hexadecimal form (lowercase, no prefix) * @return the LocationIdentifier or an empty LocationIdentifier if the string was invalid */ public static LocationIdentifier fromString(String from) { return new LocationIdentifier(Identifier.parseString(from, LENGTH)); } @Override public byte[] getBytes() { return identifier; } // This is used for serialization (for example passing a GxsId in a STOMP message) public void setBytes(byte[] identifier) { this.identifier = identifier; } @JsonIgnore @Override public int getLength() { return LENGTH; } @JsonIgnore @Override public byte[] getNullIdentifier() { return NULL_IDENTIFIER; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (LocationIdentifier) o; return Arrays.equals(identifier, that.identifier); } @Override public int hashCode() { return Arrays.hashCode(identifier); } @Override public String toString() { return Id.toString(identifier); } @Override public int compareTo(LocationIdentifier o) { return Arrays.compare(identifier, o.identifier); } } ================================================ FILE: common/src/main/java/io/xeres/common/id/MsgId.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Embeddable; import java.util.Arrays; import java.util.Objects; @Embeddable public class MsgId implements Identifier, Comparable { public static final int LENGTH = 20; public static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR private byte[] identifier; public MsgId() { } public MsgId(byte[] identifier) { Objects.requireNonNull(identifier, "Null identifier"); if (identifier.length != LENGTH) { throw new IllegalArgumentException("Bad identifier length, expected " + LENGTH + ", got " + identifier.length); } this.identifier = identifier; } /** * Creates a {@link MsgId} from a string. * * @param from a string representing the MsgId in hexadecimal form (lowercase, no prefix) * @return the MsgId or an empty MsgId if the string was invalid */ public static MsgId fromString(String from) { return new MsgId(Identifier.parseString(from, LENGTH)); } @Override public byte[] getBytes() { return identifier; } // This is used for serialization (for example passing a GxsId in a STOMP message) public void setBytes(byte[] identifier) { this.identifier = identifier; } @JsonIgnore @Override public int getLength() { return LENGTH; } @JsonIgnore @Override public byte[] getNullIdentifier() { return NULL_IDENTIFIER; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var msgId = (MsgId) o; return Arrays.equals(identifier, msgId.identifier); } @Override public int hashCode() { return Arrays.hashCode(identifier); } @Override public String toString() { return Id.toString(identifier); } @Override public int compareTo(MsgId o) { return Arrays.compare(identifier, o.identifier); } } ================================================ FILE: common/src/main/java/io/xeres/common/id/ProfileFingerprint.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Embeddable; import java.util.Arrays; import java.util.Objects; @Embeddable public class ProfileFingerprint implements Identifier { public static final int V4_LENGTH = 20; public static final int LENGTH = 32; private byte[] identifier; public ProfileFingerprint() { } public ProfileFingerprint(byte[] identifier) { Objects.requireNonNull(identifier, "Null identifier"); if (identifier.length != V4_LENGTH && identifier.length != LENGTH) { throw new IllegalArgumentException("Bad identifier length, expected " + V4_LENGTH + " or " + LENGTH + ", got " + identifier.length); } this.identifier = identifier; } @JsonIgnore @Override public byte[] getBytes() { return identifier; } // This is used for serialization (for example passing a ProfileFingerprint in a STOMP message) public void setBytes(byte[] identifier) { this.identifier = identifier; } @Override public int getLength() { if (identifier == null) { throw new IllegalStateException("getLength() called on ProfileFingerprint, which doesn't support null identifiers"); } return identifier.length; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (ProfileFingerprint) o; return Arrays.equals(identifier, that.identifier); } @Override public int hashCode() { return Arrays.hashCode(identifier); } @Override public String toString() { var s = Id.toString(identifier); var out = new StringBuilder(); var length = identifier.length * 2; for (var i = 0; i < length; i += 4) { if (i > 0) { if (i == 20) { out.append(" "); } else { out.append(" "); } } out.append(s, i, i + 4); } return out.toString(); } } ================================================ FILE: common/src/main/java/io/xeres/common/id/Sha1Sum.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Embeddable; import java.util.Arrays; import java.util.Objects; @Embeddable public class Sha1Sum implements Identifier, Cloneable, Comparable { public static final int LENGTH = 20; public static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR private byte[] identifier; public Sha1Sum() { // Needed for JPA } public Sha1Sum(byte[] sum) { Objects.requireNonNull(sum, "Null sha1 sum"); if (sum.length != LENGTH) { throw new IllegalArgumentException("Bad sha1 sum length, expected " + LENGTH + ", got " + sum.length); } identifier = sum; } /** * Creates a {@link Sha1Sum} from a string. * * @param from a string representing the Sha1Sum in hexadecimal form (lowercase, no prefix) * @return the Sha1Sum or an empty Sha1Sum if the string was invalid */ public static Sha1Sum fromString(String from) { return new Sha1Sum(Identifier.parseString(from, LENGTH)); } @Override public byte[] getBytes() { return identifier; } // This is used for serialization (for example passing a GxsId in a STOMP message) public void setBytes(byte[] identifier) { this.identifier = identifier; } @JsonIgnore @Override public int getLength() { return LENGTH; } @JsonIgnore @Override public byte[] getNullIdentifier() { return NULL_IDENTIFIER; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var that = (Sha1Sum) o; return Arrays.equals(identifier, that.identifier); } @Override public int hashCode() { return Arrays.hashCode(identifier); } @Override public String toString() { return Id.toString(identifier); } @Override public Sha1Sum clone() { try { var clone = (Sha1Sum) super.clone(); clone.identifier = identifier.clone(); return clone; } catch (CloneNotSupportedException _) { throw new AssertionError(); } } @Override public int compareTo(Sha1Sum o) { return Arrays.compare(identifier, o.identifier); } } ================================================ FILE: common/src/main/java/io/xeres/common/identity/Type.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.identity; public enum Type { /** * Anything else then the below options. */ OTHER, /** * Own identity- */ OWN, /** * Identity owned by a friend. */ FRIEND, /** * Banned identity. */ BANNED } ================================================ FILE: common/src/main/java/io/xeres/common/location/Availability.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.location; import io.xeres.common.i18n.I18nEnum; import io.xeres.common.i18n.I18nUtils; import java.util.ResourceBundle; public enum Availability implements I18nEnum { AVAILABLE, BUSY, AWAY, OFFLINE; private final ResourceBundle bundle = I18nUtils.getBundle(); @Override public String toString() { return bundle.getString(getMessageKey(this)); } } ================================================ FILE: common/src/main/java/io/xeres/common/message/MessageHeaders.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message; public final class MessageHeaders { public static final String MESSAGE_TYPE = "messageType"; public static final String DESTINATION_ID = "destinationId"; private MessageHeaders() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/message/MessagePath.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message; public final class MessagePath { public static final String BROKER_PREFIX = "/topic"; public static final String DIRECT_PREFIX = "/queue"; public static final String APP_PREFIX = "/app"; public static final String CHAT_ROOT = "/chat"; public static final String CHAT_PRIVATE_DESTINATION = "/private"; public static final String CHAT_ROOM_DESTINATION = "/room"; public static final String CHAT_BROADCAST_DESTINATION = "/broadcast"; public static final String CHAT_DISTANT_DESTINATION = "/distant"; public static final String VOIP_ROOT = "/voip"; public static final String VOIP_PRIVATE_DESTINATION = "/private"; private MessagePath() { throw new UnsupportedOperationException("Utility class"); } public static String chatPrivateDestination() { return BROKER_PREFIX + CHAT_ROOT + CHAT_PRIVATE_DESTINATION; } public static String chatRoomDestination() { return BROKER_PREFIX + CHAT_ROOT + CHAT_ROOM_DESTINATION; } public static String chatBroadcastDestination() { return BROKER_PREFIX + CHAT_ROOT + CHAT_BROADCAST_DESTINATION; } public static String chatDistantDestination() { return BROKER_PREFIX + CHAT_ROOT + CHAT_DISTANT_DESTINATION; } public static String voipPrivateDestination() { return BROKER_PREFIX + VOIP_ROOT + VOIP_PRIVATE_DESTINATION; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/MessageType.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message; public enum MessageType { NONE, // use this when not needing any CHAT_PRIVATE_MESSAGE, CHAT_ROOM_MESSAGE, CHAT_ROOM_LIST, CHAT_BROADCAST_MESSAGE, CHAT_TYPING_NOTIFICATION, CHAT_ROOM_JOIN, CHAT_ROOM_LEAVE, CHAT_ROOM_TYPING_NOTIFICATION, CHAT_ROOM_USER_JOIN, CHAT_ROOM_USER_LEAVE, CHAT_ROOM_USER_KEEP_ALIVE, CHAT_ROOM_USER_TIMEOUT, CHAT_ROOM_INVITE, CHAT_AVATAR, CHAT_AVAILABILITY } ================================================ FILE: common/src/main/java/io/xeres/common/message/MessagingConfiguration.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message; public final class MessagingConfiguration { /** * The maximum size of a message (1 MB). */ public static final int MAXIMUM_MESSAGE_SIZE = 1024 * 1024; private MessagingConfiguration() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatAvatar.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; public class ChatAvatar { private byte[] image; @SuppressWarnings("unused") // Needed for JSON public ChatAvatar() { } public ChatAvatar(byte[] image) { this.image = image; } public byte[] getImage() { return image; } public void setImage(byte[] image) { this.image = image; } @Override public String toString() { return "ChatAvatar{" + "image size=" + image.length + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatBacklog.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import java.time.Instant; public record ChatBacklog(Instant created, boolean own, String message) { } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatConstants.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import java.time.Duration; public final class ChatConstants { public static final Duration TYPING_NOTIFICATION_DELAY = Duration.ofSeconds(5); private ChatConstants() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatMessage.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import com.fasterxml.jackson.annotation.JsonIgnore; /** * Used to send messages from a chat client to a web socket only. * If a chat message has no content, it's a notification. */ public class ChatMessage { private String content; private boolean own; public ChatMessage() { // Needed for JSON } public ChatMessage(String message) { content = message; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public boolean isOwn() { return own; } public void setOwn(boolean own) { this.own = own; } @JsonIgnore public boolean isEmpty() { return content == null; } @Override public String toString() { return "ChatMessage{" + "content='" + content + "'" + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomBacklog.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import io.xeres.common.id.GxsId; import java.time.Instant; public record ChatRoomBacklog(Instant created, GxsId gxsId, String nickname, String message) { } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomContext.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; public record ChatRoomContext(ChatRoomLists chatRoomLists, ChatRoomUser ownUser) { } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomInfo.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import java.util.Objects; public class ChatRoomInfo { private long id; private String name; private RoomType roomType; private String topic; private int count; private boolean isSigned; private boolean newMessages; public ChatRoomInfo() { } public ChatRoomInfo(String name) { this.name = name; } public ChatRoomInfo(long id, String name, RoomType roomType, String topic, int count, boolean isSigned) { this.id = id; this.name = name; this.roomType = roomType; this.topic = topic; this.count = count; this.isSigned = isSigned; } public long getId() { return id; } public void setId(long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public RoomType getRoomType() { return roomType; } public void setRoomType(RoomType roomType) { this.roomType = roomType; } public String getTopic() { return topic; } public void setTopic(String topic) { this.topic = topic; } public int getCount() { return count; } public void setCount(int count) { this.count = count; } public boolean isSigned() { return isSigned; } public void setSigned(boolean signed) { isSigned = signed; } public boolean hasNewMessages() { return newMessages; } public void setNewMessages(boolean newMessages) { this.newMessages = newMessages; } public boolean isReal() { return id != 0L; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var roomInfo = (ChatRoomInfo) o; return id == roomInfo.id; } @Override public int hashCode() { return Objects.hash(id); } @Override public String toString() { return name; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomInviteEvent.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; public class ChatRoomInviteEvent { private String locationIdentifier; private String roomName; private String roomTopic; @SuppressWarnings("unused") // Needed for JSON public ChatRoomInviteEvent() { } public ChatRoomInviteEvent(String locationIdentifier, String roomName, String roomTopic) { this.locationIdentifier = locationIdentifier; this.roomName = roomName; this.roomTopic = roomTopic; } public String getRoomName() { return roomName; } public void setRoomName(String roomName) { this.roomName = roomName; } public String getRoomTopic() { return roomTopic; } public void setRoomTopic(String roomTopic) { this.roomTopic = roomTopic; } public String getLocationIdentifier() { return locationIdentifier; } public void setLocationIdentifier(String locationIdentifier) { this.locationIdentifier = locationIdentifier; } @Override public String toString() { return "ChatRoomInviteEvent{" + "locationIdentifier='" + locationIdentifier + '\'' + ", roomName='" + roomName + '\'' + ", roomTopic='" + roomTopic + '\'' + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomLists.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import java.util.ArrayList; import java.util.List; public class ChatRoomLists { private List subscribedRooms = new ArrayList<>(); private List availableRooms = new ArrayList<>(); public void addSubscribed(ChatRoomInfo chatRoomInfo) { subscribedRooms.add(chatRoomInfo); } public void addAvailable(ChatRoomInfo chatRoomInfo) { availableRooms.add(chatRoomInfo); } @SuppressWarnings("unused") // Needed for JSON serialization public void setSubscribedRooms(List subscribedRooms) { this.subscribedRooms = subscribedRooms; } @SuppressWarnings("unused") // Needed for JSON serialization public void setAvailableRooms(List availableRooms) { this.availableRooms = availableRooms; } public List getSubscribedRooms() { return subscribedRooms; } public List getAvailableRooms() { return availableRooms; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomMessage.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import com.fasterxml.jackson.annotation.JsonIgnore; import io.xeres.common.id.GxsId; public class ChatRoomMessage { private long roomId; private String senderNickname; private GxsId gxsId; private String content; public ChatRoomMessage() { // Needed for JSON } public ChatRoomMessage(String senderNickname, GxsId gxsId, String content) { this.senderNickname = senderNickname; this.gxsId = gxsId; this.content = content; } public long getRoomId() { return roomId; } public void setRoomId(long roomId) { this.roomId = roomId; } public String getSenderNickname() { return senderNickname; } public void setSenderNickname(String senderNickname) { this.senderNickname = senderNickname; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } @JsonIgnore public boolean isOwn() { return senderNickname == null; } @JsonIgnore public boolean isEmpty() { return content == null; } @Override public String toString() { return "ChatRoomMessage{" + "roomId=" + roomId + ", senderNickname='" + senderNickname + '\'' + ", gxsId'" + gxsId + '\'' + ", content='" + content + '\'' + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomTimeoutEvent.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import io.xeres.common.id.GxsId; public class ChatRoomTimeoutEvent { private GxsId gxsId; private boolean split; @SuppressWarnings("unused") // Needed for JSON public ChatRoomTimeoutEvent() { } public ChatRoomTimeoutEvent(GxsId gxsId, boolean split) { this.gxsId = gxsId; this.split = split; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public boolean isSplit() { return split; } public void setSplit(boolean split) { this.split = split; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomUser.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import io.xeres.common.id.GxsId; public record ChatRoomUser(String nickname, GxsId gxsId, long identityId) { } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/ChatRoomUserEvent.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import io.xeres.common.id.GxsId; public class ChatRoomUserEvent { private GxsId gxsId; private String nickname; private long identityId; @SuppressWarnings("unused") // Needed for JSON public ChatRoomUserEvent() { } public ChatRoomUserEvent(GxsId gxsId, String nickname, long identityId) { this.gxsId = gxsId; this.nickname = nickname; this.identityId = identityId; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public String getNickname() { return nickname != null ? nickname : ""; // Workaround against users having a null nickname } public void setNickname(String nickname) { this.nickname = nickname; } public long getIdentityId() { return identityId; } public void setIdentityId(long identityId) { this.identityId = identityId; } } ================================================ FILE: common/src/main/java/io/xeres/common/message/chat/RoomType.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.chat; import io.xeres.common.i18n.I18nEnum; import io.xeres.common.i18n.I18nUtils; import java.util.ResourceBundle; public enum RoomType implements I18nEnum { PRIVATE, PUBLIC; private final ResourceBundle bundle = I18nUtils.getBundle(); @Override public String toString() { return bundle.getString(getMessageKey(this)); } } ================================================ FILE: common/src/main/java/io/xeres/common/message/voip/VoipAction.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.voip; public enum VoipAction { RING, ACKNOWLEDGE, CLOSE } ================================================ FILE: common/src/main/java/io/xeres/common/message/voip/VoipMessage.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.message.voip; public class VoipMessage { private VoipAction action; @SuppressWarnings("unused") // Needed for JSON public VoipMessage() { } public VoipMessage(VoipAction action) { this.action = action; } public VoipAction getAction() { return action; } public void setAction(VoipAction action) { this.action = action; } @Override public String toString() { return "VoipMessage{" + "action=" + action + '}'; } } ================================================ FILE: common/src/main/java/io/xeres/common/mui/MUI.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.mui; import io.xeres.common.AppName; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.swing.*; import java.awt.*; import java.awt.event.*; import java.io.IOException; import java.util.Objects; /** * MUI: the Minimal User Interface. *

* Just an interface to show some error to the user when failing to start in non-headless mode. It also contains a minimal shell. *

* Without Xeres, MUI wouldn't exist :) */ public final class MUI { private static final Logger log = LoggerFactory.getLogger(MUI.class); private static final String PROMPT = "1.SYS:> "; private static JFrame shellFrame; private static JTextArea textArea; private static Shell shell; private static String currentLine = ""; private MUI() { throw new UnsupportedOperationException("Utility class"); } public static void setShell(Shell shell) { MUI.shell = shell; } /** * Shows an informational message. *

* Only use this when JavaFX is not available (for example displaying command arguments on Windows). * * @param message the message to display to the user */ public static void showInformation(String message) { JOptionPane.showMessageDialog(null, message, AppName.NAME + " Output", JOptionPane.INFORMATION_MESSAGE); } /** * Shows an error. *

* Only use this when JavaFX is not available. Typically, when its initialization goes wrong. * @param e the Exception */ public static void showError(Exception e) { Throwable exception = e; while (exception.getCause() != null) { exception = exception.getCause(); } showError(exception.getMessage()); } private static void showError(String message) { var scrollPane = new JScrollPane(); scrollPane.setPreferredSize(new Dimension(640, 240)); var textArea = new JTextArea(message); textArea.setEditable(false); textArea.setLineWrap(true); textArea.setWrapStyleWord(true); textArea.setMargin(new Insets(8, 8, 8, 8)); scrollPane.getViewport().setView(textArea); JOptionPane.showMessageDialog(null, scrollPane, AppName.NAME + " Runtime Problem", JOptionPane.ERROR_MESSAGE); } public static void openShell() { if (shellFrame == null) { createShellFrame(shell); } if (!shellFrame.isVisible()) { appendToTextArea(""" New Shell process 1 Type 'help' for more information. """); textArea.setCaretPosition(textArea.getDocument().getLength()); shellFrame.setVisible(true); } shellFrame.toFront(); textArea.requestFocus(); } private static void createShellFrame(Shell shell) { Objects.requireNonNull(shell, "a shell is required"); textArea = new JTextArea() { @Override public void paste() { super.paste(); updateLastLine(); } }; textArea.setEditable(true); textArea.setLineWrap(true); textArea.setWrapStyleWord(true); textArea.setMargin(new Insets(8, 8, 8, 8)); textArea.setBackground(Color.GRAY); textArea.setForeground(Color.BLACK); var scrollPane = new JScrollPane(textArea); scrollPane.setPreferredSize(new Dimension(640, 320)); scrollPane.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE)); scrollPane.getVerticalScrollBar().setUI(new MUIScrollBar()); scrollPane.getHorizontalScrollBar().setUI(new MUIScrollBar()); textArea.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { textArea.setCaretPosition(textArea.getDocument().getLength()); } }); try { var font = Font.createFont(Font.TRUETYPE_FONT, Objects.requireNonNull(MUI.class.getResourceAsStream("/topaz.ttf"))); var derivedFont = font.deriveFont(Font.PLAIN, 14f); textArea.setFont(derivedFont); } catch (FontFormatException | IOException e) { log.error("Failed to set custom font, guru meditation: {}", e.getMessage()); } textArea.addKeyListener(new KeyListener() { @Override public void keyTyped(KeyEvent e) { if (e.getKeyChar() == '\n') { e.consume(); } else if (e.getKeyChar() == '\b') { if (!currentLine.isEmpty()) { currentLine = currentLine.substring(0, currentLine.length() - 1); } } else if (StringUtils.isAsciiPrintable(String.valueOf(e.getKeyChar()))) // Strip spurious ctrl-v char sequence { currentLine += e.getKeyChar(); } } @Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_BACK_SPACE) { if (currentLine.isEmpty()) { e.consume(); return; } } else if (e.getKeyCode() == KeyEvent.VK_ENTER) { e.consume(); var result = shell.sendCommand(currentLine); switch (result.getAction()) { case UNKNOWN_COMMAND -> appendToTextArea(currentLine + ": Unknown command"); case CLS -> { textArea.setText(""); appendToTextArea(""); } case EXIT -> closeShell(); case NO_OP -> appendToTextArea(""); case SUCCESS -> appendToTextArea(result.getOutput()); case ERROR -> appendToTextArea("Error: " + result.getOutput()); } currentLine = ""; } else if (e.getKeyCode() == KeyEvent.VK_UP) { var previous = shell.getPreviousCommand(); updateLineHistory(previous); e.consume(); } else if (e.getKeyCode() == KeyEvent.VK_DOWN) { var next = shell.getNextCommand(); updateLineHistory(next); e.consume(); } else if ((e.getModifiersEx() & InputEvent.CTRL_DOWN_MASK) == InputEvent.CTRL_DOWN_MASK) { return; } if (textArea != null) // We might have typed 'exit' { textArea.setCaretPosition(textArea.getDocument().getLength()); } } @Override public void keyReleased(KeyEvent e) { textArea.setCaretPosition(textArea.getDocument().getLength()); } }); shellFrame = new JFrame(AppName.NAME + " Shell"); shellFrame.setIconImage(new ImageIcon(Objects.requireNonNull(MUI.class.getResource("/image/icon.png"))).getImage()); shellFrame.getContentPane().setLayout(new BoxLayout(shellFrame.getContentPane(), BoxLayout.Y_AXIS)); shellFrame.add(scrollPane); shellFrame.pack(); shellFrame.setLocationRelativeTo(null); shellFrame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); shellFrame.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { closeShell(); } }); } /** * Updates the last line. This is slow, only use it for paste operations or so. */ private static void updateLastLine() { String fullText = textArea.getText(); if (fullText.isEmpty()) { currentLine = ""; } else { // Split by line breaks and get the last non-empty line String[] lines = fullText.split("\\r?\\n"); currentLine = lines[lines.length - 1] .replace(PROMPT, ""); } } private static void updateLineHistory(String line) { if (line == null) { line = ""; } var pos = textArea.getDocument().getLength(); textArea.replaceRange(null, pos - currentLine.length(), pos); textArea.append(line); currentLine = line; } private static void appendToTextArea(String text) { if (!textArea.getText().isEmpty()) { textArea.append("\n"); } if (StringUtils.isNotEmpty(text)) { textArea.append(text + "\n"); } textArea.append(PROMPT); textArea.setCaretPosition(textArea.getDocument().getLength()); } public static void closeShell() { if (shellFrame != null) { shellFrame.setVisible(false); shellFrame.dispose(); textArea = null; shellFrame = null; } } } ================================================ FILE: common/src/main/java/io/xeres/common/mui/MUIScrollBar.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.mui; import javax.swing.*; import javax.swing.plaf.basic.BasicScrollBarUI; import java.awt.*; public class MUIScrollBar extends BasicScrollBarUI { @Override protected void configureScrollBarColors() { super.configureScrollBarColors(); thumbColor = new Color(102, 136, 187); trackColor = Color.DARK_GRAY; } @Override protected JButton createDecreaseButton(int orientation) { return createZeroButton(); } @Override protected JButton createIncreaseButton(int orientation) { return createZeroButton(); } private JButton createZeroButton() { var button = new JButton(); button.setPreferredSize(new Dimension(0, 0)); button.setMinimumSize(new Dimension(0, 0)); button.setMaximumSize(new Dimension(0, 0)); return button; } @Override protected void paintThumb(Graphics g, JComponent c, Rectangle thumbBounds) { Graphics2D g2 = (Graphics2D) g.create(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(thumbColor); g2.fill3DRect(thumbBounds.x, thumbBounds.y, thumbBounds.width, thumbBounds.height, true); g2.dispose(); } @Override protected void paintTrack(Graphics g, JComponent c, Rectangle trackBounds) { Graphics2D g2 = (Graphics2D) g.create(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setColor(trackColor); g2.fillRect(trackBounds.x, trackBounds.y, trackBounds.width, trackBounds.height); g2.dispose(); } } ================================================ FILE: common/src/main/java/io/xeres/common/mui/Shell.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.mui; public interface Shell { ShellResult sendCommand(String input); String getPreviousCommand(); String getNextCommand(); } ================================================ FILE: common/src/main/java/io/xeres/common/mui/ShellAction.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.mui; public enum ShellAction { UNKNOWN_COMMAND, EXIT, CLS, SUCCESS, ERROR, NO_OP } ================================================ FILE: common/src/main/java/io/xeres/common/mui/ShellResult.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.mui; public class ShellResult { final ShellAction action; String output; public ShellResult(ShellAction action, String output) { this.action = action; this.output = output; } public ShellResult(ShellAction action) { this.action = action; } public ShellAction getAction() { return action; } public String getOutput() { return output; } } ================================================ FILE: common/src/main/java/io/xeres/common/pgp/Trust.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.pgp; import io.xeres.common.i18n.I18nEnum; import io.xeres.common.i18n.I18nUtils; import java.util.ResourceBundle; /** * This is the trust level for a PGP-like "web of trust" feature. Note that * 'undefined' is not here because it's confusing. *

* Note: this is stored in the database in ordinal. Do not modify the order. */ public enum Trust implements I18nEnum { /** * No opinion about the trustworthiness of the owner. */ UNKNOWN, /** * No trust about the owner. For example, he's known to sign stuff without * checking or without the other owner's consent. */ NEVER, /** * Trust that the owner doesn't perform certifications blindly but not * very accurately either. Trust will only become valid after multiple certifications (usually 3). * A good default choice. */ MARGINAL, /** * Trust that the owner performs certification very accurately. Trust * will become valid after a single one so use with care. */ FULL, /** * Our own key. */ ULTIMATE; private final ResourceBundle bundle = I18nUtils.getBundle(); @Override public String toString() { return bundle.getString(getMessageKey(this)); } } ================================================ FILE: common/src/main/java/io/xeres/common/properties/StartupProperties.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.properties; import io.xeres.common.protocol.ip.IP; import org.apache.commons.lang3.StringUtils; public final class StartupProperties { public enum Property { SERVER_ONLY("xrs.network.server-only", Boolean.class, Origin.PROPERTY), CONTROL_PORT("server.port", Integer.class, Origin.PROPERTY), CONTROL_ADDRESS("server.address", String.class, Origin.PROPERTY), CONTROL_PASSWORD("xrs.server.password", Boolean.class, Origin.PROPERTY), SERVER_ADDRESS("xrs.network.server-address", String.class, Origin.PROPERTY), SERVER_PORT("xrs.network.server-port", Integer.class, Origin.PROPERTY), DATA_DIR("xrs.data.dir-path", String.class, Origin.PROPERTY), UI("xrs.ui.enabled", Boolean.class, Origin.PROPERTY), UI_ADDRESS("xrs.ui.address", String.class, Origin.PROPERTY), UI_PORT("xrs.ui.port", Integer.class, Origin.PROPERTY), ICONIFIED("xrs.ui.iconified", Boolean.class, Origin.PROPERTY), FAST_SHUTDOWN("xrs.network.fast-shutdown", Boolean.class, Origin.PROPERTY), REMOTE_PASSWORD("xrs.ui.remote-password", String.class, Origin.PROPERTY), HTTPS("server.ssl.enabled", Boolean.class, Origin.PROPERTY), LOGFILE("logging.file.name", String.class, Origin.PROPERTY); Property(String propertyName, Class javaClass, Origin origin) { this.propertyName = propertyName; this.javaClass = javaClass; this.origin = origin; } private final String propertyName; private final Class javaClass; private Origin origin; public String getKey() { return propertyName; } public Class getJavaClass() { return javaClass; } public Origin getOrigin() { return origin; } private void setOrigin(Origin origin) { this.origin = origin; } /** * Checks if an argument was set by command line or environment variable. * * @return true if set by env var or command line */ public boolean isUnset() { return origin == Origin.PROPERTY; } } public enum Origin { PROPERTY, ENVIRONMENT_VARIABLE, ARGUMENT } private StartupProperties() { throw new UnsupportedOperationException("Utility class"); } public static String getString(Property property, String defaultValue) { return System.getProperty(property.getKey(), defaultValue); } public static String getString(Property property) { return System.getProperty(property.getKey()); } public static void setString(Property property, String value, Origin origin) { if (!property.getJavaClass().equals(String.class)) { throw new IllegalArgumentException("Property class for " + property.getKey() + " must be a String but it's a " + property.getJavaClass()); } if (StringUtils.isBlank(value)) { throw new IllegalArgumentException("Property " + property.name() + " (" + property.getKey() + ") does not contain a value"); } property.setOrigin(origin); System.setProperty(property.getKey(), value); } @SuppressWarnings("java:S2447") public static Boolean getBoolean(Property property) { var value = System.getProperty(property.getKey()); if (value == null) { return null; } return Boolean.parseBoolean(value); } public static boolean getBoolean(Property property, boolean defaultValue) { var value = System.getProperty(property.getKey()); if (value == null) { return defaultValue; } return Boolean.parseBoolean(value); } public static void setBoolean(Property property, String value, Origin origin) { if (!property.getJavaClass().equals(Boolean.class)) { throw new IllegalArgumentException("Property class for " + property.getKey() + " must be a Boolean but it's a " + property.getJavaClass()); } var val = value.equals("1") || value.equalsIgnoreCase("yes") || Boolean.parseBoolean(value); if (!val && !(value.equals("0") || value.equalsIgnoreCase("no") || value.equalsIgnoreCase("false"))) { throw new IllegalArgumentException("Property " + property.name() + " (" + property.getKey() + ") does not contain a boolean value (" + value + ")"); } property.setOrigin(origin); System.setProperty(property.getKey(), String.valueOf(val)); } public static Integer getInteger(Property property) { var value = System.getProperty(property.getKey()); if (value == null) { return null; } return Integer.parseInt(value); } public static void setPort(Property property, String value, Origin origin) { if (!property.getJavaClass().equals(Integer.class)) { throw new IllegalArgumentException("Property class for " + property.getKey() + " must be an Integer but it's a " + property.getJavaClass()); } try { var val = Integer.parseUnsignedInt(value); if (IP.isInvalidPort(val)) { throw new NumberFormatException(); } property.setOrigin(origin); System.setProperty(property.getKey(), String.valueOf(val)); } catch (NumberFormatException _) { throw new IllegalArgumentException("Property " + property.name() + " (" + property.getKey() + ") does not contain a port bigger than 0 and smaller than 65536 (" + value + ")"); } } public static void setUiRemoteConnect(String ipAndPort, Origin origin) { var tokens = ipAndPort.split(":"); if (StringUtils.isBlank(tokens[0])) { throw new IllegalArgumentException("Missing hostname"); } if (!IP.isBindableIp(tokens[0])) { throw new IllegalArgumentException("IP " + tokens[0] + " cannot be bound to"); } setString(Property.UI_ADDRESS, tokens[0], origin); if (tokens.length == 2 && StringUtils.isNotBlank(tokens[1])) { if (IP.isInvalidPort(Integer.parseUnsignedInt(tokens[1]))) { throw new IllegalArgumentException("Invalid port " + tokens[1]); } setPort(Property.UI_PORT, tokens[1], origin); } System.setProperty("spring.main.web-application-type", "none"); } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/HostPort.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol; import static org.apache.commons.lang3.StringUtils.isBlank; public record HostPort(String host, int port) { public static HostPort parse(String hostPort) { var tokens = hostPort.split(":"); int port; if (tokens.length != 2) { throw new IllegalArgumentException("Input is not in \"host:port\" format: " + hostPort); } try { port = Integer.parseInt(tokens[1]); } catch (NumberFormatException _) { throw new IllegalArgumentException("Port is not a number: " + tokens[1]); } if (port < 0 || port > 65535) { throw new IllegalArgumentException("Port is out of range: " + port); } if (isBlank(tokens[0])) { throw new IllegalArgumentException("Host is missing"); } return new HostPort(tokens[0], port); } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/NetMode.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol; import java.util.Locale; /** * The NetMode
* Note: this is stored in the database in ordinal. Do not modify the order. */ public enum NetMode { UNKNOWN, // Unknown netmode UDP, // firewalled | UDP mode UPNP, // automatic (UPNP) | Ext (UPNP) EXT, // manually forwarded port | External port HIDDEN, // hidden mode | Hidden UNREACHABLE; // UDP mode (unreachable) @Override public String toString() { return super.toString().toLowerCase(Locale.ROOT); } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/dns/DNS.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.dns; import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.time.Duration; public final class DNS { private static final Duration TIMEOUT = Duration.ofSeconds(10); private static final int DNS_PORT = 53; private DNS() { throw new UnsupportedOperationException("Utility class"); } /** * @param host the host to resolve * @param dnsServer the dns server to resolve against * @return the IP address of the host * @throws IOException failure to resolve * @see Stack Overflow */ public static InetAddress resolve(String host, String dnsServer) throws IOException { var serverAddress = InetAddress.getByName(dnsServer); var request = new DnsRequest(host); var dnsFrame = request.toByteArray(); try (var socket = new DatagramSocket()) { socket.setSoTimeout((int) TIMEOUT.toMillis()); var dnsReqPacket = new DatagramPacket(dnsFrame, dnsFrame.length, serverAddress, DNS_PORT); socket.send(dnsReqPacket); var buf = new byte[1024]; var packet = new DatagramPacket(buf, buf.length); socket.receive(packet); var response = new DnsResponse(buf, request.getId()); return response.getAddress(); } } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/dns/DnsRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.dns; import io.xeres.common.util.SecureRandomUtils; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; class DnsRequest { private final ByteArrayOutputStream array; private final short id; DnsRequest(String hostname) throws IOException { id = SecureRandomUtils.nextShort(); array = new ByteArrayOutputStream(); var out = new DataOutputStream(array); // ID out.writeShort(id); // Write Query Flags (recursion desired) out.writeShort(0x0100); // Question Count out.writeShort(0x0001); // Answer Record Count out.writeShort(0x0000); // Authority Record Count out.writeShort(0x0000); // Additional Record Count out.writeShort(0x0000); // Query Name var domainParts = hostname.split("\\."); for (String domainPart : domainParts) { var domainBytes = domainPart.getBytes(StandardCharsets.UTF_8); out.writeByte(domainBytes.length); out.write(domainBytes); } out.writeByte(0x00); // Terminator // Query Type 0x01 = A record (host addresses) out.writeShort(0x0001); // Query Class 0x01 = Internet Address out.writeShort(0x0001); } byte[] toByteArray() { return array.toByteArray(); } public int getId() { return id; } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/dns/DnsResponse.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.dns; import java.io.ByteArrayInputStream; import java.io.DataInputStream; import java.io.IOException; import java.net.InetAddress; class DnsResponse { private final InetAddress address; DnsResponse(byte[] response, int id) throws IOException { var input = new DataInputStream(new ByteArrayInputStream(response)); var receivedId = input.readShort(); if (receivedId != id) { throw new IOException("Wrong ID, expected " + id + ", got: " + receivedId); } if ((input.readShort() & 0x8000) == 0) { throw new IOException("Not a response"); } if (input.readShort() != 1) { throw new IOException("Wrong number of query"); } var answers = input.readShort(); if (answers != 1) { throw new IOException("Wrong number of answers, wanted: 1, got: " + answers); } if (input.readShort() != 0) { throw new IOException("Wrong number of records"); } if (input.readShort() != 0) { throw new IOException("Wrong number of additional records"); } // Eat up the questions int recordCount; while ((recordCount = input.readByte()) > 0) { for (var i = 0; i < recordCount; i++) { input.readByte(); } } input.readShort(); // Question type input.readShort(); // Question class input.readShort(); // Field if (input.readShort() != 1) { throw new IOException("Wrong type of answer"); } if (input.readShort() != 1) { throw new IOException("Wrong class of answer"); } input.readInt(); // TTL if (input.readShort() != 4) { throw new IOException("Wrong length"); } var buf = new byte[4]; for (var i = 0; i < 4; i++) { buf[i] = input.readByte(); } address = InetAddress.getByAddress(buf); } public InetAddress getAddress() { return address; } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/i2p/I2pAddress.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.i2p; import java.util.regex.Pattern; public final class I2pAddress { private static final Pattern I2P_B32_PATTERN = Pattern.compile("[a-z2-7]{52}\\.b32.i2p:\\d{1,5}"); private I2pAddress() { throw new UnsupportedOperationException("Utility class"); } public static boolean isValidAddress(String address) { return I2P_B32_PATTERN.matcher(address).matches(); } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/ip/IP.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.ip; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.UncheckedIOException; import java.net.*; import java.util.Set; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.IntStream; /** * IP handling utility class. */ public final class IP { private static final Logger log = LoggerFactory.getLogger(IP.class); /** * List of port to avoid picking up as default because of their popularity in a NAT setup. * Xeres uses a range from 1025 to 32767. * Note that some ports aren't really popular, but they're scanned by default by some anti-viruses. */ private static final Set reservedPorts = Set.of( 1080, // Socks proxy 1194, // Open VPN 1433, // MS SQL 1701, // L2TP 1723, // PPTP VPN 1900, // SSDP 2021, // FTP ALG 2041, // Mail.ru 2086, // GNUnet 2375, // Docker 2376, // Docker (SSL) 3074, // XBox Live 3128, // Default proxy 3306, // MySQL 3389, // Remote Desktop Protocol 4242, // Quassel 4444, // I2P Proxy 4500, // IPSec 5000, // Yahoo! 5001, // Yahoo! 5050, // Yahoo! 5101, // Yahoo! 5190, // ICQ 5060, // Asterisk 5061, // Asterisk (SSL) 5222, // Jabber 5223, // Jabber 5269, // Jabber 6232, // Ourself 6233, // Ourself + 1 6667, // IRC 6697, // IRCS 6881, // Bittorrent 6882, // Bittorrent 6883, // Bittorrent 6884, // Bittorrent 6885, // Bittorrent 6886, // Bittorrent 6887, // Bittorrent 6888, // Bittorrent 6889, // Bittorrent 7652, // I2P 7653, // I2P 7654, // I2P 7900, // Many local tests 8000, // Many local tests 8080, // Many local tests 8088, // Many local tests 8888, // Many local tests 9001, // Tor 9030, // Tor 9050, // Tor 9051, // Tor 9080, // Logitech's LGHUB 11523 // No idea why Kaspersky scans this ); private static final int BINDING_ATTEMPTS_MAX = 100; // After that many failed attempts, there must be something wrong private IP() { throw new UnsupportedOperationException("Utility class"); } /** * Finds a free local port to bind to. There's a built-in blacklist of commonly used ports which are avoided. * * @return a free local port */ public static int getFreeLocalPort() { int port; var bindErrorDetector = 0; while (true) { // Avoid Ephemeral ports, see https://en.wikipedia.org/wiki/Ephemeral_port port = ThreadLocalRandom.current().nextInt(1025, 32767); if (reservedPorts.contains(port)) { continue; } try (var socket = new Socket()) { socket.bind(new InetSocketAddress("0.0.0.0", port)); return port; } catch (IOException _) { if (bindErrorDetector > BINDING_ATTEMPTS_MAX) { return 0; } bindErrorDetector++; } } } /** * Tries its best to get the local IP address, without requiring an external * server. Should work at all times unless the host has no TCP/IP stack. *

If the host has no internet access, then 127.0.0.1 is used. * * @return the local IP address or null */ public static String getLocalIpAddress() { String ip; try (var socket = new DatagramSocket()) { socket.connect(InetAddress.getByName("1.1.1.1"), 10000); ip = socket.getLocalAddress().getHostAddress(); if (isBindableIp(ip)) { return ip; } // The above is reported to not work on MacOS, if so, just scan all interfaces manually. ip = findIpFromInterfaces(); if (isRoutableIp(ip)) { return ip; } } catch (IOException | UncheckedIOException _) { ip = null; } return ip; } /** * Checks if the IP address can be bound to (that is, a server can run on it). * * @param ip the IP address to check * @return true if it's bindable */ public static boolean isBindableIp(String ip) { return isLanIp(ip) || isPublicIp(ip) || isLocalIp(ip); } /** * Checks if the IP address is routable, which means it's either a valid LAN address (for example, 192.168.1.4) or a public IP address. * * @param ip the IP address to check * @return true if it's routable */ public static boolean isRoutableIp(String ip) { return isLanIp(ip) || isPublicIp(ip); } /** * Checks if the IP address if from a LAN (that is, a privately routable IP address; for example, 192.168.1.4 or 10.0.0.5). * * @param ip the IP address to check * @return true if it's a LAN address */ public static boolean isLanIp(String ip) { try { return isLanAddress(InetAddress.getByName(ip)); } catch (UnknownHostException _) { return false; } } /** * Checks if the IP address is a publicly routable IP address (that is, an IP that an Internet router will forward). * * @param ip the IP address to check * @return true if it's a public IP address */ public static boolean isPublicIp(String ip) { try { return isPublicAddress(InetAddress.getByName(ip)); } catch (UnknownHostException _) { return false; } } /** * Checks if the IP address is a local IP (localhost or link local). * * @param ip the IP address to check * @return true if it's a local IP address */ public static boolean isLocalIp(String ip) { try { return isLocalAddress(InetAddress.getByName(ip)); } catch (UnknownHostException _) { return false; } } /** * Try to find the local IP by iterating all interfaces.
* Note: this doesn't work in all cases (for example, if docker has some address like 10.0.75.1 then it might be * picked up before the proper interface). * * @return the IP address if found, otherwise null * @throws SocketException if there's a failure to get the interfaces */ private static String findIpFromInterfaces() throws SocketException { var interfaces = NetworkInterface.getNetworkInterfaces().asIterator(); while (interfaces.hasNext()) { var networkInterface = interfaces.next(); if (networkInterface.isUp()) { var addresses = networkInterface.getInetAddresses().asIterator(); while (addresses.hasNext()) { var address = addresses.next(); if (isRoutableAddress(address)) { log.debug("IP found using interface enumeration system: {}", address.getHostAddress()); return address.getHostAddress(); } } } } return null; } private static boolean isRoutableAddress(InetAddress address) { return isLanAddress(address) || isPublicAddress(address); } private static boolean isLanAddress(InetAddress address) { return address.isSiteLocalAddress(); } private static boolean isPublicAddress(InetAddress address) { return !(isSpecifiedHostOnThisNetwork(address) || // 0.0.0.0 - 0.255.255.255 address.isLoopbackAddress() || // 127.0.0.0 - 127.255.255.255 address.isSiteLocalAddress() || // 10.0.0.0 - 10.255.255.255 | 172.16.0.0 - 172.31.255.255 | 192.0.0.0 - 192.0.0.255 isSharedAddressSpace(address) || // 100.64.0.0 - 100.127.255.255 address.isLinkLocalAddress() || // 169.254.0.0 - 169.254.255.255 address.isMulticastAddress() || // 224.0.0.0 - 239.255.255.255 isLimitedBroadcastAddress(address)); // 255.255.255.255 } private static boolean isLocalAddress(InetAddress address) { return address.isLinkLocalAddress() || address.isLoopbackAddress(); } private static boolean isLimitedBroadcastAddress(InetAddress address) { // 255.255.255.255, see rfc6890 return IntStream.of(3, 2, 1, 0).allMatch(i -> address.getAddress()[i] == -1); } /** * Check if an address is a current network (0.0.0.0/8). It must not be sent except as a source * address as part of an initialization procedure by which the host learns its full IP address.
* Note: 0.0.0.0 (bind to any interface) is included as well. If you only need it, use {@link InetAddress#isAnyLocalAddress()} instead. * * @param address the address to test * @return true if the address represents a current network * @see rfc6890 and rfc1122 (section 3.2.1.3) */ private static boolean isSpecifiedHostOnThisNetwork(InetAddress address) { return address.getAddress()[0] == 0; } /** * Check if an address is in a shared address space (100.64.0.0/10), which is used when the ISP * is using a carrier-grade NAT. This address cannot be reached from the public Internet directly. * * @param address the address to test * @return true if in a shared address space * @see rfc6598 */ private static boolean isSharedAddressSpace(InetAddress address) { return address.getAddress()[0] == 100 && Byte.toUnsignedInt(address.getAddress()[1]) >= 64 && Byte.toUnsignedInt(address.getAddress()[1]) < 128; } /** * Checks if a port is invalid (that is, cannot be bound to or sent to). * * @param port the port to check * @return true if valid */ public static boolean isInvalidPort(int port) { return port <= 0 || port >= 65536; } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/ip/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * IP protocol support. Only IPv4 is supported for now. */ package io.xeres.common.protocol.ip; ================================================ FILE: common/src/main/java/io/xeres/common/protocol/tor/OnionAddress.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.tor; import java.util.regex.Pattern; public final class OnionAddress { private static final Pattern ONION_PATTERN = Pattern.compile("[a-z2-7]{56}\\.onion:\\d{1,5}"); private OnionAddress() { throw new UnsupportedOperationException("Utility class"); } public static boolean isValidAddress(String address) { return ONION_PATTERN.matcher(address).matches(); } } ================================================ FILE: common/src/main/java/io/xeres/common/protocol/tor/package-info.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ /** * Tor protocol support. */ package io.xeres.common.protocol.tor; ================================================ FILE: common/src/main/java/io/xeres/common/protocol/xrs/RsServiceType.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.xrs; import io.xeres.common.annotation.RsDeprecated; /** * The registry of Retroshare service types. Do not change their names, as they're also checked for matches. */ public enum RsServiceType { NONE(0, null, 0, 0, 0, 0), /** * The discovery service. */ DISCOVERY(0x11, "disc", 1, 0, 1, 0), /** * The chat service. */ CHAT(0x12, "chat", 1, 0, 1, 0), /** * The messaging service (direct mail, etc...). */ MESSAGES(0x13, "msg", 1, 0, 1, 0), /** * The turtle service. */ TURTLE_ROUTER(0x14, "turtle", 1, 0, 1, 0), @RsDeprecated TUNNEL(0x15, null, 1, 0, 1, 0), /** * The heartbeat service. */ HEARTBEAT(0x16, "heartbeat", 1, 0, 1, 0), /** * The file transfer service. */ FILE_TRANSFER(0x17, "ft", 1, 0, 1, 0), /** * The global router. */ GLOBAL_ROUTER(0x18, "Global Router", 1, 0, 1, 0), /** * The file database transfer service. */ FILE_DATABASE(0x19, "file_database", 1, 0, 1, 0), /** * The service info service. */ SERVICE_INFO(0x20, "serviceinfo", 1, 0, 1, 0), /** * The bandwidth service. */ BANDWIDTH_CONTROL(0x21, "bandwidth_ctrl", 1, 0, 1, 0), /** * Claims to be new but was never used somehow. */ @RsDeprecated MAIL(0x22, null, 1, 0, 1, 0), /** * Direct mail messages to a location ID. */ DIRECT_MAIL(0x23, "msgdirect", 1, 0, 1, 0), @RsDeprecated DISTANT_MAIL(0x24, null, 1, 0, 1, 0), @RsDeprecated GWEMAIL_MAIL(0x25, null, 1, 0, 1, 0), /** * RS uses it internally for saving which services are permitted or not to other users. */ SERVICE_CONTROL(0x26, null, 1, 0, 1, 0), @RsDeprecated DISTANT_CHAT(0x27, null, 1, 0, 1, 0), /** * The GXS tunnel service. */ GXS_TUNNELS(0x28, "GxsTunnels", 1, 0, 1, 0), /** * IP filter list exchange. */ BANLIST(0x101, "banlist", 1, 0, 1, 0), /** * The status service. */ STATUS(0x102, "status", 1, 0, 1, 0), /** * RS has an optional standalone friend server, which dispatches friends on a Tor link. */ FRIEND_SERVER(0x103, null, 1, 0, 1, 0), /** * Just a placeholder? */ NXS(0x200, null, 1, 0, 1, 0), /** * The identity service. */ GXS_IDENTITY(0x211, "gxsid", 1, 0, 1, 0), /** * Photo album, not finished. */ GXS_PHOTO(0x212, "gxsphoto", 1, 0, 1, 0), /** * Wiki service. */ GXS_WIKI(0x213, "gxswiki", 1, 0, 1, 0), /** * Twitter clone. */ GXS_WIRE(0x214, "gxswire", 1, 0, 1, 0), /** * The forum service. */ GXS_FORUMS(0x215, "gxsforums", 1, 0, 1, 0), /** * The board service. */ GXS_BOARDS(0x216, "gxsposted", 1, 0, 1, 0), /** * The channel service. */ GXS_CHANNELS(0x217, "gxschannels", 1, 0, 1, 0), /** * The GXS circles. */ GXS_CIRCLES(0x218, "gxscircle", 1, 0, 1, 0), /** * Identity reputation transfer. */ GXS_REPUTATION(0x219, "gxsreputation", 1, 0, 1, 0), @RsDeprecated GXS_RECOGN(0x220, null, 1, 0, 1, 0), /** * Asynchronous mail delivery on top of GXS. Can be used to send messages when offline. * In RS, was implemented by chat (was) and is implemented by distant mail. */ GXS_MAILS(0x230, "GXS Mails", 1, 0, 1, 0), /** * Used internally by RS for serialization. */ JSONAPI(0x240, null, 1, 0, 1, 0), /** * Used by RS for serialization. */ FORUMS_CONFIG(0x315, null, 1, 0, 1, 0), /** * Used by RS for serialization. */ POSTED_CONFIG(0x316, null, 1, 0, 1, 0), /** * Used by RS for serialization. */ CHANNELS_CONFIG(0x317, null, 1, 0, 1, 0), /** * Experimental Destination-Sequenced Distance Vector routing in RS. Disabled. */ DSDV(0x1010, "dsdv", 1, 0, 1, 0), /** * The RTT service. */ RTT(0x1011, "rtt", 1, 0, 1, 0), // plugins ARADO_ID(0x2001, null, 1, 0, 1, 0), RETRO_CHESS(0x2002, "RetroChess", 1, 0, 1, 0), FEEDREADER(0x2003, "FEEDREADER", 1, 0, 1, 0), /** * The VoIP service. Implemented as a plugin in RS, built-in in Xeres. */ VOIP(0xA021, "VOIP", 1, 0, 1, 0), /** * GXS distant sync. Implemented for channels in RS. */ GXS_DISTANT_SYNC(0x2233, "GxsNetTunnel", 1, 0, 1, 0), // packet slicing PACKET_SLICING_PROBE(0xAABB, "SlicingProbe", 1, 0, 1, 0), // Nabu's experimental services ZERO_RESERVE(0xBEEF, null, 1, 0, 1, 0), FIDO_GW(0xF1D0, null, 1, 0, 1, 0); private final int type; private final String name; private final short versionMajor; private final short versionMinor; private final short minVersionMajor; private final short minVersionMinor; public static RsServiceType fromName(String name) { for (RsServiceType serviceType : RsServiceType.values()) { if (serviceType.name().equalsIgnoreCase(name)) { return serviceType; } } return NONE; } RsServiceType(int type, String name, int versionMajor, int versionMinor, int minVersionMajor, int minVersionMinor) { this.type = type; this.name = name; this.versionMajor = (short) versionMajor; this.versionMinor = (short) versionMinor; this.minVersionMajor = (short) minVersionMajor; this.minVersionMinor = (short) minVersionMinor; } public int getType() { return type; } public String getName() { return name; } public short getVersionMajor() { return versionMajor; } public short getVersionMinor() { return versionMinor; } public short getMinVersionMajor() { return minVersionMajor; } public short getMinVersionMinor() { return minVersionMinor; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/PathConfig.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest; public final class PathConfig { private PathConfig() { throw new UnsupportedOperationException("Utility class"); } public static final String CONFIG_PATH = "/api/v1/config"; public static final String PROFILES_PATH = "/api/v1/profiles"; public static final String LOCATIONS_PATH = "/api/v1/locations"; public static final String CONNECTIONS_PATH = "/api/v1/connections"; public static final String NOTIFICATIONS_PATH = "/api/v1/notifications"; public static final String CHAT_PATH = "/api/v1/chat"; public static final String IDENTITIES_PATH = "/api/v1/identities"; public static final String SETTINGS_PATH = "/api/v1/settings"; public static final String GEOIP_PATH = "/api/v1/geoip"; public static final String FORUMS_PATH = "/api/v1/forums"; public static final String SHARES_PATH = "/api/v1/shares"; public static final String FILES_PATH = "/api/v1/files"; public static final String STATISTICS_PATH = "/api/v1/statistics"; public static final String CONTACT_PATH = "/api/v1/contacts"; public static final String BOARDS_PATH = "/api/v1/boards"; public static final String CHANNELS_PATH = "/api/v1/channels"; } ================================================ FILE: common/src/main/java/io/xeres/common/rest/board/UpdateBoardMessageReadRequest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.board; public record UpdateBoardMessageReadRequest(long messageId, boolean read) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/channel/UpdateChannelMessageReadRequest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.channel; public record UpdateChannelMessageReadRequest(long messageId, boolean read) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/chat/ChatRoomVisibility.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.chat; public enum ChatRoomVisibility { PUBLIC, PRIVATE; public static ChatRoomVisibility fromSelection(int index) { return ChatRoomVisibility.values()[index]; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/chat/CreateChatRoomRequest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.chat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; public record CreateChatRoomRequest( @Schema(example = "Cool Room") @NotNull String name, @Schema(example = "The coolest chat room ever") @NotNull String topic, @NotNull ChatRoomVisibility visibility, boolean signedIdentities ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/chat/DistantChatRequest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.chat; import jakarta.validation.constraints.NotNull; public record DistantChatRequest( @NotNull Long identityId ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/chat/InviteToChatRoomRequest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.chat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.Set; public record InviteToChatRoomRequest( @NotNull Long chatRoomId, @Schema(example = "[\"463652d6dec7497d3c10cfe5de036ecf\", \"68105c73086c99f7299023ce4f544511\"]") @NotEmpty Set locationIdentifiers ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/Capabilities.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; public final class Capabilities { public static final String AUTOSTART = "autostart"; private Capabilities() { throw new UnsupportedOperationException("Utility class"); } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/HostnameResponse.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; public record HostnameResponse(String hostname) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/ImportRsFriendsResponse.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; public record ImportRsFriendsResponse(int success, int errors) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/IpAddressResponse.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; public record IpAddressResponse(String ip, Integer port) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/OwnIdentityRequest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import static io.xeres.common.dto.identity.IdentityConstants.NAME_LENGTH_MAX; import static io.xeres.common.dto.identity.IdentityConstants.NAME_LENGTH_MIN; public record OwnIdentityRequest( @NotNull @Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX) @Schema(example = "SuperCoolIdentity") String name, boolean anonymous ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/OwnLocationRequest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import static io.xeres.common.dto.location.LocationConstants.NAME_LENGTH_MAX; import static io.xeres.common.dto.location.LocationConstants.NAME_LENGTH_MIN; public record OwnLocationRequest( @NotNull @Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX, message = "location must be between " + NAME_LENGTH_MIN + " and " + NAME_LENGTH_MAX + " characters.") @Schema(example = "SuperCoolLocation") String name ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/OwnProfileRequest.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MAX; import static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MIN; public record OwnProfileRequest( @NotNull @Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX, message = "profile must be between " + NAME_LENGTH_MIN + " and " + NAME_LENGTH_MAX + " characters.") @Schema(example = "SuperCoolNickname") String name ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/UsernameResponse.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; public record UsernameResponse(String username) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/config/VerifyUpdateRequest.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.config; import jakarta.validation.constraints.NotNull; public record VerifyUpdateRequest( @NotNull String filePath, byte @NotNull [] signature ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/connection/ConnectionRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.connection; public record ConnectionRequest(String locationIdentifier, int connectionIndex) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/contact/Contact.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.contact; import io.xeres.common.location.Availability; public record Contact(String name, long profileId, long identityId, Availability availability, boolean accepted) { public static final Contact EMPTY = new Contact(null, 0L, 0L, Availability.OFFLINE, false); public static final Contact OWN = new Contact(null, 1L, 1L, Availability.OFFLINE, true); public static Contact withName(Contact contact, String name) { return new Contact(name, contact.profileId(), contact.identityId(), contact.availability(), contact.accepted()); } public static Contact withAvailability(Contact contact, Availability availability) { return new Contact(contact.name(), contact.profileId(), contact.identityId(), availability, contact.accepted()); } public static Contact withIdentityId(Contact contact, long identityId) { return new Contact(contact.name(), contact.profileId(), identityId, contact.availability(), contact.accepted()); } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/file/AddDownloadRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.file; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; public record AddDownloadRequest( String name, long size, Sha1Sum hash, LocationIdentifier locationIdentifier ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/file/FileDownloadRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.file; import io.xeres.common.id.LocationIdentifier; import jakarta.validation.constraints.NotNull; public record FileDownloadRequest( @NotNull String name, @NotNull String hash, long size, LocationIdentifier locationIdentifier ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/file/FileProgress.java ================================================ package io.xeres.common.rest.file; public record FileProgress(long id, String name, long currentSize, long totalSize, String hash, boolean completed) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/file/FileSearchRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.file; import jakarta.validation.constraints.NotNull; public record FileSearchRequest( @NotNull String name ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/file/FileSearchResponse.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.file; public record FileSearchResponse(int id) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/forum/CreateForumMessageRequest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.forum; import jakarta.validation.constraints.NotBlank; public record CreateForumMessageRequest( long forumId, @NotBlank(message = "Title must not be empty") String title, @NotBlank(message = "Content must not be empty") String content, long parentId, long originalId ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/forum/CreateOrUpdateForumGroupRequest.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.forum; import jakarta.validation.constraints.NotBlank; public record CreateOrUpdateForumGroupRequest( @NotBlank(message = "Name must not be empty") String name, @NotBlank(message = "Description must not be empty") String description ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/forum/ForumPostRequest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.forum; public record ForumPostRequest( long forumId, long replyToId, long messageId ) { @Override public String toString() { // This is used by the Window Manager to find the window by its unique title return forumId + "," + replyToId + "," + messageId; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/forum/UpdateForumMessageReadRequest.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.forum; public record UpdateForumMessageReadRequest(long messageId, boolean read) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/geoip/CountryResponse.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.geoip; public record CountryResponse(String isoCountry) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/location/RSIdResponse.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.location; public record RSIdResponse( String name, String location, String rsId ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/Notification.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import io.xeres.common.rest.notification.availability.AvailabilityChange; import io.xeres.common.rest.notification.board.AddOrUpdateBoardGroups; import io.xeres.common.rest.notification.board.AddOrUpdateBoardMessages; import io.xeres.common.rest.notification.board.SetBoardGroupMessagesReadState; import io.xeres.common.rest.notification.board.SetBoardMessageReadState; import io.xeres.common.rest.notification.channel.AddOrUpdateChannelGroups; import io.xeres.common.rest.notification.channel.AddOrUpdateChannelMessages; import io.xeres.common.rest.notification.channel.SetChannelGroupMessagesReadState; import io.xeres.common.rest.notification.channel.SetChannelMessageReadState; import io.xeres.common.rest.notification.contact.AddOrUpdateContacts; import io.xeres.common.rest.notification.contact.RemoveContacts; import io.xeres.common.rest.notification.file.FileNotification; import io.xeres.common.rest.notification.file.FileSearchNotification; import io.xeres.common.rest.notification.file.FileTrendNotification; import io.xeres.common.rest.notification.forum.AddOrUpdateForumGroups; import io.xeres.common.rest.notification.forum.AddOrUpdateForumMessages; import io.xeres.common.rest.notification.forum.SetForumGroupMessagesReadState; import io.xeres.common.rest.notification.forum.SetForumMessageReadState; import io.xeres.common.rest.notification.status.StatusNotification; import static io.xeres.common.rest.notification.Notification.*; /** * Notification superclass. It's important to list all of its subclasses in it because the "type" field is used * by Jackson to know which subclass to deserialize from. Changing the strings names should be avoided as this could * break the API if there's a 3rd party client. */ @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type" ) @JsonSubTypes({ // Boards @JsonSubTypes.Type(value = AddOrUpdateBoardGroups.class, name = ADD_OR_UPDATE_BOARD_GROUPS), @JsonSubTypes.Type(value = AddOrUpdateBoardMessages.class, name = ADD_OR_UPDATE_BOARD_MESSAGES), @JsonSubTypes.Type(value = SetBoardGroupMessagesReadState.class, name = SET_BOARD_GROUP_MESSAGES_READ_STATE), @JsonSubTypes.Type(value = SetBoardMessageReadState.class, name = SET_BOARD_MESSAGES_READ_STATE), // Channels @JsonSubTypes.Type(value = AddOrUpdateChannelGroups.class, name = ADD_OR_UPDATE_CHANNEL_GROUPS), @JsonSubTypes.Type(value = AddOrUpdateChannelMessages.class, name = ADD_OR_UPDATE_CHANNEL_MESSAGES), @JsonSubTypes.Type(value = SetChannelGroupMessagesReadState.class, name = SET_CHANNEL_GROUP_MESSAGES_READ_STATE), @JsonSubTypes.Type(value = SetChannelMessageReadState.class, name = SET_CHANNEL_MESSAGES_READ_STATE), // Forums @JsonSubTypes.Type(value = AddOrUpdateForumGroups.class, name = ADD_OR_UPDATE_FORUM_GROUPS), @JsonSubTypes.Type(value = AddOrUpdateForumMessages.class, name = ADD_OR_UPDATE_FORUM_MESSAGES), @JsonSubTypes.Type(value = SetForumGroupMessagesReadState.class, name = SET_FORUM_GROUP_MESSAGES_READ_STATE), @JsonSubTypes.Type(value = SetForumMessageReadState.class, name = SET_FORUM_MESSAGES_READ_STATE), // Availability @JsonSubTypes.Type(value = AvailabilityChange.class, name = AVAILABILITY_CHANGE), // Contact @JsonSubTypes.Type(value = AddOrUpdateContacts.class, name = ADD_OR_UPDATE_CONTACTS), @JsonSubTypes.Type(value = RemoveContacts.class, name = REMOVE_CONTACTS), // File @JsonSubTypes.Type(value = FileNotification.class, name = FILE), @JsonSubTypes.Type(value = FileSearchNotification.class, name = FILE_SEARCH), @JsonSubTypes.Type(value = FileTrendNotification.class, name = FILE_TREND), // Status @JsonSubTypes.Type(value = StatusNotification.class, name = STATUS), }) public interface Notification { String ADD_OR_UPDATE_BOARD_GROUPS = "add_or_update_board_groups"; String ADD_OR_UPDATE_BOARD_MESSAGES = "add_or_update_board_messages"; String SET_BOARD_GROUP_MESSAGES_READ_STATE = "set_board_group_messages_read_state"; String SET_BOARD_MESSAGES_READ_STATE = "set_board_message_read_state"; String ADD_OR_UPDATE_CHANNEL_GROUPS = "add_or_update_channel_groups"; String ADD_OR_UPDATE_CHANNEL_MESSAGES = "add_or_update_channel_messages"; String SET_CHANNEL_GROUP_MESSAGES_READ_STATE = "set_channel_group_messages_read_state"; String SET_CHANNEL_MESSAGES_READ_STATE = "set_channel_message_read_state"; String ADD_OR_UPDATE_FORUM_GROUPS = "add_or_update_forum_groups"; String ADD_OR_UPDATE_FORUM_MESSAGES = "add_or_update_forum_messages"; String SET_FORUM_GROUP_MESSAGES_READ_STATE = "set_forum_group_messages_read_state"; String SET_FORUM_MESSAGES_READ_STATE = "set_forum_message_read_state"; String AVAILABILITY_CHANGE = "availability_change"; String ADD_OR_UPDATE_CONTACTS = "add_or_update_contacts"; String REMOVE_CONTACTS = "remove_contacts"; String FILE = "file"; String FILE_SEARCH = "file_search"; String FILE_TREND = "file_trend"; String STATUS = "status"; String getType(); default boolean ignoreDuplicates() { return false; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/availability/AvailabilityChange.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.availability; import io.xeres.common.location.Availability; public record AvailabilityChange(Availability availability, long profileId, String profileName, long locationId, String locationName) implements AvailabilityNotification { @Override public String getType() { return AVAILABILITY_CHANGE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/availability/AvailabilityNotification.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.availability; import io.xeres.common.rest.notification.Notification; public sealed interface AvailabilityNotification extends Notification permits AvailabilityChange { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/board/AddOrUpdateBoardGroups.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.board; import io.xeres.common.dto.board.BoardGroupDTO; import java.util.List; public record AddOrUpdateBoardGroups(List boardGroups) implements BoardNotification { @Override public String getType() { return ADD_OR_UPDATE_BOARD_GROUPS; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/board/AddOrUpdateBoardMessages.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.board; import io.xeres.common.dto.board.BoardMessageDTO; import java.util.List; public record AddOrUpdateBoardMessages(List boardMessages) implements BoardNotification { @Override public String getType() { return ADD_OR_UPDATE_BOARD_MESSAGES; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/board/BoardNotification.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.board; import io.xeres.common.rest.notification.Notification; public sealed interface BoardNotification extends Notification permits AddOrUpdateBoardGroups, AddOrUpdateBoardMessages, SetBoardGroupMessagesReadState, SetBoardMessageReadState { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/board/SetBoardGroupMessagesReadState.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.board; public record SetBoardGroupMessagesReadState(long groupId, boolean read) implements BoardNotification { @Override public String getType() { return SET_BOARD_GROUP_MESSAGES_READ_STATE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/board/SetBoardMessageReadState.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.board; public record SetBoardMessageReadState(long groupId, long messageId, boolean read) implements BoardNotification { @Override public String getType() { return SET_BOARD_MESSAGES_READ_STATE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/channel/AddOrUpdateChannelGroups.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.channel; import io.xeres.common.dto.channel.ChannelGroupDTO; import java.util.List; public record AddOrUpdateChannelGroups(List channelGroups) implements ChannelNotification { @Override public String getType() { return ADD_OR_UPDATE_CHANNEL_GROUPS; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/channel/AddOrUpdateChannelMessages.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.channel; import io.xeres.common.dto.channel.ChannelMessageDTO; import java.util.List; public record AddOrUpdateChannelMessages(List channelMessages) implements ChannelNotification { @Override public String getType() { return ADD_OR_UPDATE_CHANNEL_MESSAGES; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/channel/ChannelNotification.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.channel; import io.xeres.common.rest.notification.Notification; public sealed interface ChannelNotification extends Notification permits AddOrUpdateChannelGroups, AddOrUpdateChannelMessages, SetChannelGroupMessagesReadState, SetChannelMessageReadState { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/channel/SetChannelGroupMessagesReadState.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.channel; public record SetChannelGroupMessagesReadState(long groupId, boolean read) implements ChannelNotification { @Override public String getType() { return SET_CHANNEL_GROUP_MESSAGES_READ_STATE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/channel/SetChannelMessageReadState.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.channel; public record SetChannelMessageReadState(long groupId, long messageId, boolean read) implements ChannelNotification { @Override public String getType() { return SET_CHANNEL_MESSAGES_READ_STATE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/contact/AddOrUpdateContacts.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.contact; import io.xeres.common.rest.contact.Contact; import java.util.List; public record AddOrUpdateContacts(List contacts) implements ContactNotification { @Override public String getType() { return ADD_OR_UPDATE_CONTACTS; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/contact/ContactNotification.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.contact; import io.xeres.common.rest.notification.Notification; public sealed interface ContactNotification extends Notification permits AddOrUpdateContacts, RemoveContacts { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/contact/RemoveContacts.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.contact; import io.xeres.common.rest.contact.Contact; import java.util.List; public record RemoveContacts(List contacts) implements ContactNotification { @Override public String getType() { return REMOVE_CONTACTS; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/file/FileNotification.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.file; import io.xeres.common.rest.notification.Notification; public record FileNotification(FileNotificationAction action, String shareName, String scannedFile) implements Notification { @Override public String getType() { return FILE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/file/FileNotificationAction.java ================================================ package io.xeres.common.rest.notification.file; public enum FileNotificationAction { NONE, START_SCANNING, START_HASHING, STOP_HASHING, STOP_SCANNING } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/file/FileSearchNotification.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.file; import io.xeres.common.rest.notification.Notification; public record FileSearchNotification(int requestId, String name, long size, String hash) implements Notification { @Override public String getType() { return FILE_SEARCH; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/file/FileTrendNotification.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.file; import io.xeres.common.rest.notification.Notification; public record FileTrendNotification(String senderName, String keywords) implements Notification { @Override public String getType() { return FILE_TREND; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/forum/AddOrUpdateForumGroups.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.forum; import io.xeres.common.dto.forum.ForumGroupDTO; import java.util.List; public record AddOrUpdateForumGroups(List forumGroups) implements ForumNotification { @Override public String getType() { return ADD_OR_UPDATE_FORUM_GROUPS; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/forum/AddOrUpdateForumMessages.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.forum; import io.xeres.common.dto.forum.ForumMessageDTO; import java.util.List; public record AddOrUpdateForumMessages(List forumMessages) implements ForumNotification { @Override public String getType() { return ADD_OR_UPDATE_FORUM_MESSAGES; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/forum/ForumNotification.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.forum; import io.xeres.common.rest.notification.Notification; public sealed interface ForumNotification extends Notification permits AddOrUpdateForumGroups, AddOrUpdateForumMessages, SetForumGroupMessagesReadState, SetForumMessageReadState { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/forum/SetForumGroupMessagesReadState.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.forum; public record SetForumGroupMessagesReadState(long groupId, boolean read) implements ForumNotification { @Override public String getType() { return SET_FORUM_GROUP_MESSAGES_READ_STATE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/forum/SetForumMessageReadState.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.forum; public record SetForumMessageReadState(long groupId, long messageId, boolean read) implements ForumNotification { @Override public String getType() { return SET_FORUM_MESSAGES_READ_STATE; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/status/DhtInfo.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.status; public record DhtInfo(DhtStatus dhtStatus, int numPeers, long receivedPackets, long receivedBytes, long sentPackets, long sentBytes, int keyCount, int itemCount) { public static DhtInfo fromStatus(DhtStatus dhtStatus) { return new DhtInfo(dhtStatus, 0, 0, 0, 0, 0, 0, 0); } public static DhtInfo fromStats(int numPeers, long receivedPackets, long receivedBytes, long sentPackets, long sentBytes, int keyCount, int itemCount) { return new DhtInfo(DhtStatus.RUNNING, numPeers, receivedPackets, receivedBytes, sentPackets, sentBytes, keyCount, itemCount); } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/status/DhtStatus.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.status; public enum DhtStatus { OFF, INITIALIZING, RUNNING } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/status/NatStatus.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.status; public enum NatStatus { UNKNOWN, FIREWALLED, UPNP } ================================================ FILE: common/src/main/java/io/xeres/common/rest/notification/status/StatusNotification.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification.status; import io.xeres.common.rest.notification.Notification; public record StatusNotification(int currentUsers, int totalUsers, NatStatus natStatus, DhtInfo dhtInfo) implements Notification { @Override public String getType() { return STATUS; } @Override public boolean ignoreDuplicates() { return true; } } ================================================ FILE: common/src/main/java/io/xeres/common/rest/profile/ProfileKeyAttributes.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.profile; public record ProfileKeyAttributes(int version, int keyAlgorithm, int keyBits, int signatureHash) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/profile/RsIdRequest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.profile; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record RsIdRequest( @NotNull(message = "Missing RS id") @Size(min = LENGTH_MIN, max = LENGTH_MAX) String rsId ) { private static final int LENGTH_MIN = 8; private static final int LENGTH_MAX = 16384; } ================================================ FILE: common/src/main/java/io/xeres/common/rest/share/TemporaryShareRequest.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.share; import jakarta.validation.constraints.NotBlank; public record TemporaryShareRequest(@NotBlank String filePath) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/share/TemporaryShareResponse.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.share; public record TemporaryShareResponse(String hash) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/share/UpdateShareRequest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.share; import io.xeres.common.dto.share.ShareDTO; import java.util.List; public record UpdateShareRequest( List shares ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/statistics/DataCounterPeer.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.statistics; public record DataCounterPeer(long id, String name, long sent, long received) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/statistics/DataCounterStatisticsResponse.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.statistics; import java.util.List; public record DataCounterStatisticsResponse(List peers) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/statistics/RttPeer.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.statistics; public record RttPeer(long id, String name, long mean) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/statistics/RttStatisticsResponse.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.statistics; import java.util.List; public record RttStatisticsResponse(List peers) { } ================================================ FILE: common/src/main/java/io/xeres/common/rest/statistics/TurtleStatisticsResponse.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.statistics; public record TurtleStatisticsResponse( float forwardTotal, float dataUpload, float dataDownload, float tunnelRequestsUpload, float tunnelRequestsDownload, float searchRequestsUpload, float searchRequestsDownload, float totalUpload, float totalDownload ) { } ================================================ FILE: common/src/main/java/io/xeres/common/rsid/Type.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rsid; public enum Type { /** * This accepts any ID and generates the best one. */ ANY, /** * A short invite is a shorter version of an ID which contains enough information * to connect to one node. Its usage is recommended. */ SHORT_INVITE, /** * This is the legacy version of the ID which contains a full PGP key and allows * connecting to several nodes. Use short invites instead. */ CERTIFICATE } ================================================ FILE: common/src/main/java/io/xeres/common/tray/TrayNotificationType.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.tray; public enum TrayNotificationType { BROADCAST, CONNECTION, DISCOVERY } ================================================ FILE: common/src/main/java/io/xeres/common/util/ByteUnitUtils.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import io.xeres.common.i18n.I18nUtils; import java.text.DecimalFormat; import java.util.ResourceBundle; /** * In the beginning God created the computer. And the computer was without form, and void; * and darkness was upon the face of the silicon. And the Spirit of God moved upon the face of * the wafers. And God said, let there be bytes: and there were bytes. And God saw the bytes, * that they were good: and God divided the bytes by 1024. */ public final class ByteUnitUtils { private static final DecimalFormat df = new DecimalFormat("#.##"); private static final ResourceBundle bundle = I18nUtils.getBundle(); private ByteUnitUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Returns the number of bytes in their proper unit, from bytes to exabytes, with up to 2 decimals, except for KBs. * * @param bytes the number of bytes, must be a positive number * @return the bytes in their proper unit or "invalid" if a negative number was given as input */ public static String fromBytes(long bytes) { if (bytes < 0) { return bundle.getString("byte-unit.invalid"); } if (bytes < 1024 * 10) { return bytes + " " + bundle.getString("byte-unit.bytes"); } else if (bytes < 1024 * 1024) { return df.format(bytes / 1024) + " " + bundle.getString("byte-unit.kb"); } else if (bytes < 1024 * 1024 * 1024) { return df.format(bytes / 1024.0 / 1024.0) + " " + bundle.getString("byte-unit.mb"); } else if (bytes < 1024L * 1024 * 1024 * 1024) { return df.format(bytes / 1024.0 / 1024.0 / 1024.0) + " " + bundle.getString("byte-unit.gb"); } else if (bytes < 1024L * 1024 * 1024 * 1024 * 1024) { return df.format(bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0) + " " + bundle.getString("byte-unit.tb"); } else if (bytes < 1024L * 1024 * 1024 * 1024 * 1024 * 1024) { return df.format(bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0 / 1024.0) + " " + bundle.getString("byte-unit.pb"); } return df.format(bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0 / 1024.0 / 1024.0) + " " + bundle.getString("byte-unit.eb"); } } ================================================ FILE: common/src/main/java/io/xeres/common/util/DebugUtils.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; /** * Various utility functions to use when debugging. */ public final class DebugUtils { private DebugUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Waits for a certain time. Useful to simulate things like a network delay or a heavy computation. * * @param seconds the number of seconds to wait */ public static void wait(int seconds) { try { Thread.sleep(seconds * 1000L); } catch (InterruptedException _) { Thread.currentThread().interrupt(); } } } ================================================ FILE: common/src/main/java/io/xeres/common/util/ExecutorUtils.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public final class ExecutorUtils { private static final Logger log = LoggerFactory.getLogger(ExecutorUtils.class); private ExecutorUtils() { throw new UnsupportedOperationException("Utility class"); } public static ScheduledExecutorService createFixedRateExecutor(NoSuppressedRunnable command, long period) { return createFixedRateExecutor(command, period, period); } public static ScheduledExecutorService createFixedRateExecutor(NoSuppressedRunnable command, long initialDelay, long period) { var executorService = Executors.newSingleThreadScheduledExecutor(); executorService.scheduleAtFixedRate(command, initialDelay, period, TimeUnit.SECONDS); return executorService; } public static void cleanupExecutor(ScheduledExecutorService executorService) { if (executorService != null) { executorService.shutdownNow(); try { var success = executorService.awaitTermination(2, TimeUnit.SECONDS); if (!success) { log.warn("Executor {} failed to terminate during the waiting period", executorService); } } catch (InterruptedException _) { Thread.currentThread().interrupt(); } } } } ================================================ FILE: common/src/main/java/io/xeres/common/util/FileNameUtils.java ================================================ package io.xeres.common.util; import org.apache.commons.lang3.StringUtils; import java.util.Optional; import java.util.regex.Pattern; public final class FileNameUtils { private static final Pattern EXTENSION = Pattern.compile("\\.(?=[^.]+$)"); private static final Pattern DOWNLOAD_COUNT = Pattern.compile("\\((\\d{1,3})\\)$"); private FileNameUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Renames the files in a way similar as Chromium. * * @param fileName the file name * @return the filename with (1) appended or incremented */ public static String rename(String fileName) { if (StringUtils.isEmpty(fileName)) { throw new IllegalArgumentException("File name cannot be empty"); } var tokens = EXTENSION.split(fileName); if (tokens.length == 2) { // We have at least one extension, find out if it's // a .tar.gz style case so that we turn it into a // "(1).tar.gz" and not into a ".tar (1).gz". if (tokens[1].length() == 2) { var tarTokens = EXTENSION.split(tokens[0]); if (tarTokens.length == 2 && tarTokens[1].length() == 3) { return increment(tarTokens[0]) + "." + tarTokens[1] + "." + tokens[1]; } } return increment(tokens[0]) + "." + tokens[1]; } else { return increment(tokens[0]); } } /** * Gets the extension of a file name. * * @param fileName the file name * @return the extension, without its dot (for example "exe") or an empty optional if there's no extension */ public static Optional getExtension(String fileName) { var tokens = EXTENSION.split(fileName); if (tokens.length == 2) { return Optional.of(tokens[1]); } return Optional.empty(); } private static String increment(String input) { var matcher = DOWNLOAD_COUNT.matcher(input); if (matcher.find()) { var count = Integer.parseInt(matcher.group(1)); return input.substring(0, input.length() - (matcher.group(1).length() + 2)) + "(" + ++count + ")"; } else { return input + " (1)"; } } } ================================================ FILE: common/src/main/java/io/xeres/common/util/NoSuppressedRunnable.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import org.slf4j.LoggerFactory; /** * This interface should be used instead of Runnable for executors so that any * exception is printed. If it's a scheduled executor, it will also keep running. *
* Example: * {@snippet : * executorService.scheduleAtFixedRate((NoSuppressedRunnable) this::manageChatRooms, 10, 10, TimeUnit.SECONDS); *} */ @FunctionalInterface public interface NoSuppressedRunnable extends Runnable { @Override default void run() { try { doRun(); } catch (Exception e) { LoggerFactory.getLogger(NoSuppressedRunnable.class).error("Exception in executor {}: ", getClass().getSimpleName(), e); } } void doRun(); } ================================================ FILE: common/src/main/java/io/xeres/common/util/OsUtils.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import io.xeres.common.AppName; import net.harawata.appdirs.AppDirsFactory; import org.apache.commons.lang3.SystemUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.system.ApplicationHome; import java.awt.*; import java.io.*; import java.lang.ProcessBuilder.Redirect; import java.lang.management.ManagementFactory; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Locale; import java.util.Objects; import java.util.regex.Pattern; import static java.util.regex.Pattern.CASE_INSENSITIVE; public final class OsUtils { private static final Logger log = LoggerFactory.getLogger(OsUtils.class); private static final String CASE_FILE_PREFIX = "XeresFileSystemCaseDetectorFile"; private static final String CASE_FILE_EXTENSION = "tmp"; private static final String LOG_FILE_NAME = "xeres.log"; private static final Pattern INVALID_WINDOWS_FILE_CHARS = Pattern.compile("([\\\\/:*?\"<>|\\p{Cntrl}]|^nul$)", CASE_INSENSITIVE); private static final Pattern INVALID_LINUX_FILE_CHARS = Pattern.compile("[\\x00/]"); private static final Pattern INVALID_MACOS_FILE_CHARS = Pattern.compile("[:/]"); private OsUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Checks if a file system is case-sensitive. * * @param path the directory path in the filesystem hierarchy. The location must be writable. * @return true if case-sensitive */ public static boolean isFileSystemCaseSensitive(Path path) { Objects.requireNonNull(path); Path lowerFile; Path upperFile = null; try { lowerFile = createFileSystemDetectionFile(path, false); } catch (IOException e) { log.warn("Couldn't write file for filesystem case detection: {}, using OS guess workaround", e.getMessage()); return isOsCaseSensitive(); } try { upperFile = createFileSystemDetectionFile(path, true); } catch (FileAlreadyExistsException _) { return false; } catch (IOException e) { log.error("Couldn't write second file for filesystem case detection: {}, shouldn't happen but using OS guess workaround anyway", e.getMessage()); return isOsCaseSensitive(); } finally { try { if (lowerFile != null) { Files.deleteIfExists(lowerFile); } if (upperFile != null) { Files.deleteIfExists(upperFile); } } catch (IOException e) { log.error("Error while deleting filesystem detection files: {}", e.getMessage()); } } return true; } /** * Executes a shell command and its arguments, for example: *

* * shellExecute("ls", "-al"); * *

* * @param args the command and its arguments * @return the resulting output, line by line (with a {@code \n} separator at the end of each line), or a string starting with "Error: " and the message. */ public static String shellExecute(String... args) { var sb = new StringBuilder(); try { var processBuilder = new ProcessBuilder(args); var process = processBuilder.start(); try (var reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { sb.append(line).append("\n"); } } } catch (IOException e) { return "Error: " + e.getMessage(); } return sb.toString(); } /** * Executes a shell command and its arguments, asynchronously. * * @param args the command and its arguments */ public static void shellExecuteAsync(String... args) { try { var processBuilder = new ProcessBuilder(args); processBuilder.redirectOutput(Redirect.DISCARD); processBuilder.redirectError(Redirect.DISCARD); processBuilder.start(); } catch (IOException e) { throw new IllegalStateException(e); } } /** * Opens a file like if it was launched from a graphical shell (for example, by double-clicking on it). * * @param file the file to open * @throws IllegalStateException if the file doesn't exist, or the OS has troubles launching it * @throws IllegalArgumentException if the file is not a file (a directory, etc...) */ public static void shellOpen(File file) { Objects.requireNonNull(file); if (!file.exists()) { throw new IllegalStateException("Couldn't open the file " + file + " because it doesn't exist"); } if (!file.isFile()) { throw new IllegalArgumentException(file + " is not a file"); } // Since JDK 25.0.2, Desktop.getDesktop().open() no longer works with executables on Windows. // See https://github.com/openjdk/jdk/commit/eddbd359654cf6e2a437367461231ba37ee76918#r185592597 and // https://learn.microsoft.com/en-us/windows/win32/api/winsafer/nf-winsafer-saferiisexecutablefiletype if (isExecutable(file.getName())) { shellExecuteAsync(file.getAbsolutePath()); } else { if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { throw new IllegalStateException("Desktop is not supported"); } try { Desktop.getDesktop().open(file); // XXX: not well tested on Linux and macOS, especially regarding executables } catch (IOException | UnsupportedOperationException e) { throw new IllegalStateException("Couldn't open the file " + file + ": " + e.getMessage()); } } } /** * Opens the folder with the file selected. * * @param file the file to show in the folder * @throws IllegalStateException if the file doesn't exist, or the OS has troubles launching a file browser */ public static void showInFolder(File file) { Objects.requireNonNull(file); if (!file.exists()) { throw new IllegalStateException("Couldn't show the folder of the file " + file + " because the later doesn't exist"); } try { Desktop.getDesktop().browseFileDirectory(file); } catch (UnsupportedOperationException e) { if (SystemUtils.IS_OS_WINDOWS) { try { new ProcessBuilder("explorer.exe", "/select,", file.getCanonicalPath()).start(); } catch (IOException ex) { throw new IllegalStateException("Couldn't show the folder of the file " + file + ": " + ex.getMessage()); } } else { throw new IllegalStateException("Couldn't show the folder of the file " + file + ": " + e.getMessage()); } } } /** * Opens the directory in the file explorer and lists its content * * @param directory the directory */ public static void showFolder(File directory) { Objects.requireNonNull(directory); if (!directory.exists()) { throw new IllegalStateException("Couldn't show the folder " + directory + " because it doesn't exist"); } if (!directory.isDirectory()) { throw new IllegalStateException("Couldn't show the folder " + directory + " because it is not a directory"); } try { Desktop.getDesktop().browseFileDirectory(directory); // This is not exactly what we want } catch (UnsupportedOperationException e) { if (SystemUtils.IS_OS_WINDOWS) { try { new ProcessBuilder("explorer.exe", directory.getCanonicalPath()).start(); } catch (IOException ex) { throw new IllegalStateException("Couldn't show the folder " + directory + ": " + ex.getMessage()); } } else { throw new IllegalStateException("Couldn't show the folder " + directory + ": " + e.getMessage()); } } } /** * Sanitizes a file name. Replaces non-valid characters by '_'. * * @param fileName the file name to sanitize * @return a sanitized version of the file name, or the original one if there's nothing to sanitize */ public static String sanitizeFileName(String fileName) { Objects.requireNonNull(fileName); if (SystemUtils.IS_OS_WINDOWS) { // Any Unicode except control characters, \, /, :, *, ?, ", <, >, | and no spaces at the beginning // or the end. The Win32 API automatically removes a single period at the end. // Forget about the "invalids" CON, AUX, COM1...9, LPT1...9. Those are only restricted in a cmd.exe or by explorer.exe, but they are valid // file names (you can create them with PowerShell, for example). Only NUL is restricted. return INVALID_WINDOWS_FILE_CHARS.matcher(fileName).replaceAll("_").trim(); } else if (SystemUtils.IS_OS_MAC) { // macOS is : and / return INVALID_MACOS_FILE_CHARS.matcher(fileName).replaceAll("_"); } else // Assume the rest is Linux { // Linux is NUL and / return INVALID_LINUX_FILE_CHARS.matcher(fileName).replaceAll("_"); } } private static Path createFileSystemDetectionFile(Path path, boolean upperCase) throws IOException { var file = path.toFile(); var pid = ManagementFactory.getRuntimeMXBean().getPid(); var pathCaseFile = Path.of((upperCase ? CASE_FILE_PREFIX.toUpperCase(Locale.ROOT) : CASE_FILE_PREFIX.toLowerCase(Locale.ROOT)) + "_" + pid + "." + CASE_FILE_EXTENSION); if (file.isDirectory()) { return Files.createFile(path.resolve(pathCaseFile)); } else if (file.isFile()) { return Files.createFile(path.resolveSibling(pathCaseFile)); } else { throw new IllegalStateException("Created path is not a directory nor a file"); } } private static boolean isOsCaseSensitive() { if (SystemUtils.IS_OS_LINUX) { return true; } else if (SystemUtils.IS_OS_MAC) { return false; } else if (SystemUtils.IS_OS_WINDOWS) { return false; } else { throw new IllegalArgumentException("OS is unsupported"); } } /** * Sets the security level of the file. This currently only works on Windows. * * @param path the path of the file * @param trusted if true, the security zone is set to Trusted Site Zone, otherwise it's set to Internet Zone */ public static void setFileSecurity(Path path, boolean trusted) { Objects.requireNonNull(path); if (SystemUtils.IS_OS_WINDOWS) { try (var ads = new RandomAccessFile(path + ":Zone.Identifier", "rw")) // We can't use Path.of() here as it won't accept the ':' { byte[] data = ("[ZoneTransfer]\r\nZoneId=" + (trusted ? "2" : "3") + "\r\nHostUrl=about:internet\r\n").getBytes(); ads.write(data); } catch (IOException e) { log.warn("Couldn't set security zone of file {}: {}", path, e.getMessage()); } } } /** * Sets the visibility of the file. *

* Note: only works on Windows. * * @param path the path of the file * @param visible true if the file must be visible (default when creating new files), false otherwise */ public static void setFileVisible(Path path, boolean visible) { if (SystemUtils.IS_OS_WINDOWS) { try { Files.setAttribute(path, "dos:hidden", !visible); } catch (IOException e) { log.warn("Couldn't set the visibility of file at {}: {}", path, e.getMessage()); } } } /** * Gets the application home. * * @return the path where the application is installed */ public static Path getApplicationHome() { var home = new ApplicationHome(OsUtils.class); return home.getDir().toPath().toAbsolutePath(); } /** * Gets the OS system cache directory of the app. * * @return the cache directory */ public static Path getCacheDir() { return Path.of(AppDirsFactory.getInstance().getUserCacheDir(AppName.NAME, null, null)); } /** * Gets the OS data directory of the app. * * @return the data directory */ public static Path getDataDir() { return Path.of(AppDirsFactory.getInstance().getUserDataDir(AppName.NAME, null, null, true)); } /** * Gets the OS download directory of the app. * * @return the download directory */ public static Path getDownloadDir() { return Path.of(AppDirsFactory.getInstance().getUserDownloadsDir(null, null, null)); } /** * Gets the location of the log file. * * @return the location of the logfile */ public static Path getLogFile() { if (isInstalled()) { return Path.of(OsUtils.getDataDir().toString(), "Logs", LOG_FILE_NAME); } else { // Assumes we're in portable mode and from CurrentDir return Path.of(LOG_FILE_NAME); } } /** * Checks if we're installed in the system. * * @return true if we were installed by jpackage */ public static boolean isInstalled() { var appPath = System.getProperty("jpackage.app-path"); return appPath != null && !appPath.isEmpty(); } private static boolean isExecutable(String name) { if (SystemUtils.IS_OS_WINDOWS) { return name.toLowerCase(Locale.ROOT).endsWith(".exe") || name.endsWith(".msi"); } return false; } } ================================================ FILE: common/src/main/java/io/xeres/common/util/RemoteUtils.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; /** * Some utility class to get remote information for the client. */ public final class RemoteUtils { private RemoteUtils() { throw new UnsupportedOperationException("Utility class"); } public static String getHostnameAndPort() { return getHostname() + ":" + getControlPort(); } private static String getHostname() { return System.getProperty("xrs.ui.address", "127.0.0.1"); } private static int getControlPort() { return Integer.parseInt(System.getProperty("xrs.ui.port", "6232")); } public static String getControlUrl() { if ("true".equals(System.getProperty("server.ssl.enabled"))) { return "https://" + getHostnameAndPort(); } //noinspection HttpUrlsUsage return "http://" + getHostnameAndPort(); } /** * Checks if we're running as a remote client. That is, we're connecting to * a remote location. * * @return true if we are a remote client */ public static boolean isRemoteUiClient() { return "none".equals(System.getProperty("spring.main.web-application-type")); } } ================================================ FILE: common/src/main/java/io/xeres/common/util/SecureRandomUtils.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import java.security.SecureRandom; import java.util.Collections; import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; /** * A utility class to get secure random numbers. Prefer this instead of using new SecureRandom() directly * as it's more efficient. If you don't need a secure random, use {@code ThreadLocalRandom.current()}. */ public final class SecureRandomUtils { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private SecureRandomUtils() { throw new UnsupportedOperationException("Utility class"); } public static short nextShort() { return (short) SECURE_RANDOM.nextInt(); } public static int nextInt() { return SECURE_RANDOM.nextInt(); } public static long nextLong() { return SECURE_RANDOM.nextLong(); } public static double nextDouble() { return SECURE_RANDOM.nextDouble(); } public static void nextBytes(byte[] bytes) { SECURE_RANDOM.nextBytes(bytes); } public static SecureRandom getGenerator() { return SECURE_RANDOM; } /** * Creates a secure password consisting of alphanumerical characters in upper and lower case. * * @param password the byte array that will be filled in with a password. Between 1 and 512 bytes. */ public static void nextPassword(char[] password) { Objects.requireNonNull(password); var size = password.length; if (size == 0) { throw new IllegalArgumentException("Password length must be at least 1"); } if (size > 512) { throw new IllegalArgumentException("Password length must be less than or equal to 512"); } var upperSize = size / 3; var lowerSize = size / 3; size -= lowerSize + upperSize; var numberSize = size; var passwordList = Stream.concat(getUpperCaseChars(upperSize), Stream.concat(getLowerCaseChars(lowerSize), getNumbers(numberSize))) .collect(Collectors.toList()); Collections.shuffle(passwordList); for (var i = 0; i < passwordList.size(); i++) { password[i] = passwordList.get(i); } } private static Stream getUpperCaseChars(int count) { var upperChars = SECURE_RANDOM.ints(count, 65, 91); return upperChars.mapToObj(data -> (char) data); } private static Stream getLowerCaseChars(int count) { var lowerChars = SECURE_RANDOM.ints(count, 97, 123); return lowerChars.mapToObj(data -> (char) data); } private static Stream getNumbers(int count) { var lowerChars = SECURE_RANDOM.ints(count, 48, 58); return lowerChars.mapToObj(data -> (char) data); } } ================================================ FILE: common/src/main/java/io/xeres/common/util/ThreadUtils.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; public final class ThreadUtils { private static final Logger log = LoggerFactory.getLogger(ThreadUtils.class); private ThreadUtils() { throw new UnsupportedOperationException("Utility class"); } public static void waitForThread(Thread thread) { if (thread == null) { return; } try { if (!thread.join(Duration.ofSeconds(5))) { log.warn("Thread {} timed out", thread.getName()); } } catch (InterruptedException e) { log.error("Failed to wait for termination on thread {}: {}", thread.getName(), e.getMessage(), e); Thread.currentThread().interrupt(); } } } ================================================ FILE: common/src/main/java/io/xeres/common/util/image/ImageUtils.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util.image; import dev.mccue.imgscalr.Scalr; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import java.awt.*; import java.awt.image.BufferedImage; import java.awt.image.IndexColorModel; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Base64; import java.util.Iterator; import java.util.stream.IntStream; /** * Provides utility methods for working with images. */ public final class ImageUtils { private static final Logger log = LoggerFactory.getLogger(ImageUtils.class); private static final String DATA_IMAGE_PNG_BASE_64 = "data:image/png;base64,"; private static final String DATA_IMAGE_JPEG_BASE_64 = "data:image/jpeg;base64,"; private static final byte[] JPEG_HEADER = new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff}; private static final byte[] PNG_HEADER = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}; private static final byte[] GIF_HEADER = new byte[]{'G', 'I', 'F'}; private static final byte[] RIFF_HEADER = new byte[]{'R', 'I', 'F', 'F'}; private static final byte[] WEBP_SIGNATURE = new byte[]{'W', 'E', 'B', 'P'}; private static final int MAX_PNG_QUALITY = 3; private static final int MIN_PNG_QUALITY = 2; // 1 is too CPU intensive and doesn't compress much more (around 2.5% better) private ImageUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Writes a buffered image as a PNG or JPEG data URL, depending on the needs, * that is, a transparent image will be PNG and the rest will be JPEG. A transparent * image that is effectively opaque will still result as a JPEG. * * @param bufferedImage the image * @param maximumSize the maximum size of the image in bytes. If 0, no limit is applied. * @return the image as a PNG or JPEG data URL, or an empty string if the image couldn't be written. */ public static String writeImage(BufferedImage bufferedImage, int maximumSize) { if (isTransparent(bufferedImage)) { return writeImageAsPngData(bufferedImage, maximumSize); } else { return writeImageAsJpegData(bufferedImage, maximumSize); } } /** * Writes a buffered image as a PNG file. The image is optimized * trying to fit the size. If needed, the image is converted to indexed PNG or scaled. * * @param bufferedImage the buffered image * @param maximumSize the maximum size of the image in bytes. If 0, no limit is applied. * @param outputStream the output stream * @return true if the image could be written, false otherwise */ public static boolean writeImageAsPng(BufferedImage bufferedImage, int maximumSize, OutputStream outputStream) { try { var out = new ByteArrayOutputStream(); PngUtils.writeBufferedImageToPng(bufferedImage, out); out = compressPngWithVaryingQuality(out, maximumSize); // If still too big, try to convert to indexed PNG and then optimize it again if (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray())) { out = new ByteArrayOutputStream(); bufferedImage = PngUtils.convertToIndexedPng(bufferedImage); PngUtils.writeBufferedImageToPng(bufferedImage, out); out = compressPng(out); // Indexed PNGs can't use varying qualities } if (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray())) { out = new ByteArrayOutputStream(); bufferedImage = PngUtils.convertToIndexedPng(limitMaximumImageSize(bufferedImage, (int) (bufferedImage.getWidth() * bufferedImage.getHeight() * 0.75))); PngUtils.writeBufferedImageToPng(bufferedImage, out); out = compressPng(out); // Ditto } if (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray())) { out = new ByteArrayOutputStream(); bufferedImage = PngUtils.convertToIndexedPng(limitMaximumImageSize(bufferedImage, (int) (bufferedImage.getWidth() * bufferedImage.getHeight() * 0.50))); PngUtils.writeBufferedImageToPng(bufferedImage, out); out = compressPng(out); // Ditto } if (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray())) { log.warn("Couldn't compress to PNG below the maximum size"); return false; } outputStream.write(out.toByteArray()); return true; } catch (IOException e) { log.error("Couldn't save buffered image as PNG: {}", e.getMessage()); return false; } } /** * Writes a buffered image as a PNG data URL. The image is optimized * trying to fit the size. If needed, the image is converted to indexed PNG. * * @param bufferedImage the buffered image * @param maximumSize the maximum size of the image in bytes. If 0, no limit is applied. * @return the image as a PNG data URL, or an empty string if the image couldn't be written. */ public static String writeImageAsPngData(BufferedImage bufferedImage, int maximumSize) { var out = new ByteArrayOutputStream(); if (writeImageAsPng(bufferedImage, maximumSize, out)) { return DATA_IMAGE_PNG_BASE_64 + Base64.getEncoder().encodeToString(out.toByteArray()); } else { return ""; } } /** * Writes an image as a JPEG file. The image is optimized and its quality reduced * until it fits the size. * * @param bufferedImage the image * @param maximumSize the maximum size of the image in bytes. If 0, no limit is applied. * @param outputStream the output stream * @return true if the image could be written, false otherwise */ public static boolean writeImageAsJpeg(BufferedImage bufferedImage, int maximumSize, OutputStream outputStream) { try { var out = new ByteArrayOutputStream(); var quality = 0.7f; byte[] array; bufferedImage = JpegUtils.stripAlphaIfNeeded(bufferedImage); do { JpegUtils.writeBufferedImageToJpeg(bufferedImage, quality, out); array = out.toByteArray(); quality -= 0.1f; } while (canCompressionPossiblyBeImproved(maximumSize, array, quality)); outputStream.write(array); return true; } catch (IOException e) { log.error("Couldn't save image as JPEG: {}", e.getMessage()); return false; } } /** * Writes an image as a JPEG data URL. The image is optimized and its quality reduced * until it fits the size. * * @param bufferedImage the image * @param maximumSize the maximum size of the image in bytes. If 0, no limit is applied. * @return the image as a JPEG data URL, or an empty string if the image couldn't be written. */ public static String writeImageAsJpegData(BufferedImage bufferedImage, int maximumSize) { var out = new ByteArrayOutputStream(); if (writeImageAsJpeg(bufferedImage, maximumSize, out)) { return DATA_IMAGE_JPEG_BASE_64 + Base64.getEncoder().encodeToString(out.toByteArray()); } else { return ""; } } /** * Limits the size of an image by scaling it down. The aspect ratio is always preserved. * Uses a high quality incremental scaling algorithm. * * @param image the image * @param maximumSize the maximum size of the image in total number of pixels * @return the scaled image */ public static BufferedImage limitMaximumImageSize(BufferedImage image, int maximumSize) { var width = image.getWidth(); var height = image.getHeight(); var size = width * height; if (size > maximumSize) { var reductionRatio = Math.sqrt((double) maximumSize / size); var destWidth = (int) (width * reductionRatio); var destHeight = (int) (height * reductionRatio); // This uses incremental scaling, which is the best one return Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, destWidth, destHeight); } return image; } /** * Scales an image up or down. The aspect ratio is always preserved, thus the target width and height might be smaller than * the parameters given. Uses a high quality incremental scaling algorithm. * * @param image the image * @param targetWidth the target width * @param targetHeight the target height * @return the scaled image */ public static BufferedImage setImageSize(BufferedImage image, int targetWidth, int targetHeight) { var width = image.getWidth(); var height = image.getHeight(); // Calculate the scaling factor to fit within target dimensions double scaleX = (double) targetWidth / width; double scaleY = (double) targetHeight / height; // Use the smaller scale to ensure the image fits within both dimensions double scale = Math.min(scaleX, scaleY); // Calculate the resulting dimensions int newWidth = (int) Math.round(width * scale); int newHeight = (int) Math.round(height * scale); // Apply the scaling using imgscalr return Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, newWidth, newHeight); } /** * Scales an image up or down. The aspect ratio is always preserved. If the source image is not square, the * background is filled either by a transparent background or a white background depending on the source image. * * @param image the image * @param sideSize the side size * @return the scaled image */ public static BufferedImage setImageSquareAndFill(BufferedImage image, int sideSize) { var scaledImage = setImageSize(image, sideSize, sideSize); // Determine if we need a transparent background boolean hasTransparency = isTransparent(image); // Create a new image with the specified side size int imageType = hasTransparency ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB; var result = new BufferedImage(sideSize, sideSize, imageType); // Create a graphics context to draw on the new image var graphics = result.createGraphics(); // Set background color based on transparency if (hasTransparency) { // For transparent backgrounds, we'll use alpha to create transparency graphics.setComposite(AlphaComposite.Clear); graphics.fillRect(0, 0, sideSize, sideSize); graphics.setComposite(AlphaComposite.SrcOver); } else { // For non-transparent images, use white background graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, sideSize, sideSize); } // Calculate position to center the scaled image int x = (sideSize - scaledImage.getWidth()) / 2; int y = (sideSize - scaledImage.getHeight()) / 2; // Draw the scaled image centered on the new image graphics.drawImage(scaledImage, x, y, null); // Clean up graphics.dispose(); return result; } /** * Scales an image up or down. The aspect ratio is always preserved. If the source image is not square, its * largest dimension is cropped. * * @param image the image * @param sideSize the side size * @return the scaled image */ public static BufferedImage setImageSquareAndCrop(BufferedImage image, int sideSize) { // Determine if we need a transparent background boolean hasTransparency = isTransparent(image); var width = image.getWidth(); var height = image.getHeight(); // Calculate the scaling factor to fit within target dimensions double scaleX = (double) sideSize / width; double scaleY = (double) sideSize / height; // Use the smaller scale to ensure the image fits within both dimensions double scale = Math.max(scaleX, scaleY); // Calculate the resulting dimensions int newWidth = (int) Math.round(width * scale); int newHeight = (int) Math.round(height * scale); // Apply the scaling using imgscalr var scaledImage = Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, newWidth, newHeight); // Create a new image with the specified side size int imageType = hasTransparency ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB; var result = new BufferedImage(sideSize, sideSize, imageType); // Create a graphics context to draw on the new image var graphics = result.createGraphics(); // Calculate position to center the scaled image int x = (sideSize - scaledImage.getWidth()) / 2; int y = (sideSize - scaledImage.getHeight()) / 2; // Draw the scaled image centered on the new image graphics.drawImage(scaledImage, x, y, null); // Clean up graphics.dispose(); return result; } /** * Detects the format of the image without decoding the whole. *

* Currently supported: *

    *
  • PNG
  • *
  • JPEG
  • *
  • GIF
  • *
  • WebP
  • *
* * @param image the byte array containing the image data * @return the {@link MediaType} of the image or null if unknown */ public static MediaType getImageMimeType(byte[] image) { if (image == null) { return null; } if (isStartingWith(PNG_HEADER, image)) { return MediaType.IMAGE_PNG; } else if (isStartingWith(JPEG_HEADER, image)) { return MediaType.IMAGE_JPEG; } else if (isStartingWith(GIF_HEADER, image)) { return MediaType.IMAGE_GIF; } else if (isStartingWith(RIFF_HEADER, image) && contains(WEBP_SIGNATURE, 8, image)) { return MediaType.parseMediaType("image/webp"); } return null; } /** * Gets an image dimension without decoding the image data. * * @param inputStream the input stream * @return the image dimension or null if there was an error */ public static Dimension getImageDimension(InputStream inputStream) { try (var in = ImageIO.createImageInputStream(inputStream)) { Iterator readers = ImageIO.getImageReaders(in); if (readers.hasNext()) { ImageReader reader = readers.next(); try { reader.setInput(in); int width = reader.getWidth(0); int height = reader.getHeight(0); return new Dimension(width, height); } finally { reader.dispose(); } } log.warn("Unsupported image format"); } catch (IOException e) { log.warn("Invalid image file: {}", e.getMessage()); } return null; } /** * Finds out if a media format is possibly transparent. This focuses more on the intent * of the format and is thus unreliable because some formats support both modes. * * @param contentType the MIME content type (for example "image/jpeg") * @return true if possibly transparent */ public static boolean isPossiblyTransparent(String contentType) { return MediaType.IMAGE_PNG_VALUE.equals(contentType) || MediaType.IMAGE_GIF_VALUE.equals(contentType) || "image/webp".equals(contentType) || "image/svg+xml".equals(contentType) || "image/x-icon".equals(contentType); } private static ByteArrayOutputStream compressPngWithVaryingQuality(ByteArrayOutputStream input, int maximumSize) throws IOException { var quality = MAX_PNG_QUALITY; ByteArrayOutputStream newOut; do { newOut = new ByteArrayOutputStream(); PngUtils.optimizePng(input.toByteArray(), quality, newOut); log.debug("quality: {}, size: {}, maximumSize: {}", quality, newOut.size(), maximumSize); quality -= 1; } while (canCompressionPossiblyBeImproved(maximumSize, newOut.toByteArray(), quality)); return newOut; } private static ByteArrayOutputStream compressPng(ByteArrayOutputStream input) throws IOException { var out = new ByteArrayOutputStream(); PngUtils.optimizePng(input.toByteArray(), MAX_PNG_QUALITY, out); return out; } private static boolean isStartingWith(byte[] header, byte[] image) { return contains(header, 0, image); } private static boolean contains(byte[] signature, int offset, byte[] image) { return image.length >= signature.length + offset && IntStream.range(offset, signature.length).allMatch(i -> signature[i] == image[i]); } private static boolean canCompressionPossiblyBeImproved(int maximumSize, byte[] array, float quality) { return maximumSize != 0 && Math.ceil((double) array.length / 3) * 4 > maximumSize - 200 && quality >= MIN_PNG_QUALITY; // 200 bytes to be safe as the message might contain tags and so on } private static boolean canCompressionPossiblyBeImproved(int maximumSize, byte[] array) { return canCompressionPossiblyBeImproved(maximumSize, array, MAX_PNG_QUALITY); } private static boolean isTransparent(BufferedImage bufferedImage) { var cm = bufferedImage.getColorModel(); // IndexColorModel may define a single transparent palette index if (cm instanceof IndexColorModel icm) { if (icm.getTransparentPixel() != -1) { return true; } // if no transparent palette index and no alpha, it's opaque if (!icm.hasAlpha()) { return false; } } else { // If color model reports no alpha channel, image is opaque if (!cm.hasAlpha()) { return false; } } // Now check pixel alpha values. Use a single getRGB call for speed. int w = bufferedImage.getWidth(); int h = bufferedImage.getHeight(); int totalPixels = w * h; int transparentCount = 0; int[] pixels = bufferedImage.getRGB(0, 0, w, h, null, 0, w); for (int argb : pixels) { int alpha = (argb >>> 24) & 0xff; if (alpha != 0xff) { transparentCount++; } } // If more than 5% is transparent, then we are transparent. Windows' snap tool // loves to add useless transparent borders. return (double) transparentCount / totalPixels > 0.05; } } ================================================ FILE: common/src/main/java/io/xeres/common/util/image/JpegUtils.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util.image; import javax.imageio.IIOImage; import javax.imageio.ImageIO; import javax.imageio.ImageWriteParam; import java.awt.*; import java.awt.image.BufferedImage; import java.io.IOException; import java.io.OutputStream; /** * This class contains private utility methods for working with JPEG images. */ final class JpegUtils { private JpegUtils() { throw new UnsupportedOperationException("Utility class"); } static void writeBufferedImageToJpeg(BufferedImage image, float quality, OutputStream outputStream) throws IOException { var jpegWriter = ImageIO.getImageWritersByFormatName("JPEG").next(); var jpegWriteParam = jpegWriter.getDefaultWriteParam(); jpegWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); jpegWriteParam.setCompressionQuality(quality); var ios = ImageIO.createImageOutputStream(outputStream); jpegWriter.setOutput(ios); var outputImage = new IIOImage(image, null, null); jpegWriter.write(null, outputImage, jpegWriteParam); jpegWriter.dispose(); } static BufferedImage stripAlphaIfNeeded(BufferedImage originalImage) { if (originalImage.getTransparency() == Transparency.OPAQUE) { return originalImage; } var w = originalImage.getWidth(); var h = originalImage.getHeight(); var newImage = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); var rgb = originalImage.getRGB(0, 0, w, h, null, 0, w); newImage.setRGB(0, 0, w, h, rgb, 0, w); return newImage; } } ================================================ FILE: common/src/main/java/io/xeres/common/util/image/PngUtils.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util.image; import com.googlecode.pngtastic.core.PngImage; import com.googlecode.pngtastic.core.PngOptimizer; import javax.imageio.IIOImage; import javax.imageio.ImageIO; import javax.imageio.ImageWriteParam; import java.awt.image.BufferedImage; import java.awt.image.ComponentColorModel; import java.awt.image.IndexColorModel; import java.awt.image.PackedColorModel; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.Comparator; import java.util.List; /** * This class contains private utility methods for working with PNG images. */ final class PngUtils { private PngUtils() { throw new UnsupportedOperationException("Utility class"); } static BufferedImage convertToIndexedPng(BufferedImage image) { var indexedImage = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_INDEXED, getOrCreateIndexedColorModel(image)); // Using drawImage with an indexed color model always produces dithering which looks ugly for stickers, so we copy manually for (var x = 0; x < image.getWidth(); x++) { for (var y = 0; y < image.getHeight(); y++) { indexedImage.setRGB(x, y, image.getRGB(x, y)); } } return indexedImage; } /** * Creates or uses an indexed color model for the given image. * * @param image the image * @return and indexed color model */ private static IndexColorModel getOrCreateIndexedColorModel(BufferedImage image) { var colorModel = image.getColorModel(); if (colorModel instanceof IndexColorModel indexColorModel) { return indexColorModel; } else if (colorModel instanceof PackedColorModel || colorModel instanceof ComponentColorModel) { return createOptimizedPalette(image, 256); } else { throw new IllegalArgumentException("Unsupported color model: " + colorModel.getClass().getSimpleName()); } } /** * Creates an optimized palette for the given image. * Uses a median-cut algorithm to create a palette with the given number of colors. * Transparency is preserved, but only one level is used, so it can still give * some rough edges. * * @param image the image * @param paletteSize the size of the palette. Should be a power of 2 or colors will be wasted. * @return the optimized palette */ static IndexColorModel createOptimizedPalette(BufferedImage image, int paletteSize) { if (paletteSize < 1 || paletteSize > 256) { throw new IllegalArgumentException("Palette size must be between 1 and 256"); } // Extract ARGB pixels and check if we have transparency int width = image.getWidth(); int height = image.getHeight(); var argbPixels = new int[width * height]; image.getRGB(0, 0, width, height, argbPixels, 0, width); List opaquePixels = new ArrayList<>(); var hasTransparency = false; for (int argb : argbPixels) { int a = (argb >> 24) & 0xff; if (a < 128) { hasTransparency = true; } else { opaquePixels.add(new int[]{ (argb >> 16) & 0xff, // R (argb >> 8) & 0xff, // G argb & 0xff // B }); } } // Adjust palette size for transparency int colorSlots = hasTransparency ? paletteSize - 1 : paletteSize; colorSlots = Math.max(1, colorSlots); // Ensure at least 1 color List palette = medianCut(opaquePixels, colorSlots); // Create IndexColorModel var r = new byte[paletteSize]; var g = new byte[paletteSize]; var b = new byte[paletteSize]; var a = new byte[paletteSize]; // Set transparent entry if needed var firstColorIdx = 0; if (hasTransparency) { firstColorIdx = 1; // The first color is transparent } // Fill palette colors for (var i = 0; i < palette.size(); i++) { int idx = firstColorIdx + i; if (idx >= paletteSize) { break; // Safety check } int[] color = palette.get(i); r[idx] = (byte) color[0]; g[idx] = (byte) color[1]; b[idx] = (byte) color[2]; a[idx] = (byte) 0xff; // Opaque } return new IndexColorModel( Integer.SIZE - Integer.numberOfLeadingZeros(paletteSize - 1), paletteSize, r, g, b, a ); } /** * Creates a palette by performing a median-cut algorithm, given all pixels from an image: *
    *
  • Find the color channel (R, G, or B) with the greatest range *
  • Sort pixels by that channel and split them into two buckets at the median *
  • Repeat recursively until the desired number of buckets (colors) is reached *
  • Average each bucket to create the final palette *
*

* * @param pixels a list of pixels * @param maxColors the maximum number of colors in the palette * @return the palette */ private static List medianCut(List pixels, int maxColors) { List> buckets = new ArrayList<>(); buckets.add(pixels); while (buckets.size() < maxColors) { List> newBuckets = new ArrayList<>(); for (List bucket : buckets) { if (bucket.size() <= 1) { newBuckets.add(bucket); continue; } int channel = findChannel(bucket); bucket.sort(Comparator.comparingInt(pixel -> pixel[channel])); int median = bucket.size() / 2; newBuckets.add(bucket.subList(0, median)); newBuckets.add(bucket.subList(median, bucket.size())); } buckets = newBuckets; } // Average buckets to create a palette List palette = new ArrayList<>(); for (List bucket : buckets) { if (bucket.isEmpty()) { continue; } palette.add(getAverage(bucket)); } return palette; } /** * Finds the channel with the greatest range. * * @param bucket the bucket of pixels * @return the channel with the greatest range (0, 1 or 2) */ private static int findChannel(List bucket) { int[] min = {255, 255, 255}; int[] max = {0, 0, 0}; for (int[] pixel : bucket) { for (var i = 0; i < 3; i++) { min[i] = Math.min(min[i], pixel[i]); max[i] = Math.max(max[i], pixel[i]); } } // Sort and split if (max[0] - min[0] >= max[1] - min[1]) { if (max[0] - min[0] >= max[2] - min[2]) { return 0; } else { return 2; } } else { if (max[1] - min[1] >= max[2] - min[2]) { return 1; } else { return 2; } } } private static int[] getAverage(List bucket) { int[] avg = {0, 0, 0}; for (int[] pixel : bucket) { for (var i = 0; i < 3; i++) { avg[i] += pixel[i]; } } for (var i = 0; i < 3; i++) { avg[i] /= bucket.size(); } return avg; } private static int qualityToCompressionLevel(int quality) { return switch (quality) { case 3 -> 3; case 2 -> 6; case 1 -> 9; default -> throw new IllegalStateException("Unexpected value: " + quality); }; } /** * Optimizes the given PNG image using the given compression level. * * @param in the PNG image as a byte array * @param quality the quality (3, 2 and 1, 1 being best but most CPU intensive) * @param outputStream the output stream to write the image to * @throws IOException if an I/O error occurs */ static void optimizePng(byte[] in, int quality, OutputStream outputStream) throws IOException { int compressionLevel = qualityToCompressionLevel(quality); var pngImage = new PngImage(in); var optimizer = new PngOptimizer(); var pngOut = optimizer.optimize(pngImage, true, compressionLevel); pngOut.writeDataOutputStream(outputStream); } /** * Writes the given image to PNG using the default compression level. *

* This compressor doesn't compress very well, and it's a good idea to run it through an optimizer. * * @param image the image to compress * @param outputStream the output stream to write the image to * @throws IOException if an I/O error occurs */ static void writeBufferedImageToPng(BufferedImage image, OutputStream outputStream) throws IOException { var pngWriter = ImageIO.getImageWritersByFormatName("PNG").next(); var pngWriteParam = pngWriter.getDefaultWriteParam(); pngWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); pngWriteParam.setCompressionQuality(0.5f); // This compressor is actually pretty bad and doesn't change much depending on the quality var ios = ImageIO.createImageOutputStream(outputStream); pngWriter.setOutput(ios); var outputImage = new IIOImage(image, null, null); pngWriter.write(null, outputImage, pngWriteParam); pngWriter.dispose(); } } ================================================ FILE: common/src/main/javadoc/overview.html ================================================ This is the common part of Xeres. It contains code shared between the server part and the UI part. ================================================ FILE: common/src/main/resources/i18n/messages.properties ================================================ # # Copyright (c) 2019-2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # Common ok=OK cancel=Cancel close=Close send=Send create=Create remove=Remove download=Download add=Add open=Open copy-link=Copy Link Address copy=Copy save-as=Save as... paste-id=Paste own ID undo=Undo redo=Redo cut=Cut paste=Paste delete=Delete select-all=Select All deselect-all=Deselect All view-fullscreen=View Fullscreen copy-image=Copy Image save-image-as=Save Image as... enabled=Enabled no-results=No results found skip=Skip name=Name help=Help settings=Settings exit=Exit profile=Profile subscribed=Subscribed own=Own description=Description subject=Subject hash=Hash size=Size trust=Trust unknown-lc=unknown logo=Logo latest=Latest update=Update edit=Edit body=Body text (optional) text=Text image=Image link=Link mark-read-unread=Mark as read/unread mark-unread=Mark as unread thumbnail=Thumbnail posts-at-remote-nodes=Posts at remote node last-activity=Last activity state=State ip=IP port=Port # File Requesters file-requester.profiles=Profile files file-requester.xml=XML files file-requester.png=PNG files file-requester.sounds=Sound files file-requester.select-sound-title=Choose a sound file file-requester.images=Image files file-requester.save-image-title=Select where to save your image file-requester.error=Error with file {0}: {1} file-requester.add-files=Select file(s) to add # Main ## Menu main.menu.add-peer=Add Peer... main.menu.broadcast=Broadcast... main.menu.shares=Configure shares main.menu.statistics=Show statistics main.menu.tools=Tools main.menu.tools.import-from-rs=Import friends from Retroshare... main.menu.tools.export=Export... main.menu.help.about=About Xeres main.menu.help.documentation=Documentation main.menu.help.report-bug=Report bug ↗ main.menu.help.check-for-updates=Check for updates... ↗ main.friends-import-successful=Imported {0} locations successfully. main.friends-import-errors=Imported {0} locations, but {1} had errors. main.systray.peers={0,number,integer} peers connected ## Splash splash.status.database=Loading database splash.status.network=Starting network ## Content main.home=Home main.contacts=Contacts main.chats=Chats main.forums=Forums main.files=Files main.boards=Boards main.channels=Channels main.home.slogan=Where Friendship Meets Freedom main.home.share-id=This is your Xeres ID. Share it with other people. main.home.received-id=Did you receive an ID from a peer? main.home.add-peer=Add Peer main.home.add-peer.tip=Add a friend by pasting its ID main.home.need-help=Need help? main.home.online-help=Online Help ↗ main.home.online-help.tip=Show the online help (Ctrl+F1) main.home.copy-id.tip=Copy Xeres ID to clipboard main.home.qrcode.tip=Use the QR code to transfer your ID. Print it or take a picture with your phone then show it to a webcam. main.select-avatar=Select Avatar Picture main.export-profile=Select where to save your profile main.import-friends=Select the Retroshare friends file main.scanning=Scanning {0}... main.hashing=Hashing {0} main.scanning.tip=Share: {0}, file: {1} ## Status main.status.connections=Connections: main.status.nat.unknown=Status is still unknown. main.status.nat.firewalled=The client is not reachable from connections initiated from the Internet. main.status.nat.upnp=UPNP is active and the client is fully reachable from the Internet. main.status.dht.disabled=DHT is disabled. main.status.dht.initializing=DHT is currently initializing. This can take a while. main.status.dht.running=DHT is working properly, the client's IP address is advertised to its peers. main.status.dht.stats=Number of peers: {0,number,integer}\nReceived packets: {1,number,integer} ({2})\nSent packets: {3,number,integer} ({4})\nKey count: {5,number,integer}\nItem count: {6,number,integer} main.exit.confirm=Are you sure you want to exit Xeres? # Account creation account.welcome=Welcome to Xeres account.welcome.tip=You need to create a profile and a location.\n\nThe profile is yourself, you can use your name or nickname, while the location is the machine you're on.\n\nYou can have several locations like a desktop and a laptop that both use the same profile (you).\n\nUse the import option to import a profile that you already created before.\n\nEverything is always stored locally so don't forget to back up your data.\n\nPress the F1 key to read the built-in documentation, and remember that leaving your mouse pointer above a user interface element for a short while will describe what it is. account.profile.prompt=Profile name account.profile.tip=Use a nickname or real name. A profile can have several locations. account.location=Location account.location.prompt=Location name account.location.tip=This is your Xeres' instance on this device. Use your device's nickname or model. account.options=Options account.generation.profile-keys=Generating profile keys... account.generation.location-keys-and-certificate=Generating location keys and certificate... account.generation.identity=Generating identity... account.generation.profile-load=Select a Xeres profile file (xeres_backup.xml), a Retroshare keyring (retroshare_secret_keyring.gpg) or a Retroshare profile (*.asc) account.generation.import=Import... account.generation.import.tip=You can import three kinds of profiles:\n\nA profile exported from Xeres (xeres_backup.xml).\n\nA Retroshare keyring (retroshare_secret_keyring.gpg) or a profile exported from Retroshare (*.asc). account.generation.import.progress=Importing profile... account.generation.import.confirm.title=Retroshare Importer account.generation.import.confirm.prompt=Enter the Retroshare password account.generation.import.unknown=Unknown file format # Chat ## Common chat.notification.typing={0} is typing ## Room common chat.room.id=ID chat.room.topic=Topic chat.room.security=Security chat.room.users=Users chat.room.info=Topic: {0}\nUsers: {1,number,integer}\nSecurity: {2}\nID: {3} chat.room.none=[none] chat.room.private=private chat.room.public=public chat.room.signed-only=signed IDs only chat.room.anonymous-allowed=anonymous IDs allowed chat.room.user-info=Name: {0}\nID: {1} chat.room.user-menu=Information chat.room.clear-history=Do you really want to clear the history? chat.room.copy-selection=Copy selection chat.room.clear-chat-history=Clear chat history ## Room create chat.room.create.window-title=Create Chat Room chat.room.create.name.prompt=Short and descriptive name of the room chat.room.create.name.tip=Name of the room. Use proper capitalization and spaces. chat.room.create.topic.prompt=What the room is about chat.room.create.topic.tip=The description of the room, what it is about. chat.room.create.visibility=Visibility chat.room.create.visibility.tip=Public rooms are visible by peers.\nPrivate rooms aren't and work on invitation only. chat.room.create.security.checkbox=Signed identities only chat.room.create.security.tip=A room restricted to signed identities is more resistant to spam because anonymous identities cannot join. chat.room.create.tooltip=Create a new chat room ## Room invite chat.room.invite.window-title=Invite peer to the current chat room chat.room.invite.button=Invite chat.room.invite.tip=Invite peers to the current chat room chat.room.invite.request={0} wants to invite you to {1} ({2}) chat.room.join=Join chat.room.leave=Leave chat.room.not-found=Room not found. It's likely that the room is not available on any of your connected friends. # Forums gxs-group.tree.popular=Popular gxs-group.tree.other=Other gxs-group.tree.info=Name: {0}\nID: {1}\nRemote messages: {2}\nRemote activity: {3} gxs-group.tree.subscribe=Subscribe gxs-group.tree.unsubscribe=Unsubscribe forum.new-message.window-title=New message forum.create.window-title=Create forum forum.create.name.prompt=Short and descriptive name of the forum forum.create.name.tip=Name of the forum. Use proper capitalization and spaces. forum.create.description.prompt=What the forum is about forum.create.description.tip=The description of the forum, what it is about. forum.editor.name=Forum forum.editor.name.prompt=The forum's name forum.editor.thread.description=The thread's subject forum.editor.cancel=The forum message has not been sent yet! Do you really want to discard this message? forum.view.create.tip=Create a new forum forum.view.header.author=Author forum.view.header.date=Date forum.view.new-message.tip=Create a new message forum.view.group.not-found=Forum not found. It's likely that it is not available on any of your connected friends. forum.view.message.not-found=Message not found. It's likely that the message is too old or the originator has a too low reputation. forum.view.from=From: forum.view.subject=Subject: forum.view.reply=Reply forum.view.history=This selector allows displaying previous message versions. # Boards board.create.window-title=Create board board.create.name.prompt=Short and descriptive name of the board board.create.name.tip=Name of the board. Use proper capitalization and spaces. board.create.description.prompt=What the board is about board.create.description.tip=The description of the board, what it is about. board.select-logo=Select Board Picture board.select-image=Select Image to Post board.view.create.tip=Create a new board board.view.group.not-found=Board not found. It's likely that it is not available on any of your connected friends. board.new-message.window-title=New board post board.editor.name=Board board.editor.name.prompt=The board's name board.editor.thread.title=Title board.editor.post.description=The post's title board.editor.cancel=The board post has not been sent yet! Do you really want to discard this post? board.posted-by=Posted by board.on=on # Channels channel.view.create.tip=Create a new channel channel.create.window-title=Create channel channel.create.name.prompt=Short and descriptive name of the channel channel.create.name.tip=Name of the channel. Use proper capitalization and spaces. channel.create.description.prompt=What the channel is about channel.create.description.tip=The description of the channel, what it is about. channel.select-image=Select Image for the channel post channel.view.group.not-found=Channel not found. It's likely that it is not available on any of your connected friends. channel.select-logo=Select Channel Picture channel.new-message.window-title=New channel post channel.editor.name=Channel channel.editor.name.prompt=The channel's name channel.editor.thread.title=Title channel.editor.post.description=The post's title channel.editor.cancel=The channel post has not been sent yet! Do you really want to discard this post? channel.clipboard.error=Clipboard doesn't contain file links. channel.files=Files channel.post=Post channel.drag-drop=Add files or drag and drop them here channel.add-files=Add file(s) channel.paste-links=Paste link(s) channel.remove-files=Remove file(s) # Add RSID rs-id.add.window-title=Add Peer rs-id.add.textarea.prompt=Paste the ID of the peer rs-id.add.textarea.tip=The ID is a string of around a hundred of base64 characters. It encodes all information needed to connect to a peer. rs-id.add.details=Peer's details rs-id.add.name.tip=Name of the peer, make sure you know who it is. rs-id.add.profile=Profile ID rs-id.add.profile.tip=Unique ID to check if your peer's profile is the right one. rs-id.add.fingerprint=Fingerprint rs-id.add.fingerprint.tip=Cryptographic checksum to certify the authenticity of your peer's profile. rs-id.add.location=Location ID rs-id.add.location.tip=Location identifier. A profile can have several locations and each has a unique ID. rs-id.add.addresses=Addresses rs-id.add.addresses.tip=Addresses to connect to. They will all be tried in turn, but you can preselect the best one for a faster initial connection.\nAddresses ending in .onion require using a Tor proxy.\nAddresses ending in .i2p require using an I2P proxy. rs-id.add.trust.tip=The trust level you have on the peer.\nUnknown: no opinion.\nNever: none or minimal, met online recently.\nMarginal: more or less trustable, acquaintance.\nFull: very trustable, good friend. rs-id.add.invalid=Invalid ID rs-id.add.scan=Scan the QR Code using the camera. # Broadcast broadcast.window-title=Broadcast broadcast.send.explanation=Send a message to all currently connected peers. broadcast.send.warning-header=Warning: broadcast.send.warning=do not abuse this function. Only use it for emergencies or exceptional situations. # Messaging messaging.prompt=Type a message messaging.file-requester.send-picture=Select Picture to Send Inline messaging.file-requester.send-file=Select File to Send messaging.send-picture=Select an image to send inline messaging.send-sticker=Send a sticker messaging.send.file=Select a file to send messaging.action.call=Make a direct call messaging.action.send-inline=Send an image inline messaging.action.send-file=Send a file messaging.warning.title=Warning messaging.warning.description=The user is currently offline and cannot receive messages. messaging.tunneling=Trying to establish tunnel... messaging.closing-tunnel.confirm=Closing this window will end the distant chat and drop all unsent messages. Are you sure? # Profiles profiles.delete=Delete profile # About about.window-title=About {0} about.version=Version: about.title=About about.slogan=A Friend-to-Friend, decentralized and secure application for communication and sharing about.authors=Authors about.author-by=by about.all-rights-reserved=All Rights Reserved about.report-bugs=Report bugs or suggest improvements. about.website=Website about.wiki=Wiki about.source-code=Source code about.thanks=Thanks To about.license=License about.additional-licenses=Additional Licenses about.release=Release about.profiles=Profiles: # QR Code qr-code.window-title=QR Code qr-code.print=Print... qr-code.save-as-png=Save as PNG qr-code.download-client=Download client at https://xeres.io qr-code.camera.error=No camera has been detected # Camera camera.window-title=Scan QR Code # Settings ## Main settings.general=General settings.network=Network settings.transfer=Transfer settings.notifications=Notifications settings.sound=Sound settings.remote=Remote settings.directory.no-remote=Cannot choose a directory in remote mode ## General settings.general.theme=Theme settings.general.system=System settings.general.startup=Launch on system startup settings.general.startup.tip=Run automatically when the system starts, minimized in the tray. settings.general.startup.not-available=Not available. Either the OS is not supported or you're running in portable mode. settings.general.update-check=Automatically check for updates settings.general.update-check.tip=Automatically checks GitHub once per day to see if there's a new release. ## Network settings.network.hidden-services=Hidden Services settings.network.tor-proxy=Tor Socks Proxy settings.network.tor-proxy.prompt=Tor server settings.network.tor-proxy.tip=The Tor SOCKS v5 IP address or hostname, usually 127.0.0.1 if running on the same host. settings.network.tor-port.tip=The Tor SOCKS v5 port, usually 9050. settings.network.i2p-proxy=I2P Socks Proxy settings.network.i2p-proxy.prompt=I2P server settings.network.i2p-proxy.tip=The I2P SOCKS v5 IP address or hostname, usually 127.0.0.1 if running on the same host. settings.network.i2p-port.tip=The I2P SOCKS v5 port, usually 4447. settings.network.use-upnp=Use UPNP settings.network.use-upnp.tip=UPNP (Universal Plug and Play) allows automatically setting the correct incoming ports in your router. This improves the connection reliability from your peers. settings.network.external-ip-and-port=External IP and Port settings.network.external-ip-and-port.tip=The external IP address and port of your location. This is how your connection appears on the Internet. settings.network.use-broadcast-discovery=Enable Broadcast Discovery settings.network.use-broadcast-discovery.tip=Broadcast Discovery allows telling your IP and port to other locations on your LAN. This improves the connection reliability from eventual peers on your LAN. settings.network.internal-ip-and-port=Internal IP and Port settings.network.internal-ip-and-port.tip=The internal IP address and port of your location. This is how your connection appears on your LAN (Local Area Network). settings.network.use-dht=Enable DHT settings.network.use-dht.tip=The DHT (Distributed Hash Table) allows peers to find each other's IP address when it changes. This improves the connectivity when roaming. ## Remote settings.remote.title=Remote Access settings.remote.username=Username settings.remote.password=Password settings.remote.note=Setting an empty password disables authentication. settings.remote.enabled.tip=Enable remote access. This instance can then be accessed either from another Xeres instance or from the Android client. settings.remote.upnp-set=Set with UPNP settings.remote.upnp-set.tip=Set the remote port with UPNP, making it accessible from the WAN. settings.remote.restart=You need to restart Xeres in order for the remote access changes to be effective. Exit now? settings.remote.view-api=View API ## Transfer settings.transfer.select-incoming=Select Incoming Directory settings.transfer.incoming=Incoming directory ## Notifications settings.notifications.show-connections=Show connections settings.notifications.show-connections.tip=Shows when a connection with a friend is made. settings.notifications.show-broadcasts=Show broadcasts settings.notifications.show-broadcasts.tip=Shows message broadcasts sent by friends. settings.notifications.show-discovery=Show discovery settings.notifications.show-discovery.tip=Shows when a client who has Broadcast Discovery enabled appears on the LAN. ## Sound settings.sound.message=Message received settings.sound.message.tip=Plays a sound when a private message is received and the window is inactive. settings.sound.highlight=Highlight settings.sound.highlight.tip=Plays a sound when someone addresses you in a chat room. settings.sound.friend=Friend connected settings.sound.friend.tip=Plays a sound when a friend connects to you. settings.sound.download=Download complete settings.sound.download.tip=Plays a sound when a download is complete. settings.sound.ringing=Call settings.sound.ringing.tip=Plays a sound when receiving or doing a call. # Share share.window-title=Shares share.select-directory=Select directory to share share.remove=Remove share share.error.empty-name=Share name cannot be empty. Set a unique name. share.error.empty-path=Share path cannot be empty. Set a share path. share.error.not-unique=Share name already exists. Each share name has to be unique. share.list.directory=Shared directory share.list.visible-name=Visible name share.list.searchable=Searchable share.list.browsable=Browsable share.create=Create a new share share.apply=Apply and close # Tray tray.open=Open {0} tray.peers=Peers tray.status=Status # EditorView editor.hyperlink.enter=Enter URL editor.action.undo=Undo (Ctrl+Z) editor.action.redo=Redo (Ctrl+Shift+Z) editor.action.bold=Bold (Ctrl+B) editor.action.italic=Italic (Ctrl+I) editor.action.hyperlink=Link (Ctrl+L) editor.action.quote=Quote (Ctrl+Q) editor.action.code=Code (Ctrl+K) editor.action.unordered-list=Unordered list (Ctrl+U) editor.action.ordered-list=Ordered list (Ctrl+Shift+U) editor.action.header=Header (Ctrl+1) editor.action.preview=Preview the message (F12) # Search / Download / Uploads search.main.search=Search search.main.downloads=Downloads search.main.uploads=Uploads search.main.trends=Trends search.input.prompt=Enter search terms search.input.search.tip=Typing several search terms will search files that contain all of them. Use \" around the search terms for an exact match. search.searching=Searching... trends.none=No trends yet trends.list.terms=Terms trends.list.from=From trends.list.time=Time download-view.list.none=No downloads download-view.list.state=State download-view.list.progress=Progress download-view.list.total-size=Total Size download-view.show-in-folder=Show in folder download-view.open-error=Failed to open: download-view.show-error=Failed to show in explorer: download-add.window-title=Add Download download-add.bytes={0,number,integer} bytes upload-view.none=No files being uploaded file-result.column.type=Type # StatisticsTurtle statistics.window-title=Statistics statistics.elapsed-time=Elapsed time (seconds) statistics.turtle.data-in=Data In statistics.turtle.data-in.tip=The content data received (downloads) statistics.turtle.data-out=Data Out statistics.turtle.data-out.tip=The content data sent (uploads) statistics.turtle.data-forward=Data Forward statistics.turtle.data-forward.tip=The content data forwarded to other peers statistics.turtle.tunnel-in=Tunnel Reqs In statistics.turtle.tunnel-in.tip=Incoming tunnel requests statistics.turtle.tunnel-out=Tunnel Reqs Out statistics.turtle.tunnel-out.tip=Tunnel requests forwarded and own requests statistics.turtle.search-in=Search Reqs In statistics.turtle.search-in.tip=Incoming search requests statistics.turtle.search-out=Search Reqs Out statistics.turtle.search-out.tip=Search requests forwarded and our own requests statistics.turtle.bandwidth=Bandwidth statistics.turtle.speed=Speed (KB/s) statistics.turtle.tip=The chart shows the turtle router statistics. It consists of:\nSearch requests: file searches (title, size, etc...).\nTunnel requests: tunnel setups between remote peers to prepare for a file transfer.\nData requests: data that flows within tunnels.\nMost requests and data that is not destined to our own node is forwarded to other peers, within a given probability that varies given the distance. statistics.rtt.rtt=Round Trip Time statistics.rtt.time=RTT (milliseconds) statistics.rtt.tip=The RTT (Round Trip Time), is the amount of time taken for a message to be sent to a destination and for a reply to be sent back to the sender. It gives an idea of the network latency between peers.\nExpect problems if the RTT is too high (more than a few seconds). statistics.data-counter.title=Data Usage statistics.data-counter.data=Data (KB) statistics.data-counter.tip=This chart shows the amount of data coming in and going out to peers. statistics.data-counter.peers=Peers statistics.turtle=Turtle statistics.rtt=RTT statistics.data-usage=Data usage # ContactView contact-view.profile-delete.confirm=This will remove and disconnect your from profile {0}. Do you really want to? contact-view.avatar-delete.confirm=Do you really want to remove your avatar image? contact-view.location.last-connected.now=Now contact-view.location.last-connected.never=Never contact-view.information.linked-to-profile=Identity linked to profile contact-view.information.profile=Profile contact-view.information.identity=Identity contact-view.information.type=Type contact-view.information.created=Created contact-view.information.updated=Updated contact-view.information.created-unknown=unknown contact-view.information.key-information-with-length=Version: {0}\nAlgorithm: {1}\nLength: {2} bits\nSignature hash: {3} contact-view.information.key-information=Version: {0}\nAlgorithm: {1}\nSignature hash: {2} contact-view.open.identity-not-found=Identity not found contact-view.open.profile-not-found=Profile not found contact-view.information.location.id=Location ID: contact-view.information.location.version=Version: contact-view.search.prompt=Search people contact-view.search.show-all=Show all contacts contact-view.search.no-contacts=No contacts contact-view.badge.own=Own contact-view.badge.own.tip=This is yourself. contact-view.badge.partial=Partial contact-view.badge.partial.tip=A partial contact is not backed by a full profile yet. It needs to be connected to at least once, then it will be checked and, if successful, promoted to a full profile. contact-view.badge.accepted=Accepted contact-view.badge.accepted.tip=This contact is accepted for incoming connections, and outgoing connections to it are attempted as well. contact-view.badge.not-validated=Not validated yet contact-view.badge.not-validated.tip=The contact has not been validated yet. Its profile signature will be verified shortly and, if successful, will be marked as valid. If unsuccessful, it will be deleted (but might be transferred again, if so, try to inform its owner about the problem). contact-view.action.chat=Chat contact-view.action.distant-chat=Distant chat contact-view.action.connect=Attempt to connect contact-view.information.locations=Locations contact-view.column.last-connected=Last Connected contact-view.chat.start=Start direct chat contact-view.distant-chat.start=Start distant chat # ImageSelectorView image-selector-view.change-image=Change image... image-selector-view.change-image-short=Change image-selector-view.add-image=Add image... # VoIP voip.window-title=Call voip.action.message=Message voip.action.message.tip=Send a direct chat message to the user voip.action.recall=Call again voip.action.recall.tip=Call the user back voip.action.close.tip=Close the window voip.action.answer=Answer voip.action.reject=Reject voip.action.hangup=Hang up voip.action.window-quit=Are you sure you want to abort the call? voip.status.incoming=Incoming call... voip.status.calling=Calling... voip.status.ongoing=In call voip.status.ended=Call ended # Update update.latest-already=You already have the latest version. update.new-version=There''s a new version available ({0}). Download, verify and install? update.new-version-auto=There''s a new version available ({0}). update.download-failure=Couldn''t download url and/or signing url update.download-file=Downloading file... update.download.title=Xeres Updater update.download.verifying=Verifying file... update.download.install=Install update.download.install-ready=Ready to install! update.download-verification-failed=Verification failed! # Stickers stickers.instructions=Add your stickers into {0}\n\nOne directory per sticker collection, each containing PNGs or JPEGs. # ChatCommands chat-command.code=Send the text as a block of code chat-command.coin=Flip a coin chat-command.me=Send an action message in the third person chat-command.pre=Send the text as preformatted chat-command.quote=Send the text as a quote chat-command.random=Send a random number from 1 to 10 chat-command-send=Send {0} # Misc uri.malicious-link=Warning! This is a malicious link, clicking will get you to: {0} uri.unsafe-link=Warning! This link might be unsafe, clicking will get you to: {0} uri.malicious-link.confirm=Warning! This is a malicious link, it will get you to {0}. Do you really want to? uri.unsafe-link.confirm=Warning! This link might be unsafe, it will get you to {0}. Do you know what it is and do you trust it? content-image.exit=Press ESC or click to exit websocket.disconnected=WebSocket connection lost. Chat unavailable. Reconnect? # TrustConverter trust-converter.nobody=Nobody trust-converter.everybody=Everybody trust-converter.marginal=Marginal trustees trust-converter.full=Full trustees trust-converter.ultimate=Only myself # Byte units byte-unit.invalid=invalid byte-unit.bytes=bytes byte-unit.kb=KB byte-unit.mb=MB byte-unit.gb=GB byte-unit.tb=TB byte-unit.pb=PB byte-unit.eb=EB # Help help.back=Go back in the history help.forward=Go forward in the history help.home=Go to the home section # Enums (beware of the key naming which must be the same as the class!) ## Trust # suppress inspection "UnusedProperty" enum.trust.unknown=Unknown # suppress inspection "UnusedProperty" enum.trust.never=Never # suppress inspection "UnusedProperty" enum.trust.marginal=Marginal # suppress inspection "UnusedProperty" enum.trust.full=Full # suppress inspection "UnusedProperty" enum.trust.ultimate=Ultimate ## Availability # suppress inspection "UnusedProperty" enum.availability.available=Available # suppress inspection "UnusedProperty" enum.availability.busy=Busy # suppress inspection "UnusedProperty" enum.availability.away=Away # suppress inspection "UnusedProperty" enum.availability.offline=Offline ## RoomType # suppress inspection "UnusedProperty" enum.room-type.private=Private # suppress inspection "UnusedProperty" enum.room-type.public=Public ## FileType # suppress inspection "UnusedProperty" enum.file-type.any=Any # suppress inspection "UnusedProperty" enum.file-type.audio=Audio # suppress inspection "UnusedProperty" enum.file-type.archive=Archive # suppress inspection "UnusedProperty" enum.file-type.document=Document # suppress inspection "UnusedProperty" enum.file-type.picture=Picture # suppress inspection "UnusedProperty" enum.file-type.program=Program # suppress inspection "UnusedProperty" enum.file-type.video=Video # suppress inspection "UnusedProperty" enum.file-type.subtitles=Subtitle # suppress inspection "UnusedProperty" enum.file-type.collection=Collection # suppress inspection "UnusedProperty" enum.file-type.directory=Directory ## FileProgressDisplay State # suppress inspection "UnusedProperty" enum.file-progress-display.state.searching=Searching # suppress inspection "UnusedProperty" enum.file-progress-display.state.transferring=Transferring # suppress inspection "UnusedProperty" enum.file-progress-display.state.removing=Removing # suppress inspection "UnusedProperty" enum.file-progress-display.state.done=Done ## FileAttachment State # suppress inspection "UnusedProperty" enum.channel-file.state.hashing=Hashing # suppress inspection "UnusedProperty" enum.channel-file.state.done=Done ## Country # suppress inspection "UnusedProperty" enum.country.af=Afghanistan # suppress inspection "UnusedProperty" enum.country.al=Albania # suppress inspection "UnusedProperty" enum.country.dz=Algeria # suppress inspection "UnusedProperty" enum.country.as=American Samoa # suppress inspection "UnusedProperty" enum.country.ad=Andorra # suppress inspection "UnusedProperty" enum.country.ao=Angola # suppress inspection "UnusedProperty" enum.country.ai=Anguilla # suppress inspection "UnusedProperty" enum.country.aq=Antarctica # suppress inspection "UnusedProperty" enum.country.ag=Antigua and Barbuda # suppress inspection "UnusedProperty" enum.country.ar=Argentina # suppress inspection "UnusedProperty" enum.country.am=Armenia # suppress inspection "UnusedProperty" enum.country.aw=Aruba # suppress inspection "UnusedProperty" enum.country.au=Australia # suppress inspection "UnusedProperty" enum.country.at=Austria # suppress inspection "UnusedProperty" enum.country.az=Azerbaijan # suppress inspection "UnusedProperty" enum.country.ba=Bosnia and Herzegovina # suppress inspection "UnusedProperty" enum.country.bs=Bahamas # suppress inspection "UnusedProperty" enum.country.bh=Bahrain # suppress inspection "UnusedProperty" enum.country.bd=Bangladesh # suppress inspection "UnusedProperty" enum.country.bb=Barbados # suppress inspection "UnusedProperty" enum.country.by=Belarus # suppress inspection "UnusedProperty" enum.country.be=Belgium # suppress inspection "UnusedProperty" enum.country.bz=Belize # suppress inspection "UnusedProperty" enum.country.bj=Benin # suppress inspection "UnusedProperty" enum.country.bm=Bermuda # suppress inspection "UnusedProperty" enum.country.bt=Bhutan # suppress inspection "UnusedProperty" enum.country.bo=Bolivia # suppress inspection "UnusedProperty" enum.country.bw=Botswana # suppress inspection "UnusedProperty" enum.country.bv=Bouvet Island # suppress inspection "UnusedProperty" enum.country.br=Brazil # suppress inspection "UnusedProperty" enum.country.io=British Indian Ocean Territory # suppress inspection "UnusedProperty" enum.country.bn=Brunei # suppress inspection "UnusedProperty" enum.country.bg=Bulgaria # suppress inspection "UnusedProperty" enum.country.bf=Burkina Faso # suppress inspection "UnusedProperty" enum.country.bi=Burundi # suppress inspection "UnusedProperty" enum.country.kh=Cambodia # suppress inspection "UnusedProperty" enum.country.cm=Cameroon # suppress inspection "UnusedProperty" enum.country.ca=Canada # suppress inspection "UnusedProperty" enum.country.cv=Cape Verde # suppress inspection "UnusedProperty" enum.country.ky=Cayman Islands # suppress inspection "UnusedProperty" enum.country.cf=Central African Republic # suppress inspection "UnusedProperty" enum.country.td=Chad # suppress inspection "UnusedProperty" enum.country.cl=Chile # suppress inspection "UnusedProperty" enum.country.cn=China # suppress inspection "UnusedProperty" enum.country.cx=Christmas Island # suppress inspection "UnusedProperty" enum.country.cc=Cocos (Keeling) Islands # suppress inspection "UnusedProperty" enum.country.co=Colombia # suppress inspection "UnusedProperty" enum.country.km=Comoros # suppress inspection "UnusedProperty" enum.country.cg=Congo # suppress inspection "UnusedProperty" enum.country.cd=Democratic Republic of the Congo # suppress inspection "UnusedProperty" enum.country.ck=Cook Islands # suppress inspection "UnusedProperty" enum.country.cr=Costa Rica # suppress inspection "UnusedProperty" enum.country.ci=Ivory Coast # suppress inspection "UnusedProperty" enum.country.hr=Croatia # suppress inspection "UnusedProperty" enum.country.cu=Cuba # suppress inspection "UnusedProperty" enum.country.cy=Cyprus # suppress inspection "UnusedProperty" enum.country.cz=Czech Republic # suppress inspection "UnusedProperty" enum.country.dk=Denmark # suppress inspection "UnusedProperty" enum.country.dj=Djibouti # suppress inspection "UnusedProperty" enum.country.dm=Dominica # suppress inspection "UnusedProperty" enum.country.do=Dominican Republic # suppress inspection "UnusedProperty" enum.country.ec=Ecuador # suppress inspection "UnusedProperty" enum.country.eg=Egypt # suppress inspection "UnusedProperty" enum.country.sv=El Salvador # suppress inspection "UnusedProperty" enum.country.gq=Equatorial Guinea # suppress inspection "UnusedProperty" enum.country.er=Eritrea # suppress inspection "UnusedProperty" enum.country.ee=Estonia # suppress inspection "UnusedProperty" enum.country.et=Ethiopia # suppress inspection "UnusedProperty" enum.country.fk=Falkland Islands (Malvinas) # suppress inspection "UnusedProperty" enum.country.fo=Faroe Islands # suppress inspection "UnusedProperty" enum.country.fj=Fiji # suppress inspection "UnusedProperty" enum.country.fi=Finland # suppress inspection "UnusedProperty" enum.country.fr=France # suppress inspection "UnusedProperty" enum.country.gf=French Guiana # suppress inspection "UnusedProperty" enum.country.pf=French Polynesia # suppress inspection "UnusedProperty" enum.country.tf=French Southern Territories # suppress inspection "UnusedProperty" enum.country.ga=Gabon # suppress inspection "UnusedProperty" enum.country.gm=Gambia # suppress inspection "UnusedProperty" enum.country.ge=Georgia # suppress inspection "UnusedProperty" enum.country.de=Germany # suppress inspection "UnusedProperty" enum.country.gh=Ghana # suppress inspection "UnusedProperty" enum.country.gi=Gibraltar # suppress inspection "UnusedProperty" enum.country.gr=Greece # suppress inspection "UnusedProperty" enum.country.gl=Greenland # suppress inspection "UnusedProperty" enum.country.gd=Grenada # suppress inspection "UnusedProperty" enum.country.gp=Guadeloupe # suppress inspection "UnusedProperty" enum.country.gu=Guam # suppress inspection "UnusedProperty" enum.country.gt=Guatemala # suppress inspection "UnusedProperty" enum.country.gg=Guernsey # suppress inspection "UnusedProperty" enum.country.gn=Guinea # suppress inspection "UnusedProperty" enum.country.gw=Guinea-Bissau # suppress inspection "UnusedProperty" enum.country.gy=Guyana # suppress inspection "UnusedProperty" enum.country.ht=Haiti # suppress inspection "UnusedProperty" enum.country.hm=Heard Island and McDonald Islands # suppress inspection "UnusedProperty" enum.country.va=Holy See (Vatican City State) # suppress inspection "UnusedProperty" enum.country.hn=Honduras # suppress inspection "UnusedProperty" enum.country.hk=Hong Kong # suppress inspection "UnusedProperty" enum.country.hu=Hungary # suppress inspection "UnusedProperty" enum.country.is=Iceland # suppress inspection "UnusedProperty" enum.country.in=India # suppress inspection "UnusedProperty" enum.country.id=Indonesia # suppress inspection "UnusedProperty" enum.country.ir=Iran # suppress inspection "UnusedProperty" enum.country.iq=Iraq # suppress inspection "UnusedProperty" enum.country.ie=Ireland # suppress inspection "UnusedProperty" enum.country.im=Isle of Man # suppress inspection "UnusedProperty" enum.country.il=Israel # suppress inspection "UnusedProperty" enum.country.it=Italy # suppress inspection "UnusedProperty" enum.country.jm=Jamaica # suppress inspection "UnusedProperty" enum.country.jp=Japan # suppress inspection "UnusedProperty" enum.country.je=Jersey # suppress inspection "UnusedProperty" enum.country.jo=Jordan # suppress inspection "UnusedProperty" enum.country.kz=Kazakhstan # suppress inspection "UnusedProperty" enum.country.ke=Kenya # suppress inspection "UnusedProperty" enum.country.ki=Kiribati # suppress inspection "UnusedProperty" enum.country.kp=North Korea # suppress inspection "UnusedProperty" enum.country.kr=South Korea # suppress inspection "UnusedProperty" enum.country.kw=Kuwait # suppress inspection "UnusedProperty" enum.country.kg=Kyrgyzstan # suppress inspection "UnusedProperty" enum.country.la=Lao People's Democratic Republic # suppress inspection "UnusedProperty" enum.country.lv=Latvia # suppress inspection "UnusedProperty" enum.country.lb=Lebanon # suppress inspection "UnusedProperty" enum.country.ls=Lesotho # suppress inspection "UnusedProperty" enum.country.lr=Liberia # suppress inspection "UnusedProperty" enum.country.ly=Libya # suppress inspection "UnusedProperty" enum.country.li=Liechtenstein # suppress inspection "UnusedProperty" enum.country.lt=Lithuania # suppress inspection "UnusedProperty" enum.country.lu=Luxembourg # suppress inspection "UnusedProperty" enum.country.mo=Macao # suppress inspection "UnusedProperty" enum.country.mk=Macedonia # suppress inspection "UnusedProperty" enum.country.mg=Madagascar # suppress inspection "UnusedProperty" enum.country.mw=Malawi # suppress inspection "UnusedProperty" enum.country.my=Malaysia # suppress inspection "UnusedProperty" enum.country.mv=Maldives # suppress inspection "UnusedProperty" enum.country.ml=Mali # suppress inspection "UnusedProperty" enum.country.mt=Malta # suppress inspection "UnusedProperty" enum.country.mh=Marshall Islands # suppress inspection "UnusedProperty" enum.country.mq=Martinique # suppress inspection "UnusedProperty" enum.country.mr=Mauritania # suppress inspection "UnusedProperty" enum.country.mu=Mauritius # suppress inspection "UnusedProperty" enum.country.yt=Mayotte # suppress inspection "UnusedProperty" enum.country.mx=Mexico # suppress inspection "UnusedProperty" enum.country.fm=Micronesia # suppress inspection "UnusedProperty" enum.country.md=Moldova # suppress inspection "UnusedProperty" enum.country.mc=Monaco # suppress inspection "UnusedProperty" enum.country.mn=Mongolia # suppress inspection "UnusedProperty" enum.country.me=Montenegro # suppress inspection "UnusedProperty" enum.country.ms=Montserrat # suppress inspection "UnusedProperty" enum.country.ma=Morocco # suppress inspection "UnusedProperty" enum.country.mz=Mozambique # suppress inspection "UnusedProperty" enum.country.mm=Myanmar # suppress inspection "UnusedProperty" enum.country.na=Namibia # suppress inspection "UnusedProperty" enum.country.nr=Nauru # suppress inspection "UnusedProperty" enum.country.np=Nepal # suppress inspection "UnusedProperty" enum.country.nl=Netherlands # suppress inspection "UnusedProperty" enum.country.an=Netherlands Antilles # suppress inspection "UnusedProperty" enum.country.nc=New Caledonia # suppress inspection "UnusedProperty" enum.country.nz=New Zealand # suppress inspection "UnusedProperty" enum.country.ni=Nicaragua # suppress inspection "UnusedProperty" enum.country.ne=Niger # suppress inspection "UnusedProperty" enum.country.ng=Nigeria # suppress inspection "UnusedProperty" enum.country.nu=Niue # suppress inspection "UnusedProperty" enum.country.nf=Norfolk Island # suppress inspection "UnusedProperty" enum.country.mp=Northern Mariana Islands # suppress inspection "UnusedProperty" enum.country.no=Norway # suppress inspection "UnusedProperty" enum.country.om=Oman # suppress inspection "UnusedProperty" enum.country.pk=Pakistan # suppress inspection "UnusedProperty" enum.country.pw=Palau # suppress inspection "UnusedProperty" enum.country.ps=Palestine # suppress inspection "UnusedProperty" enum.country.pa=Panama # suppress inspection "UnusedProperty" enum.country.pg=Papua New Guinea # suppress inspection "UnusedProperty" enum.country.py=Paraguay # suppress inspection "UnusedProperty" enum.country.pe=Peru # suppress inspection "UnusedProperty" enum.country.ph=Philippines # suppress inspection "UnusedProperty" enum.country.pn=Pitcairn # suppress inspection "UnusedProperty" enum.country.pl=Poland # suppress inspection "UnusedProperty" enum.country.pt=Portugal # suppress inspection "UnusedProperty" enum.country.pr=Puerto Rico # suppress inspection "UnusedProperty" enum.country.qa=Qatar # suppress inspection "UnusedProperty" enum.country.re=Reunion # suppress inspection "UnusedProperty" enum.country.ro=Romania # suppress inspection "UnusedProperty" enum.country.ru=Russia # suppress inspection "UnusedProperty" enum.country.rw=Rwanda # suppress inspection "UnusedProperty" enum.country.sh=Saint Helena, Ascension and Tristan da Cunha # suppress inspection "UnusedProperty" enum.country.kn=Saint Kitts and Nevis # suppress inspection "UnusedProperty" enum.country.lc=Saint Lucia # suppress inspection "UnusedProperty" enum.country.pm=Saint Pierre and Miquelon # suppress inspection "UnusedProperty" enum.country.vc=Saint Vincent and the Grenadines # suppress inspection "UnusedProperty" enum.country.ws=Samoa # suppress inspection "UnusedProperty" enum.country.sm=San Marino # suppress inspection "UnusedProperty" enum.country.st=Sao Tome and Principe # suppress inspection "UnusedProperty" enum.country.sa=Saudi Arabia # suppress inspection "UnusedProperty" enum.country.sn=Senegal # suppress inspection "UnusedProperty" enum.country.rs=Serbia # suppress inspection "UnusedProperty" enum.country.sc=Seychelles # suppress inspection "UnusedProperty" enum.country.sl=Sierra Leone # suppress inspection "UnusedProperty" enum.country.sg=Singapore # suppress inspection "UnusedProperty" enum.country.sk=Slovakia # suppress inspection "UnusedProperty" enum.country.si=Slovenia # suppress inspection "UnusedProperty" enum.country.sb=Solomon Islands # suppress inspection "UnusedProperty" enum.country.so=Somalia # suppress inspection "UnusedProperty" enum.country.za=South Africa # suppress inspection "UnusedProperty" enum.country.gs=South Georgia and the South Sandwich Islands # suppress inspection "UnusedProperty" enum.country.ss=South Sudan # suppress inspection "UnusedProperty" enum.country.es=Spain # suppress inspection "UnusedProperty" enum.country.lk=Sri Lanka # suppress inspection "UnusedProperty" enum.country.sd=Sudan # suppress inspection "UnusedProperty" enum.country.sr=Suriname # suppress inspection "UnusedProperty" enum.country.sj=Svalbard and Jan Mayen # suppress inspection "UnusedProperty" enum.country.sz=Swaziland # suppress inspection "UnusedProperty" enum.country.se=Sweden # suppress inspection "UnusedProperty" enum.country.ch=Switzerland # suppress inspection "UnusedProperty" enum.country.sy=Syria # suppress inspection "UnusedProperty" enum.country.tw=Taiwan # suppress inspection "UnusedProperty" enum.country.tj=Tajikistan # suppress inspection "UnusedProperty" enum.country.tz=Tanzania # suppress inspection "UnusedProperty" enum.country.th=Thailand # suppress inspection "UnusedProperty" enum.country.tl=Timor-Leste # suppress inspection "UnusedProperty" enum.country.tg=Togo # suppress inspection "UnusedProperty" enum.country.tk=Tokelau # suppress inspection "UnusedProperty" enum.country.to=Tonga # suppress inspection "UnusedProperty" enum.country.tt=Trinidad and Tobago # suppress inspection "UnusedProperty" enum.country.tn=Tunisia # suppress inspection "UnusedProperty" enum.country.tr=Turkey # suppress inspection "UnusedProperty" enum.country.tm=Turkmenistan # suppress inspection "UnusedProperty" enum.country.tc=Turks and Caicos Islands # suppress inspection "UnusedProperty" enum.country.tv=Tuvalu # suppress inspection "UnusedProperty" enum.country.ug=Uganda # suppress inspection "UnusedProperty" enum.country.ua=Ukraine # suppress inspection "UnusedProperty" enum.country.ae=United Arab Emirates # suppress inspection "UnusedProperty" enum.country.gb=United Kingdom # suppress inspection "UnusedProperty" enum.country.us=United States # suppress inspection "UnusedProperty" enum.country.um=United States Minor Outlying Islands # suppress inspection "UnusedProperty" enum.country.uy=Uruguay # suppress inspection "UnusedProperty" enum.country.uz=Uzbekistan # suppress inspection "UnusedProperty" enum.country.vu=Vanuatu # suppress inspection "UnusedProperty" enum.country.ve=Venezuela # suppress inspection "UnusedProperty" enum.country.vn=Vietnam # suppress inspection "UnusedProperty" enum.country.vg=Virgin Islands, British # suppress inspection "UnusedProperty" enum.country.vi=Virgin Islands, U.S. # suppress inspection "UnusedProperty" enum.country.wf=Wallis and Futuna # suppress inspection "UnusedProperty" enum.country.eh=Western Sahara # suppress inspection "UnusedProperty" enum.country.ye=Yemen # suppress inspection "UnusedProperty" enum.country.zm=Zambia # suppress inspection "UnusedProperty" enum.country.zw=Zimbabwe # suppress inspection "UnusedProperty" enum.country.tor=Tor # suppress inspection "UnusedProperty" enum.country.i2p=I2P # suppress inspection "UnusedProperty" enum.country.lan=LAN ================================================ FILE: common/src/main/resources/i18n/messages_es.properties ================================================ # # Copyright (c) 2025-2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # Common ok=Aceptar cancel=Cancelar close=Cerrar send=Enviar create=Crear remove=Eliminar download=Descargar add=Agregar open=Abrir copy-link=Copiar dirección del enlace copy=Copiar save-as=Guardar como... paste-id=Pegar ID propio undo=Deshacer redo=Rehacer cut=Cortar paste=Pegar delete=Eliminar select-all=Seleccionar todo deselect-all=Deseleccionar todo view-fullscreen=Ver pantalla completa copy-image=Copiar imagen save-image-as=Guardar imagen como... enabled=Habilitado no-results=No se encontraron resultados skip=Omitir name=Nombre help=Ayuda settings=Configuración exit=Salir profile=Perfil subscribed=Suscrito own=Propios description=Descripción subject=Asunto hash=Hash size=Tamaño trust=Confiar unknown-lc=desconocido logo=Logotipo latest=Último update=Actualizar edit=Editar body=Texto del cuerpo (opcional) text=Texto image=Imagen link=Enlace mark-read-unread=Marcar como leído/no leído thumbnail=Miniatura posts-at-remote-nodes=Publicaciones en nodo remoto last-activity=Última actividad state=Estado ip=IP port=Puerto # File Requesters file-requester.profiles=Archivos de perfil file-requester.xml=Archivos XML file-requester.png=Archivos PNG file-requester.sounds=Archivos de sonido file-requester.select-sound-title=Seleccionar archivo de sonido file-requester.images=Archivos de imagen file-requester.save-image-title=Seleccionar dónde guardar la imagen file-requester.error=Error con el archivo {0}: {1} file-requester.add-files=Seleccionar archivo(s) para agregar # Main ## Menu main.menu.add-peer=Agregar par... main.menu.broadcast=Transmitir... main.menu.shares=Configurar compartidos main.menu.statistics=Mostrar estadísticas main.menu.tools=Herramientas main.menu.tools.import-from-rs=Importar amigos desde Retroshare... main.menu.tools.export=Exportar... main.menu.help.about=Acerca de Xeres main.menu.help.documentation=Documentación main.menu.help.report-bug=Reportar error ↗ main.menu.help.check-for-updates=Buscar actualizaciones... ↗ main.friends-import-successful=Se importaron {0} ubicaciones exitosamente. main.friends-import-errors=Se importaron {0} ubicaciones, pero {1} tuvieron errores. main.systray.peers={0,number,integer} pares conectados ## Splash splash.status.database=Cargando base de datos splash.status.network=Iniciando red ## Content main.home=Inicio main.contacts=Contactos main.chats=Chats main.forums=Foros main.files=Archivos main.boards=Boards main.channels=Canales main.home.slogan=Donde la amistad se encuentra con la libertad main.home.share-id=Este es tu ID de Xeres. Compártelo con otras personas. main.home.received-id=¿Recibiste un ID de un par? main.home.add-peer=Agregar par main.home.add-peer.tip=Agregar un amigo pegando su ID main.home.need-help=¿Necesitas ayuda? main.home.online-help=Ayuda en línea ↗ main.home.online-help.tip=Mostrar la ayuda en línea (Ctrl+F1) main.home.copy-id.tip=Copiar ID de Xeres al portapapeles main.home.qrcode.tip=Usa el código QR para transferir tu ID. Imprímelo o toma una foto con tu teléfono y luego muéstralo a una cámara web. main.select-avatar=Seleccionar imagen de avatar main.export-profile=Seleccionar dónde guardar tu perfil main.import-friends=Seleccionar el archivo de amigos de Retroshare main.scanning=Escaneando {0}... main.hashing=Calculando hash de {0} main.scanning.tip=Compartido: {0}, archivo: {1} ## Status main.status.connections=Conexiones: main.status.nat.unknown=El estado aún es desconocido. main.status.nat.firewalled=El cliente no es accesible desde conexiones iniciadas desde Internet. main.status.nat.upnp=UPNP está activo y el cliente es completamente accesible desde Internet. main.status.dht.disabled=DHT está deshabilitado. main.status.dht.initializing=DHT se está inicializando. Esto puede tomar un tiempo. main.status.dht.running=DHT está funcionando correctamente, la dirección IP del cliente se anuncia a sus pares. main.status.dht.stats=Número de pares: {0,number,integer}\nPaquetes recibidos: {1,number,integer} ({2})\nPaquetes enviados: {3,number,integer} ({4})\nRecuento de claves: {5,number,integer}\nRecuento de elementos: {6,number,integer} main.exit.confirm=¿Estás seguro de que quieres salir de Xeres? # Account creation account.welcome=Bienvenido a Xeres account.welcome.tip=Necesitas crear un perfil y una ubicación.\n\nEl perfil eres tú, puedes usar tu nombre o apodo, mientras que la ubicación es la máquina en la que estás.\n\nPuedes tener varias ubicaciones como una computadora de escritorio y una portátil que usen el mismo perfil (tú).\n\nUsa la opción de importar para importar un perfil que ya hayas creado antes.\n\nTodo siempre se almacena localmente, así que no olvides hacer una copia de seguridad de tus datos.\n\nPresiona la tecla F1 para leer la documentación incorporada, y recuerda que dejar el puntero del mouse sobre un elemento de la interfaz por un momento describirá qué es. account.profile.prompt=Nombre del perfil account.profile.tip=Usa un apodo o nombre real. Un perfil puede tener varias ubicaciones. account.location=Ubicación account.location.prompt=Nombre de la ubicación account.location.tip=Esta es tu instancia de Xeres en este dispositivo. Usa el apodo o modelo de tu dispositivo. account.options=Opciones account.generation.profile-keys=Generando claves del perfil... account.generation.location-keys-and-certificate=Generando claves de ubicación y certificado... account.generation.identity=Generando identidad... account.generation.profile-load=Selecciona un archivo de perfil de Xeres (xeres_backup.xml), un llavero de Retroshare (retroshare_secret_keyring.gpg) o un perfil de Retroshare (*.asc) account.generation.import=Importar... account.generation.import.tip=Puedes importar 3 tipos de perfiles:\n\nUn perfil exportado desde Xeres (xeres_backup.xml).\n\nUn llavero de Retroshare (retroshare_secret_keyring.gpg) o un perfil exportado desde Retroshare (*.asc). account.generation.import.progress=Importando perfil... account.generation.import.confirm.title=Importador de Retroshare account.generation.import.confirm.prompt=Ingresa la contraseña de Retroshare account.generation.import.unknown=Formato de archivo desconocido # Chat ## Common chat.notification.typing={0} está escribiendo ## Room common chat.room.id=ID chat.room.topic=Tema chat.room.security=Seguridad chat.room.users=Usuarios chat.room.info=Tema: {0}\nUsuarios: {1,number,integer}\nSeguridad: {2}\nID: {3} chat.room.none=[ninguno] chat.room.private=privado chat.room.public=público chat.room.signed-only=solo IDs firmados chat.room.anonymous-allowed=IDs anónimos permitidos chat.room.user-info=Nombre: {0}\nID: {1} chat.room.user-menu=Información chat.room.clear-history=¿Realmente quieres borrar el historial? chat.room.copy-selection=Copiar selección chat.room.clear-chat-history=Borrar historial de chat ## Room create chat.room.create.window-title=Crear sala de chat chat.room.create.name.prompt=Nombre corto y descriptivo de la sala chat.room.create.name.tip=Nombre de la sala. Usa mayúsculas y espacios apropiados. chat.room.create.topic.prompt=De qué trata la sala chat.room.create.topic.tip=La descripción de la sala, de qué trata. chat.room.create.visibility=Visibilidad chat.room.create.visibility.tip=Las salas públicas son visibles para los pares.\nLas salas privadas no lo son y funcionan solo por invitación. chat.room.create.security.checkbox=Solo identidades firmadas chat.room.create.security.tip=Una sala restringida a identidades firmadas es más resistente al spam porque las identidades anónimas no pueden unirse. chat.room.create.tooltip=Crear una nueva sala de chat ## Room invite chat.room.invite.window-title=Invitar par a la sala de chat actual chat.room.invite.button=Invitar chat.room.invite.tip=Invitar pares a la sala de chat actual chat.room.invite.request={0} quiere invitarte a {1} ({2}) chat.room.join=Unirse chat.room.leave=Salir chat.room.not-found=Sala no encontrada. Es probable que la sala no esté disponible en ninguno de tus amigos conectados. # Forums gxs-group.tree.popular=Populares gxs-group.tree.other=Otros gxs-group.tree.info=Nombre: {0}\nID: {1}\nMensajes remotos: {2}\nActividad remota: {3} gxs-group.tree.subscribe=Suscribirse gxs-group.tree.unsubscribe=Cancelar suscripción forum.new-message.window-title=Nuevo mensaje forum.create.window-title=Crear foro forum.create.name.prompt=Nombre corto y descriptivo del foro forum.create.name.tip=Nombre del foro. Usa mayúsculas y espacios adecuados. forum.create.description.prompt=De qué trata el foro forum.create.description.tip=La descripción del foro, de qué trata. forum.editor.name=Foro forum.editor.name.prompt=Nombre del foro forum.editor.thread.description=El asunto del hilo forum.editor.cancel=¡El mensaje del foro no ha sido enviado aún! ¿Realmente quieres descartar este mensaje? forum.view.create.tip=Crear un nuevo foro forum.view.header.author=Autor forum.view.header.date=Fecha forum.view.new-message.tip=Crear un nuevo mensaje forum.view.group.not-found=Foro no encontrado. Es probable que no esté disponible en ninguno de tus amigos conectados. forum.view.message.not-found=Mensaje no encontrado. Es probable que el mensaje sea demasiado antiguo o que el originador tenga una reputación demasiado baja. forum.view.from=De: forum.view.subject=Asunto: forum.view.reply=Responder forum.view.history=Este selector permite mostrar versiones anteriores de los mensajes. # Boards board.create.window-title=Crear tablón board.create.name.prompt=Nombre corto y descriptivo del tablón board.create.name.tip=Nombre del tablón. Use mayúsculas y espacios adecuadamente. board.create.description.prompt=De qué trata el tablón board.create.description.tip=La descripción del tablón, de qué trata. board.select-logo=Seleccionar imagen del tablón board.select-image=Seleccionar imagen para publicar board.view.create.tip=Crear un nuevo tablón board.view.group.not-found=Tablón no encontrado. Es probable que no esté disponible en ninguno de tus amigos conectados. board.new-message.window-title=Nueva publicación en el tablón board.editor.name=Tablón board.editor.name.prompt=El nombre del tablón board.editor.thread.title=Título board.editor.post.description=El título de la publicación board.editor.cancel=¡La publicación en el tablón aún no se ha enviado! ¿Realmente quieres descartar esta publicación? board.posted-by=Publicado por board.on=el # Channels channel.view.create.tip=Crear un nuevo canal channel.create.window-title=Crear canal channel.create.name.prompt=Nombre corto y descriptivo del canal channel.create.name.tip=Nombre del canal. Use mayúsculas y espacios adecuadamente. channel.create.description.prompt=De qué trata el canal channel.create.description.tip=La descripción del canal, de qué trata. channel.select-image=Seleccionar imagen para la publicación del canal channel.view.group.not-found=Canal no encontrado. Es probable que no esté disponible en ninguno de tus amigos conectados. channel.select-logo=Seleccionar imagen del canal channel.new-message.window-title=Nueva publicación en el canal channel.editor.name=Canal channel.editor.name.prompt=El nombre del canal channel.editor.thread.title=Título channel.editor.post.description=El título de la publicación channel.editor.cancel=¡La publicación en el canal aún no se ha enviado! ¿Realmente quieres descartar esta publicación? channel.clipboard.error=El portapapeles no contiene enlaces de archivos. channel.files=Archivos channel.post=Publicar channel.drag-drop=Agregar archivos o arrastrarlos y soltarlos aquí channel.add-files=Agregar archivo(s) channel.paste-links=Pegar enlace(s) channel.remove-files=Eliminar archivo(s) # Add RSID rs-id.add.window-title=Agregar Par rs-id.add.textarea.prompt=Pega el ID del par rs-id.add.textarea.tip=El ID es una cadena de alrededor de cien caracteres base64. Codifica toda la información necesaria para conectarse a un par. rs-id.add.details=Detalles del par rs-id.add.name.tip=Nombre del par, asegúrate de saber quién es. rs-id.add.profile=ID de perfil rs-id.add.profile.tip=ID único para verificar si el perfil de tu par es el correcto. rs-id.add.fingerprint=Huella digital rs-id.add.fingerprint.tip=Suma de verificación criptográfica para certificar la autenticidad del perfil de tu par. rs-id.add.location=ID de ubicación rs-id.add.location.tip=Identificador de ubicación. Un perfil puede tener varias ubicaciones y cada una tiene un ID único. rs-id.add.addresses=Direcciones rs-id.add.addresses.tip=Direcciones para conectarse. Todas se intentarán en turno, pero puedes preseleccionar la mejor para una conexión inicial más rápida.\nLas direcciones que terminan en .onion requieren usar un proxy Tor.\nLas direcciones que terminan en .i2p requieren usar un proxy I2P. rs-id.add.trust.tip=El nivel de confianza que tienes en el par.\nDesconocido: sin opinión.\nNunca: ninguna o mínima, conocido en línea recientemente.\nMarginal: más o menos confiable, conocido.\nCompleto: muy confiable, buen amigo. rs-id.add.invalid=ID inválido rs-id.add.scan=Escanea el código QR usando la cámara. # Broadcast broadcast.window-title=Transmitir broadcast.send.explanation=Enviar un mensaje a todos los pares actualmente conectados. broadcast.send.warning-header=Advertencia: broadcast.send.warning=no abuses de esta función. Solo úsala para emergencias o situaciones excepcionales. # Messaging messaging.prompt=Escribe un mensaje messaging.file-requester.send-picture=Seleccionar imagen para enviar en línea messaging.file-requester.send-file=Seleccionar archivo para enviar messaging.send-picture=Seleccionar una imagen para enviar en línea messaging.send-sticker=Enviar un sticker messaging.send.file=Seleccionar un archivo para enviar messaging.action.call=Hacer una llamada directa messaging.action.send-inline=Enviar una imagen en línea messaging.action.send-file=Enviar un archivo messaging.warning.title=Advertencia messaging.warning.description=El usuario está actualmente desconectado y no puede recibir mensajes. messaging.tunneling=Intentando establecer túnel... messaging.closing-tunnel.confirm=Cerrar esta ventana terminará el chat distante y descartará todos los mensajes no enviados. ¿Estás seguro? # Profiles profiles.delete=Eliminar perfil # About about.window-title=Acerca de {0} about.version=Versión: about.title=Acerca de about.slogan=Una aplicación Friend-to-Friend, descentralizada y segura para comunicación y compartir about.authors=Autores about.author-by=por about.all-rights-reserved=Todos los derechos reservados about.report-bugs=Reportar errores o sugerir mejoras. about.website=Sitio web about.wiki=Wiki about.source-code=Código fuente about.thanks=Agradecimientos a about.license=Licencia about.additional-licenses=Licencias adicionales about.release=Versión about.profiles=Perfiles: # QR Code qr-code.window-title=Código QR qr-code.print=Imprimir... qr-code.save-as-png=Guardar como PNG qr-code.download-client=Descargar cliente en https://xeres.io qr-code.camera.error=No se ha detectado ninguna cámara # Camera camera.window-title=Escanear código QR # Settings ## Main settings.general=General settings.network=Red settings.transfer=Transferencia settings.notifications=Notificaciones settings.sound=Sonido settings.remote=Remoto settings.directory.no-remote=No se puede elegir un directorio en modo remoto ## General settings.general.theme=Tema settings.general.system=Sistema settings.general.startup=Ejecutar al inicio del sistema settings.general.startup.tip=Ejecutar automáticamente cuando el sistema inicia, minimizado en la bandeja. settings.general.startup.not-available=No disponible. O el sistema operativo no es compatible o estás ejecutando en modo portátil. settings.general.update-check=Buscar actualizaciones automáticamente settings.general.update-check.tip=Verifica automáticamente GitHub una vez al día para ver si hay una nueva versión. ## Network settings.network.hidden-services=Servicios ocultos settings.network.tor-proxy=Proxy Socks de Tor settings.network.tor-proxy.prompt=Servidor Tor settings.network.tor-proxy.tip=La dirección IP o nombre de host del SOCKS v5 de Tor, generalmente 127.0.0.1 si se ejecuta en el mismo host. settings.network.tor-port.tip=El puerto SOCKS v5 de Tor, generalmente 9050. settings.network.i2p-proxy=Proxy Socks de I2P settings.network.i2p-proxy.prompt=Servidor I2P settings.network.i2p-proxy.tip=La dirección IP o nombre de host del SOCKS v5 de I2P, generalmente 127.0.0.1 si se ejecuta en el mismo host. settings.network.i2p-port.tip=El puerto SOCKS v5 de I2P, generalmente 4447. settings.network.use-upnp=Usar UPNP settings.network.use-upnp.tip=UPNP (Universal Plug and Play) permite configurar automáticamente los puertos entrantes correctos en tu router. Esto mejora la confiabilidad de la conexión desde tus pares. settings.network.external-ip-and-port=IP y puerto externos settings.network.external-ip-and-port.tip=La dirección IP externa y puerto de tu ubicación. Así es como aparece tu conexión en Internet. settings.network.use-broadcast-discovery=Habilitar descubrimiento por broadcast settings.network.use-broadcast-discovery.tip=El descubrimiento por broadcast permite informar tu IP y puerto a otras ubicaciones en tu LAN. Esto mejora la confiabilidad de la conexión desde posibles pares en tu LAN. settings.network.internal-ip-and-port=IP y puerto internos settings.network.internal-ip-and-port.tip=La dirección IP interna y puerto de tu ubicación. Así es como aparece tu conexión en tu LAN (Red de área local). settings.network.use-dht=Habilitar DHT settings.network.use-dht.tip=El DHT (Tabla hash distribuida) permite a los pares encontrar la dirección IP del otro cuando cambia. Esto mejora la conectividad cuando se está en movimiento. ## Remote settings.remote.title=Acceso remoto settings.remote.username=Nombre de usuario settings.remote.password=Contraseña settings.remote.note=Establecer una contraseña vacía deshabilita la autenticación. settings.remote.enabled.tip=Habilitar acceso remoto. Esta instancia puede entonces ser accedida desde otra instancia de Xeres o desde el cliente Android. settings.remote.upnp-set=Establecer con UPNP settings.remote.upnp-set.tip=Establecer el puerto remoto con UPNP, haciéndolo accesible desde la WAN. settings.remote.restart=¿Necesitas reiniciar Xeres para que los cambios de acceso remoto sean efectivos? ¿Salir ahora? settings.remote.view-api=Consultar el API ## Transfer settings.transfer.select-incoming=Seleccionar directorio de entrada settings.transfer.incoming=Directorio de entrada ## Notifications settings.notifications.show-connections=Mostrar conexiones settings.notifications.show-connections.tip=Muestra cuando se establece una conexión con un amigo. settings.notifications.show-broadcasts=Mostrar transmisiones settings.notifications.show-broadcasts.tip=Muestra transmisiones de mensajes enviadas por amigos. settings.notifications.show-discovery=Mostrar descubrimiento settings.notifications.show-discovery.tip=Muestra cuando un cliente que tiene habilitado el descubrimiento por broadcast aparece en la LAN. ## Sound settings.sound.message=Mensaje recibido settings.sound.message.tip=Reproduce un sonido cuando se recibe un mensaje privado y la ventana está inactiva. settings.sound.highlight=Resaltar settings.sound.highlight.tip=Reproduce un sonido cuando alguien se dirige a ti en una sala de chat. settings.sound.friend=Amigo conectado settings.sound.friend.tip=Reproduce un sonido cuando un amigo se conecta a ti. settings.sound.download=Descarga completada settings.sound.download.tip=Reproduce un sonido cuando se completa una descarga. settings.sound.ringing=Llamada settings.sound.ringing.tip=Reproduce un sonido al recibir o realizar una llamada. # Share share.window-title=Compartidos share.select-directory=Seleccionar directorio para compartir share.remove=Eliminar compartido share.error.empty-name=El nombre del compartido no puede estar vacío. Establece un nombre único. share.error.empty-path=La ruta del compartido no puede estar vacía. Establece una ruta de compartido. share.error.not-unique=El nombre del compartido ya existe. Cada nombre de compartido debe ser único. share.list.directory=Directorio compartido share.list.visible-name=Nombre visible share.list.searchable=Buscable share.list.browsable=Navegable share.create=Crear un nuevo compartido share.apply=Aplicar y cerrar # Tray tray.open=Abrir {0} tray.peers=Pares tray.status=Estado # EditorView editor.hyperlink.enter=Ingresar URL editor.action.undo=Deshacer (Ctrl+Z) editor.action.redo=Rehacer (Ctrl+Shift+Z) editor.action.bold=Negrita (Ctrl+B) editor.action.italic=Cursiva (Ctrl+I) editor.action.hyperlink=Enlace (Ctrl+L) editor.action.quote=Cita (Ctrl+Q) editor.action.code=Código (Ctrl+K) editor.action.unordered-list=Lista desordenada (Ctrl+U) editor.action.ordered-list=Lista ordenada (Ctrl+Shift+U) editor.action.header=Encabezado (Ctrl+1) editor.action.preview=Vista previa del mensaje (F12) # Search / Download / Uploads search.main.search=Buscar search.main.downloads=Descargas search.main.uploads=Subidas search.main.trends=Tendencias search.input.prompt=Ingresar términos de búsqueda search.input.search.tip=Escribir varios términos de búsqueda buscará archivos que contengan todos ellos. Usa " alrededor de los términos de búsqueda para una coincidencia exacta. search.searching=Buscando... trends.none=Aún no hay tendencias trends.list.terms=Términos trends.list.from=De trends.list.time=Tiempo download-view.list.none=No hay descargas download-view.list.state=Estado download-view.list.progress=Progreso download-view.list.total-size=Tamaño total download-view.show-in-folder=Mostrar en carpeta download-view.open-error=Error al abrir: download-view.show-error=Error al mostrar en el explorador: download-add.window-title=Agregar descarga download-add.bytes={0,number,integer} bytes upload-view.none=No se están subiendo archivos file-result.column.type=Tipo # StatisticsTurtle statistics.window-title=Estadísticas statistics.elapsed-time=Tiempo transcurrido (segundos) statistics.turtle.data-in=Datos de entrada statistics.turtle.data-in.tip=Los datos de contenido recibidos (descargas) statistics.turtle.data-out=Datos de salida statistics.turtle.data-out.tip=Los datos de contenido enviados (subidas) statistics.turtle.data-forward=Datos reenviados statistics.turtle.data-forward.tip=Los datos de contenido reenviados a otros pares statistics.turtle.tunnel-in=Solicitudes de túnel entrantes statistics.turtle.tunnel-in.tip=Solicitudes de túnel entrantes statistics.turtle.tunnel-out=Solicitudes de túnel salientes statistics.turtle.tunnel-out.tip=Solicitudes de túnel reenviadas y propias solicitudes statistics.turtle.search-in=Solicitudes de búsqueda entrantes statistics.turtle.search-in.tip=Solicitudes de búsqueda entrantes statistics.turtle.search-out=Solicitudes de búsqueda salientes statistics.turtle.search-out.tip=Solicitudes de búsqueda reenviadas y nuestras propias solicitudes statistics.turtle.bandwidth=Ancho de banda statistics.turtle.speed=Velocidad (KB/s) statistics.turtle.tip=El gráfico muestra las estadísticas del router turtle. Consiste en:\nSolicitudes de búsqueda: búsquedas de archivos (título, tamaño, etc...).\nSolicitudes de túnel: configuraciones de túnel entre pares remotos para preparar una transferencia de archivos.\nSolicitudes de datos: datos que fluyen dentro de túneles.\nLa mayoría de las solicitudes y datos que no están destinados a nuestro propio nodo se reenvían a otros pares, dentro de una probabilidad dada que varía según la distancia. statistics.rtt.rtt=Tiempo de ida y vuelta statistics.rtt.time=RTT (milisegundos) statistics.rtt.tip=El RTT (Round Trip Time), es la cantidad de tiempo que tarda un mensaje en enviarse a un destino y que se envíe una respuesta al remitente. Da una idea de la latencia de la red entre pares.\nEspera problemas si el RTT es demasiado alto (más de unos segundos). statistics.data-counter.title=Uso de datos statistics.data-counter.data=Datos (KB) statistics.data-counter.tip=Este gráfico muestra la cantidad de datos que entran y salen hacia los pares. statistics.data-counter.peers=Pares statistics.turtle=Turtle statistics.rtt=RTT statistics.data-usage=Uso de datos # ContactView contact-view.profile-delete.confirm=Esto eliminará y desconectará tu perfil {0}. ¿Realmente quieres hacerlo? contact-view.avatar-delete.confirm=¿Realmente quieres eliminar tu imagen de avatar? contact-view.location.last-connected.now=Ahora contact-view.location.last-connected.never=Nunca contact-view.information.linked-to-profile=Identidad vinculada al perfil contact-view.information.profile=Perfil contact-view.information.identity=Identidad contact-view.information.type=Tipo contact-view.information.created=Creado contact-view.information.updated=Actualizado contact-view.information.created-unknown=desconocido contact-view.information.key-information-with-length=Versión: {0}\nAlgoritmo: {1}\nLongitud: {2} bits\nHash de firma: {3} contact-view.information.key-information=Versión: {0}\nAlgoritmo: {1}\nHash de firma: {2} contact-view.open.identity-not-found=Identidad no encontrada contact-view.open.profile-not-found=Perfil no encontrada contact-view.information.location.id=ID de ubicación: contact-view.information.location.version=Versión: contact-view.search.prompt=Buscar personas contact-view.search.show-all=Mostrar todos los contactos contact-view.search.no-contacts=No hay contactos contact-view.badge.own=Propio contact-view.badge.own.tip=Este eres tú. contact-view.badge.partial=Parcial contact-view.badge.partial.tip=Un contacto parcial no está respaldado por un perfil completo aún. Necesita conectarse al menos una vez, luego se verificará y, si tiene éxito, se promoverá a un perfil completo. contact-view.badge.accepted=Aceptado contact-view.badge.accepted.tip=Este contacto es aceptado para conexiones entrantes y también se intentan conexiones salientes hacia él. contact-view.badge.not-validated=Aún no validado contact-view.badge.not-validated.tip=El contacto no ha sido validado aún. Su firma de perfil se verificará shortly y, si tiene éxito, se marcará como válido. Si no tiene éxito, se eliminará (pero podría transferirse nuevamente, si es así, intenta informar a su propietario sobre el problema). contact-view.action.chat=Chat contact-view.action.distant-chat=Chat distante contact-view.action.connect=Intentar conectar contact-view.information.locations=Ubicaciones contact-view.column.last-connected=Última conexión contact-view.chat.start=Iniciar chat directo contact-view.distant-chat.start=Iniciar chat distante # ImageSelectorView image-selector-view.change-image=Cambiar imagen... image-selector-view.change-image-short=Cambiar image-selector-view.add-image=Agregar imagen... # VoIP voip.window-title=Llamada voip.action.message=Mensaje voip.action.message.tip=Enviar un mensaje de chat directo al usuario voip.action.recall=Llamar nuevamente voip.action.recall.tip=Llamar al usuario de nuevo voip.action.close.tip=Cerrar la ventana voip.action.answer=Contestar voip.action.reject=Rechazar voip.action.hangup=Colgar voip.action.window-quit=¿Estás seguro de que quieres abortar la llamada? voip.status.incoming=Llamada entrante... voip.status.calling=Llamando... voip.status.ongoing=En llamada voip.status.ended=Llamada terminada # Update update.latest-already=Ya tienes la última versión. update.new-version=Hay una nueva versión disponible ({0}). ¿Descargar, verificar e instalar? update.new-version-auto=Hay una nueva versión disponible ({0}). update.download-failure=No se pudo descargar la URL y/o la URL de firma update.download-file=Descargando archivo... update.download.title=Actualizador de Xeres update.download.verifying=Verificando archivo... update.download.install=Instalar update.download.install-ready=¡Listo para instalar! update.download-verification-failed=¡La verificación falló! # Stickers stickers.instructions=Agrega tus stickers en {0}\n\nUn directorio por colección de stickers, cada uno conteniendo PNGs o JPEGs. # ChatCommands chat-command.code=Enviar el texto como un bloque de código chat-command.coin=Lanzar una moneda chat-command.me=Enviar un mensaje de acción en tercera persona chat-command.pre=Enviar el texto como preformateado chat-command.quote=Enviar el texto como una cita chat-command.random=Enviar un número aleatorio del 1 al 10 chat-command-send=Enviar {0} # Misc uri.malicious-link=¡Advertencia! Este es un enlace malicioso, hacer clic te llevará a: {0} uri.unsafe-link=¡Advertencia! Este enlace podría no ser seguro, al hacer clic irás a: {0} uri.malicious-link.confirm=¡Advertencia! Este es un enlace malicioso, te llevará a {0}. ¿Realmente quieres? uri.unsafe-link.confirm=¡Advertencia! Este enlace podría no ser seguro, te llevará a {0}. ¿Sabes lo que es y confías en él? content-image.exit=Presiona ESC o haz clic para salir websocket.disconnected=Conexión WebSocket perdida. Chat no disponible. ¿Reconectar? # TrustConverter trust-converter.nobody=Nadie trust-converter.everybody=Todos trust-converter.marginal=Fideicomisarios marginales trust-converter.full=Fideicomisarios completos trust-converter.ultimate=Solo yo # Byte units byte-unit.invalid=inválido byte-unit.bytes=bytes byte-unit.kb=KB byte-unit.mb=MB byte-unit.gb=GB byte-unit.tb=TB byte-unit.pb=PB byte-unit.eb=EB # Help help.back=Retroceder en el historial help.forward=Avanzar en el historial help.home=Ir a la sección de inicio # Enums (beware of the key naming which must be the same as the class!) ## Trust # suppress inspection "UnusedProperty" enum.trust.unknown=Desconocido # suppress inspection "UnusedProperty" enum.trust.never=Nunca # suppress inspection "UnusedProperty" enum.trust.marginal=Marginal # suppress inspection "UnusedProperty" enum.trust.full=Completo # suppress inspection "UnusedProperty" enum.trust.ultimate=Máximo ## Availability # suppress inspection "UnusedProperty" enum.availability.available=Disponible # suppress inspection "UnusedProperty" enum.availability.busy=Ocupado # suppress inspection "UnusedProperty" enum.availability.away=Ausente # suppress inspection "UnusedProperty" enum.availability.offline=Desconectado ## RoomType # suppress inspection "UnusedProperty" enum.room-type.private=Privado # suppress inspection "UnusedProperty" enum.room-type.public=Público ## FileType # suppress inspection "UnusedProperty" enum.file-type.any=Cualquiera # suppress inspection "UnusedProperty" enum.file-type.audio=Audio # suppress inspection "UnusedProperty" enum.file-type.archive=Archivo # suppress inspection "UnusedProperty" enum.file-type.document=Documento # suppress inspection "UnusedProperty" enum.file-type.picture=Imagen # suppress inspection "UnusedProperty" enum.file-type.program=Programa # suppress inspection "UnusedProperty" enum.file-type.video=Video # suppress inspection "UnusedProperty" enum.file-type.subtitles=Subtítulo # suppress inspection "UnusedProperty" enum.file-type.collection=Colección # suppress inspection "UnusedProperty" enum.file-type.directory=Directorio ## FileProgressDisplay State # suppress inspection "UnusedProperty" enum.file-progress-display.state.searching=Buscando # suppress inspection "UnusedProperty" enum.file-progress-display.state.transferring=Transfiriendo # suppress inspection "UnusedProperty" enum.file-progress-display.state.removing=Eliminando # suppress inspection "UnusedProperty" enum.file-progress-display.state.done=Completado ## FileAttachment State # suppress inspection "UnusedProperty" enum.channel-file.state.hashing=Calculando Hash # suppress inspection "UnusedProperty" enum.channel-file.state.done=Completado ## Country # suppress inspection "UnusedProperty" enum.country.af=Afganistán # suppress inspection "UnusedProperty" enum.country.al=Albania # suppress inspection "UnusedProperty" enum.country.dz=Argelia # suppress inspection "UnusedProperty" enum.country.as=Samoa Americana # suppress inspection "UnusedProperty" enum.country.ad=Andorra # suppress inspection "UnusedProperty" enum.country.ao=Angola # suppress inspection "UnusedProperty" enum.country.ai=Anguila # suppress inspection "UnusedProperty" enum.country.aq=Antártida # suppress inspection "UnusedProperty" enum.country.ag=Antigua y Barbuda # suppress inspection "UnusedProperty" enum.country.ar=Argentina # suppress inspection "UnusedProperty" enum.country.am=Armenia # suppress inspection "UnusedProperty" enum.country.aw=Aruba # suppress inspection "UnusedProperty" enum.country.au=Australia # suppress inspection "UnusedProperty" enum.country.at=Austria # suppress inspection "UnusedProperty" enum.country.az=Azerbaiyán # suppress inspection "UnusedProperty" enum.country.ba=Bosnia y Herzegovina # suppress inspection "UnusedProperty" enum.country.bs=Bahamas # suppress inspection "UnusedProperty" enum.country.bh=Bahréin # suppress inspection "UnusedProperty" enum.country.bd=Bangladesh # suppress inspection "UnusedProperty" enum.country.bb=Barbados # suppress inspection "UnusedProperty" enum.country.by=Bielorrusia # suppress inspection "UnusedProperty" enum.country.be=Bélgica # suppress inspection "UnusedProperty" enum.country.bz=Belice # suppress inspection "UnusedProperty" enum.country.bj=Benín # suppress inspection "UnusedProperty" enum.country.bm=Bermudas # suppress inspection "UnusedProperty" enum.country.bt=Bután # suppress inspection "UnusedProperty" enum.country.bo=Bolivia # suppress inspection "UnusedProperty" enum.country.bw=Botsuana # suppress inspection "UnusedProperty" enum.country.bv=Isla Bouvet # suppress inspection "UnusedProperty" enum.country.br=Brasil # suppress inspection "UnusedProperty" enum.country.io=Territorio Británico del Océano Índico # suppress inspection "UnusedProperty" enum.country.bn=Brunei # suppress inspection "UnusedProperty" enum.country.bg=Bulgaria # suppress inspection "UnusedProperty" enum.country.bf=Burkina Faso # suppress inspection "UnusedProperty" enum.country.bi=Burundi # suppress inspection "UnusedProperty" enum.country.kh=Camboya # suppress inspection "UnusedProperty" enum.country.cm=Camerún # suppress inspection "UnusedProperty" enum.country.ca=Canadá # suppress inspection "UnusedProperty" enum.country.cv=Cabo Verde # suppress inspection "UnusedProperty" enum.country.ky=Islas Caimán # suppress inspection "UnusedProperty" enum.country.cf=República Centroafricana # suppress inspection "UnusedProperty" enum.country.td=Chad # suppress inspection "UnusedProperty" enum.country.cl=Chile # suppress inspection "UnusedProperty" enum.country.cn=China # suppress inspection "UnusedProperty" enum.country.cx=Isla de Navidad # suppress inspection "UnusedProperty" enum.country.cc=Islas Cocos (Keeling) # suppress inspection "UnusedProperty" enum.country.co=Colombia # suppress inspection "UnusedProperty" enum.country.km=Comoras # suppress inspection "UnusedProperty" enum.country.cg=Congo # suppress inspection "UnusedProperty" enum.country.cd=República Democrática del Congo # suppress inspection "UnusedProperty" enum.country.ck=Islas Cook # suppress inspection "UnusedProperty" enum.country.cr=Costa Rica # suppress inspection "UnusedProperty" enum.country.ci=Costa de Marfil # suppress inspection "UnusedProperty" enum.country.hr=Croacia # suppress inspection "UnusedProperty" enum.country.cu=Cuba # suppress inspection "UnusedProperty" enum.country.cy=Chipre # suppress inspection "UnusedProperty" enum.country.cz=República Checa # suppress inspection "UnusedProperty" enum.country.dk=Dinamarca # suppress inspection "UnusedProperty" enum.country.dj=Yibuti # suppress inspection "UnusedProperty" enum.country.dm=Dominica # suppress inspection "UnusedProperty" enum.country.do=República Dominicana # suppress inspection "UnusedProperty" enum.country.ec=Ecuador # suppress inspection "UnusedProperty" enum.country.eg=Egipto # suppress inspection "UnusedProperty" enum.country.sv=El Salvador # suppress inspection "UnusedProperty" enum.country.gq=Guinea Ecuatorial # suppress inspection "UnusedProperty" enum.country.er=Eritrea # suppress inspection "UnusedProperty" enum.country.ee=Estonia # suppress inspection "UnusedProperty" enum.country.et=Etiopía # suppress inspection "UnusedProperty" enum.country.fk=Islas Malvinas (Falkland) # suppress inspection "UnusedProperty" enum.country.fo=Islas Feroe # suppress inspection "UnusedProperty" enum.country.fj=Fiyi # suppress inspection "UnusedProperty" enum.country.fi=Finlandia # suppress inspection "UnusedProperty" enum.country.fr=Francia # suppress inspection "UnusedProperty" enum.country.gf=Guayana Francesa # suppress inspection "UnusedProperty" enum.country.pf=Polinesia Francesa # suppress inspection "UnusedProperty" enum.country.tf=Territorios Australes Franceses # suppress inspection "UnusedProperty" enum.country.ga=Gabón # suppress inspection "UnusedProperty" enum.country.gm=Gambia # suppress inspection "UnusedProperty" enum.country.ge=Georgia # suppress inspection "UnusedProperty" enum.country.de=Alemania # suppress inspection "UnusedProperty" enum.country.gh=Ghana # suppress inspection "UnusedProperty" enum.country.gi=Gibraltar # suppress inspection "UnusedProperty" enum.country.gr=Grecia # suppress inspection "UnusedProperty" enum.country.gl=Groenlandia # suppress inspection "UnusedProperty" enum.country.gd=Granada # suppress inspection "UnusedProperty" enum.country.gp=Guadalupe # suppress inspection "UnusedProperty" enum.country.gu=Guam # suppress inspection "UnusedProperty" enum.country.gt=Guatemala # suppress inspection "UnusedProperty" enum.country.gg=Guernsey # suppress inspection "UnusedProperty" enum.country.gn=Guinea # suppress inspection "UnusedProperty" enum.country.gw=Guinea-Bisáu # suppress inspection "UnusedProperty" enum.country.gy=Guyana # suppress inspection "UnusedProperty" enum.country.ht=Haití # suppress inspection "UnusedProperty" enum.country.hm=Islas Heard y McDonald # suppress inspection "UnusedProperty" enum.country.va=Santa Sede (Ciudad del Vaticano) # suppress inspection "UnusedProperty" enum.country.hn=Honduras # suppress inspection "UnusedProperty" enum.country.hk=Hong Kong # suppress inspection "UnusedProperty" enum.country.hu=Hungría # suppress inspection "UnusedProperty" enum.country.is=Islandia # suppress inspection "UnusedProperty" enum.country.in=India # suppress inspection "UnusedProperty" enum.country.id=Indonesia # suppress inspection "UnusedProperty" enum.country.ir=Irán # suppress inspection "UnusedProperty" enum.country.iq=Irak # suppress inspection "UnusedProperty" enum.country.ie=Irlanda # suppress inspection "UnusedProperty" enum.country.im=Isla de Man # suppress inspection "UnusedProperty" enum.country.il=Israel # suppress inspection "UnusedProperty" enum.country.it=Italia # suppress inspection "UnusedProperty" enum.country.jm=Jamaica # suppress inspection "UnusedProperty" enum.country.jp=Japón # suppress inspection "UnusedProperty" enum.country.je=Jersey # suppress inspection "UnusedProperty" enum.country.jo=Jordania # suppress inspection "UnusedProperty" enum.country.kz=Kazajistán # suppress inspection "UnusedProperty" enum.country.ke=Kenia # suppress inspection "UnusedProperty" enum.country.ki=Kiribati # suppress inspection "UnusedProperty" enum.country.kp=Corea del Norte # suppress inspection "UnusedProperty" enum.country.kr=Corea del Sur # suppress inspection "UnusedProperty" enum.country.kw=Kuwait # suppress inspection "UnusedProperty" enum.country.kg=Kirguistán # suppress inspection "UnusedProperty" enum.country.la=Laos # suppress inspection "UnusedProperty" enum.country.lv=Letonia # suppress inspection "UnusedProperty" enum.country.lb=Líbano # suppress inspection "UnusedProperty" enum.country.ls=Lesoto # suppress inspection "UnusedProperty" enum.country.lr=Liberia # suppress inspection "UnusedProperty" enum.country.ly=Libia # suppress inspection "UnusedProperty" enum.country.li=Liechtenstein # suppress inspection "UnusedProperty" enum.country.lt=Lituania # suppress inspection "UnusedProperty" enum.country.lu=Luxemburgo # suppress inspection "UnusedProperty" enum.country.mo=Macao # suppress inspection "UnusedProperty" enum.country.mk=Macedonia del Norte # suppress inspection "UnusedProperty" enum.country.mg=Madagascar # suppress inspection "UnusedProperty" enum.country.mw=Malaui # suppress inspection "UnusedProperty" enum.country.my=Malasia # suppress inspection "UnusedProperty" enum.country.mv=Maldivas # suppress inspection "UnusedProperty" enum.country.ml=Mali # suppress inspection "UnusedProperty" enum.country.mt=Malta # suppress inspection "UnusedProperty" enum.country.mh=Islas Marshall # suppress inspection "UnusedProperty" enum.country.mq=Martinica # suppress inspection "UnusedProperty" enum.country.mr=Mauritania # suppress inspection "UnusedProperty" enum.country.mu=Mauricio # suppress inspection "UnusedProperty" enum.country.yt=Mayotte # suppress inspection "UnusedProperty" enum.country.mx=México # suppress inspection "UnusedProperty" enum.country.fm=Micronesia # suppress inspection "UnusedProperty" enum.country.md=Moldavia # suppress inspection "UnusedProperty" enum.country.mc=Mónaco # suppress inspection "UnusedProperty" enum.country.mn=Mongolia # suppress inspection "UnusedProperty" enum.country.me=Montenegro # suppress inspection "UnusedProperty" enum.country.ms=Montserrat # suppress inspection "UnusedProperty" enum.country.ma=Marruecos # suppress inspection "UnusedProperty" enum.country.mz=Mozambique # suppress inspection "UnusedProperty" enum.country.mm=Myanmar (Birmania) # suppress inspection "UnusedProperty" enum.country.na=Namibia # suppress inspection "UnusedProperty" enum.country.nr=Nauru # suppress inspection "UnusedProperty" enum.country.np=Nepal # suppress inspection "UnusedProperty" enum.country.nl=Países Bajos # suppress inspection "UnusedProperty" enum.country.an=Antillas Neerlandesas # suppress inspection "UnusedProperty" enum.country.nc=Nueva Caledonia # suppress inspection "UnusedProperty" enum.country.nz=Nueva Zelanda # suppress inspection "UnusedProperty" enum.country.ni=Nicaragua # suppress inspection "UnusedProperty" enum.country.ne=Níger # suppress inspection "UnusedProperty" enum.country.ng=Nigeria # suppress inspection "UnusedProperty" enum.country.nu=Niue # suppress inspection "UnusedProperty" enum.country.nf=Isla Norfolk # suppress inspection "UnusedProperty" enum.country.mp=Islas Marianas del Norte # suppress inspection "UnusedProperty" enum.country.no=Noruega # suppress inspection "UnusedProperty" enum.country.om=Omán # suppress inspection "UnusedProperty" enum.country.pk=Pakistán # suppress inspection "UnusedProperty" enum.country.pw=Palaos # suppress inspection "UnusedProperty" enum.country.ps=Palestina # suppress inspection "UnusedProperty" enum.country.pa=Panamá # suppress inspection "UnusedProperty" enum.country.pg=Papúa Nueva Guinea # suppress inspection "UnusedProperty" enum.country.py=Paraguay # suppress inspection "UnusedProperty" enum.country.pe=Perú # suppress inspection "UnusedProperty" enum.country.ph=Filipinas # suppress inspection "UnusedProperty" enum.country.pn=Islas Pitcairn # suppress inspection "UnusedProperty" enum.country.pl=Polonia # suppress inspection "UnusedProperty" enum.country.pt=Portugal # suppress inspection "UnusedProperty" enum.country.pr=Puerto Rico # suppress inspection "UnusedProperty" enum.country.qa=Catar # suppress inspection "UnusedProperty" enum.country.re=Reunión # suppress inspection "UnusedProperty" enum.country.ro=Rumanía # suppress inspection "UnusedProperty" enum.country.ru=Rusia # suppress inspection "UnusedProperty" enum.country.rw=Ruanda # suppress inspection "UnusedProperty" enum.country.sh=Santa Elena, Ascensión y Tristán de Acuña # suppress inspection "UnusedProperty" enum.country.kn=San Cristóbal y Nieves # suppress inspection "UnusedProperty" enum.country.lc=Santa Lucía # suppress inspection "UnusedProperty" enum.country.pm=San Pedro y Miquelón # suppress inspection "UnusedProperty" enum.country.vc=San Vicente y las Granadinas # suppress inspection "UnusedProperty" enum.country.ws=Samoa # suppress inspection "UnusedProperty" enum.country.sm=San Marino # suppress inspection "UnusedProperty" enum.country.st=Santo Tomé y Príncipe # suppress inspection "UnusedProperty" enum.country.sa=Arabia Saudita # suppress inspection "UnusedProperty" enum.country.sn=Senegal # suppress inspection "UnusedProperty" enum.country.rs=Serbia # suppress inspection "UnusedProperty" enum.country.sc=Seychelles # suppress inspection "UnusedProperty" enum.country.sl=Sierra Leona # suppress inspection "UnusedProperty" enum.country.sg=Singapur # suppress inspection "UnusedProperty" enum.country.sk=Eslovaquia # suppress inspection "UnusedProperty" enum.country.si=Eslovenia # suppress inspection "UnusedProperty" enum.country.sb=Islas Salomón # suppress inspection "UnusedProperty" enum.country.so=Somalia # suppress inspection "UnusedProperty" enum.country.za=Sudáfrica # suppress inspection "UnusedProperty" enum.country.gs=Islas Georgias del Sur y Sandwich del Sur # suppress inspection "UnusedProperty" enum.country.ss=Sudán del Sur # suppress inspection "UnusedProperty" enum.country.es=España # suppress inspection "UnusedProperty" enum.country.lk=Sri Lanka # suppress inspection "UnusedProperty" enum.country.sd=Sudán # suppress inspection "UnusedProperty" enum.country.sr=Surinam # suppress inspection "UnusedProperty" enum.country.sj=Svalbard y Jan Mayen # suppress inspection "UnusedProperty" enum.country.sz=Esuatini # suppress inspection "UnusedProperty" enum.country.se=Suecia # suppress inspection "UnusedProperty" enum.country.ch=Suiza # suppress inspection "UnusedProperty" enum.country.sy=Siria # suppress inspection "UnusedProperty" enum.country.tw=Taiwán # suppress inspection "UnusedProperty" enum.country.tj=Tayikistán # suppress inspection "UnusedProperty" enum.country.tz=Tanzania # suppress inspection "UnusedProperty" enum.country.th=Tailandia # suppress inspection "UnusedProperty" enum.country.tl=Timor-Leste # suppress inspection "UnusedProperty" enum.country.tg=Togo # suppress inspection "UnusedProperty" enum.country.tk=Tokelau # suppress inspection "UnusedProperty" enum.country.to=Tonga # suppress inspection "UnusedProperty" enum.country.tt=Trinidad y Tobago # suppress inspection "UnusedProperty" enum.country.tn=Túnez # suppress inspection "UnusedProperty" enum.country.tr=Turquía # suppress inspection "UnusedProperty" enum.country.tm=Turkmenistán # suppress inspection "UnusedProperty" enum.country.tc=Islas Turcas y Caicos # suppress inspection "UnusedProperty" enum.country.tv=Tuvalu # suppress inspection "UnusedProperty" enum.country.ug=Uganda # suppress inspection "UnusedProperty" enum.country.ua=Ucrania # suppress inspection "UnusedProperty" enum.country.ae=Emiratos Árabes Unidos # suppress inspection "UnusedProperty" enum.country.gb=Reino Unido # suppress inspection "UnusedProperty" enum.country.us=Estados Unidos # suppress inspection "UnusedProperty" enum.country.um=Islas Ultramarinas Menores de Estados Unidos # suppress inspection "UnusedProperty" enum.country.uy=Uruguay # suppress inspection "UnusedProperty" enum.country.uz=Uzbekistán # suppress inspection "UnusedProperty" enum.country.vu=Vanuatu # suppress inspection "UnusedProperty" enum.country.ve=Venezuela # suppress inspection "UnusedProperty" enum.country.vn=Vietnam # suppress inspection "UnusedProperty" enum.country.vg=Islas Vírgenes Británicas # suppress inspection "UnusedProperty" enum.country.vi=Islas Vírgenes de los Estados Unidos # suppress inspection "UnusedProperty" enum.country.wf=Wallis y Futuna # suppress inspection "UnusedProperty" enum.country.eh=Sáhara Occidental # suppress inspection "UnusedProperty" enum.country.ye=Yemen # suppress inspection "UnusedProperty" enum.country.zm=Zambia # suppress inspection "UnusedProperty" enum.country.zw=Zimbabue # suppress inspection "UnusedProperty" enum.country.tor=Tor # suppress inspection "UnusedProperty" enum.country.i2p=I2P # suppress inspection "UnusedProperty" enum.country.lan=LAN ================================================ FILE: common/src/main/resources/i18n/messages_fr.properties ================================================ # # Copyright (c) 2019-2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # Common ok=OK cancel=Annuler close=Fermer send=Envoyer create=Créer remove=Enlever download=Télécharger add=Ajouter open=Ouvrir copy-link=Copier adresse du lien copy=Copier save-as=Sauver comme... paste-id=Coller son identifiant undo=Défaire redo=Refaire cut=Couper paste=Coller delete=Effacer select-all=Tout sélectionner deselect-all=Désélectionner tout view-fullscreen=Voir en plein écran copy-image=Copier image save-image-as=Sauver image sous... enabled=Activé no-results=Aucun résultat trouvé skip=Passer name=Nom help=Aide settings=Paramètres exit=Quitter profile=Profil subscribed=Inscrit own=A soi description=Description subject=Sujet hash=Hash size=Taille trust=Confiance unknown-lc=Inconnu logo=Logo latest=Dernier update=Mettre à jour edit=Editer body=Corps du texte (optionnel) text=Texte image=Image link=Lien mark-read-unread=Marquer comme lu/non-lu thumbnail=Vignette posts-at-remote-nodes=Posts sur noeuds distants last-activity=Dernière activité state=Etat ip=IP port=Port # File Requesters file-requester.profiles=Fichiers de profiles file-requester.xml=Fichiers XML file-requester.png=Fichiers PNG file-requester.sounds=Fichiers sonore file-requester.select-sound-title=Choisissez fichier audio file-requester.images=Fichiers images file-requester.save-image-title=Sélectionnez où sauver votre image file-requester.error=Erreur avec le fichier {0}: {1} file-requester.add-files=Sélectionner fichier(s) à ajouter # Main ## Menu main.menu.add-peer=Ajouter pair... main.menu.broadcast=Diffusion simultanée... main.menu.shares=Configurer les partages main.menu.statistics=Montrer les statistiques main.menu.tools=Plus d'outils main.menu.tools.import-from-rs=Importer des amis de Retroshare... main.menu.tools.export=Exporter... main.menu.help.about=A propos de Xeres main.menu.help.documentation=Documentation main.menu.help.report-bug=Signaler un bug ↗ main.menu.help.check-for-updates=Vérifier mises à jour... ↗ main.friends-import-successful={0} localisations ont été importées avec succès. main.friends-import-errors={0} localisations ont été importées, mais {1} ont eu des erreurs. main.systray.peers={0,number,integer} pairs connectés ## Splash splash.status.database=Chargement base de données splash.status.network=Démarrage réseau ## Content main.home=Maison main.contacts=Contacts main.chats=Conversations main.forums=Forums main.files=Fichiers main.boards=Boards main.channels=Chaînes main.home.slogan=Là où l'amitié rencontre la liberté main.home.share-id=Ceci est votre ID Xeres. Partagez-la avec d'autres personnes. main.home.received-id=Reçu l'ID d'un pair? main.home.add-peer=Ajouter un pair main.home.add-peer.tip=Ajouter un ami en utilisant son ID main.home.need-help=Besoin d'aide? main.home.online-help=Aide en ligne ↗ main.home.online-help.tip=Montrer l'aide en ligne (Ctrl+F1) main.home.copy-id.tip=Copier l'ID Xeres dans le presse-papier main.home.qrcode.tip=Utilisez le QR code pour transférer votre ID. Imprimez-le ou prenez-le en photo avec votre smartphone puis montrez-le à une webcam. main.select-avatar=Choisissez une image d'avatar main.export-profile=Sélectionnez où le profile doit être exporté main.import-friends=Sélectionnez le fichier amis de Retroshare main.scanning=Scan de {0}... main.hashing=Hachage de {0} main.scanning.tip=Partage: {0}, fichier: {1} ## Status main.status.connections=Connexions: main.status.nat.unknown=Statut encore inconnu. main.status.nat.firewalled=Le client n'est pas joignable par les connexions entrantes venant d'Internet. main.status.nat.upnp=UPNP activé et client pleinement atteignable depuis Internet. main.status.dht.disabled=DHT est désactivé. main.status.dht.initializing=DHT est en cours d'initialisation. Ceci peut prendre un certain temps. main.status.dht.running=DHT fonctionne correctement, l'adresse IP du client est distribué à ses pairs. main.status.dht.stats=Nombre de pairs: {0,number,integer}\nPaquets reçus: {1,number,integer} ({2})\nPaquets envoyés: {3,number,integer} ({4})\nNombre de clefs: {5,number,integer}\nNombre de valeurs: {6,number,integer} main.exit.confirm=Etes-vous sûr de vouloir quitter Xeres? # Account creation account.welcome=Bienvenue dans Xeres account.welcome.tip=Vous devez créer un profile et une localisation.\n\nLe profile représente vous-même, vous pouvez utiliser votre nom ou surnom, quant à la localisation, il s'agit de la machine que vous utilisez actuellement.\n\nVous pouvez disposer de plusieurs localisations comme une machine desktop et un laptop qui utilisent le même profile (vous).\n\nL'option d'importation permet d'utiliser un profile crée précédemment.\n\nTout est toujours stocké en local donc n'oubliez pas de faire des sauvegardes.\n\nAppuyez sur la touche F1 pour lire la documentation intégrée, et souvenez-vous qu'en tout temps, vous pouvez laisser votre pointeur de souris pendant un instant sur un objet pour avoir droit à une aide contextuelle. account.profile.prompt=Nom du profil account.profile.tip=Utilisez un surnom ou votre vrai nom. Un profil peut avoir plusieurs localisations. account.location=Localisation account.location.prompt=Nom de localisation account.location.tip=Ceci est votre instance Xeres sur cet appareil. Utilisez le nom de votre appareil ou le model. account.options=Options account.generation.profile-keys=Génération des clefs de profile... account.generation.location-keys-and-certificate=Génération des clefs de localisation et du certificat... account.generation.identity=Génération de l'identité... account.generation.profile-load=Sélectionner un fichier de profile Xeres (xeres_backup.xml), un fichier Keyring de Retroshare (retroshare_secret_keyring.gpg) ou un profile Retroshare (*.asc) account.generation.import=Importer... account.generation.import.tip=Vous pouvez importer 3 types de profiles:\n\nUn profile exporté depuis Xeres (xeres_backup.xml).\n\nUn keyring Retroshare (retroshare_secret_keyring.gpg) ou un profile exporté depuis Retroshare (*.asc). account.generation.import.progress=Importation du profile... account.generation.import.confirm.title=Importeur Retroshare account.generation.import.confirm.prompt=Entrez le mot de passe de Retroshare account.generation.import.unknown=Format de fichier inconnu # Chat ## Common chat.notification.typing={0} est en train d''écrire ## Room common chat.room.id=Identifiant chat.room.topic=Topic chat.room.security=Securité chat.room.users=Utilisateurs chat.room.info=Sujet: {0}\nUtilisateurs: {1,number,integer}\nSécurité: {2}\nIdentifiant: {3} chat.room.none=[aucun] chat.room.private=privé chat.room.public=public chat.room.signed-only=identifiants signés uniquement chat.room.anonymous-allowed=identifiants anonymes autorisés chat.room.user-info=Nom: {0}\nIdentifiant: {1} chat.room.user-menu=Information chat.room.clear-history=Voulez-vous vraiment effacer l'historique? chat.room.copy-selection=Copier la sélection chat.room.clear-chat-history=Effacer l'historique de chat ## Room create chat.room.create.window-title=Création de salon chat.room.create.name.prompt=Nom court et descriptif du salon chat.room.create.name.tip=Nom de la salle. Utilisez les majuscules et les espaces appropriés. chat.room.create.topic.prompt=But du salon chat.room.create.topic.tip=La description du salon, de quoi on y parle. chat.room.create.visibility=Visibilité chat.room.create.visibility.tip=Les salons publics sont visibles par les pairs.\nLes salons privés sont joignables sur invitation uniquement. chat.room.create.security.checkbox=Identités signées uniquement chat.room.create.security.tip=Un salon limité uniquement aux identités signées est plus résistant contre le spam, car les identités anonymes ne peuvent le joindre. chat.room.create.tooltip=Créer un nouveau salon ## Room invite chat.room.invite.window-title=Invitation de pair dans le salon actuel chat.room.invite.button=Inviter chat.room.invite.tip=Inviter un peer dans le salon courant chat.room.invite.request={0} veut vous inviter dans {1} ({2}) chat.room.join=Joindre chat.room.leave=Partir chat.room.not-found=Salon introuvable. Le salon est probablement absent de la liste de vos amis. # Forums gxs-group.tree.popular=Populaire gxs-group.tree.other=Autre gxs-group.tree.info=Nom: {0}\nID: {1}\nMessages distants: {2}\nActivité distante: {3} gxs-group.tree.subscribe=S'inscrire gxs-group.tree.unsubscribe=Se désinscrire forum.new-message.window-title=Nouveau message forum.create.window-title=Créer forum forum.create.name.prompt=Nom court et descriptif du forum forum.create.name.tip=Nom du forum. Utiliser une capitalisation et des espaces appropriés. forum.create.description.prompt=A quoi le forum sert forum.create.description.tip=La description du forum, à quoi il sert. forum.editor.name=Forum forum.editor.name.prompt=Le nom du forum forum.editor.thread.description=Le sujet du thread forum.editor.cancel=Le message pour le forum n'a pas encore été envoyé! Etes-vous sûr de vouloir effacer ce message? forum.view.create.tip=Créer un nouveau forum forum.view.header.author=Auteur forum.view.header.date=Date forum.view.new-message.tip=Créer un nouveau message forum.view.group.not-found=Forum introuvable. Il n'est probablement pas disponible chez vos amis. forum.view.message.not-found=Message introuvable. Il est probablement trop ancien ou son auteur n'a pas assez bonne réputation. forum.view.from=De: forum.view.subject=Sujet: forum.view.reply=Répondre forum.view.history=Ce sélecteur permet d'afficher les versions précédentes du message. # Boards board.create.window-title=Créer board board.create.name.prompt=Nom court et descriptif du board board.create.name.tip=Nom du board. Utiliser une capitalisation et des espaces appropriés. board.create.description.prompt=A quoi le board sert board.create.description.tip=La description du board, à quoi il sert. board.select-logo=Sélectionnez une image pour le board board.select-image=Sélectionnez une image pour le message board.view.create.tip=Créer un nouveau board board.view.group.not-found=Board introuvable. Il n'est probablement pas disponible chez vos amis. board.new-message.window-title=Nouveau message pour le board board.editor.name=Board board.editor.name.prompt=Le nom du board board.editor.thread.title=Titre board.editor.post.description=Le titre du message board.editor.cancel=Le message pour le board n'a pas encore été envoyé! Etes-vous sûr de vouloir effacer ce message? board.posted-by=Envoyé par board.on=le # Channels channel.view.create.tip=Créer une nouvelle chaîne channel.create.window-title=Créer chaîne channel.create.name.prompt=Nom court et descriptif de la chaîne channel.create.name.tip=Nom de la chaîne. Utiliser une capitalisation et des espaces appropriés. channel.create.description.prompt=A quoi la chaîne sert channel.create.description.tip=La description de la chaîne, à quoi elle sert. channel.select-image=Sélectionnez une image pour le message channel.view.group.not-found=Chaîne introuvable. Elle n'est probablement pas disponible chez vos amis. channel.select-logo=Sélectionnez une image pour la chaîne channel.new-message.window-title=Nouveau message pour la chaîne channel.editor.name=Chaîne channel.editor.name.prompt=Le nom de la chaîne channel.editor.thread.title=Titre channel.editor.post.description=Le titre du message channel.editor.cancel=Le message pour la chaîne n'a pas encore été envoyé! Etes-vous sûr de vouloir effacer ce message? channel.clipboard.error=Le presse papier ne contient aucun lien de fichiers. channel.files=Fichiers channel.post=Message channel.drag-drop=Ajouter fichier ou faire un drag and drop ici channel.add-files=Ajouter fichier(s) channel.paste-links=Coller liens(s) channel.remove-files=Enlever fichier(s) # Add RSID rs-id.add.window-title=Ajout de pair rs-id.add.textarea.prompt=Collez l'identifiant du pair rs-id.add.textarea.tip=L'identifiant est une chaîne d'une centaine de charactères en base 64. Elle contient toutes les informations nécessaires pour se connecter à un pair. rs-id.add.details=Détails du pair rs-id.add.name.tip=Nom du pair, assurez-vous qu'il s'agit bien de la bonne personne. rs-id.add.profile=Identifiant du profil rs-id.add.profile.tip=Identifiant unique pour vérifier si le profil du pair est le bon. rs-id.add.fingerprint=Empreinte rs-id.add.fingerprint.tip=Chaîne de contrôle cryptographique pour certifier l'authenticité du profil du pair. rs-id.add.location=Identifiant de localisation rs-id.add.location.tip=Identifiant de localisation. Un profil peut avoir plusieurs localisations dont chacune possède un identifiant unique. rs-id.add.addresses=Adresses rs-id.add.addresses.tip=Adresses pour s'y connecter. Elles seront essayées une à une dans l'ordre, mais vous pouvez préselectionner la meilleure afin d'accélérer la connexion.\nLes adresses qui se terminent en .onion nécessitent un proxy Tor.\nLes adresses qui se terminent en .i2p nécessitent un proxy I2P. rs-id.add.trust.tip=Le niveau de confiance dans le pair.\nInconnu: pas d'opinion.\nJamais: aucune ou minimale, rencontré en ligne récemment.\nMarginal: plus ou moins en confiance, connaissance.\nTotal: très confiant, ami. rs-id.add.invalid=Identifiant invalide rs-id.add.scan=Scannez le code QR avec la caméra. # Broadcast broadcast.window-title=Envoi multiple broadcast.send.explanation=Envoye un message broadcast.send.warning-header=Attention: broadcast.send.warning=n'abusez pas de cette fonction. Utilisez-la uniquement # Messaging messaging.prompt=Ecrivez un message messaging.file-requester.send-picture=Selectionnez une image à envoyer dans le chat messaging.file-requester.send-file=Sélectionnez un fichier à transmettre messaging.send-picture=Sélectionnez une image pour envoyer dans le chat messaging.send-sticker=Envoyer un sticker messaging.send.file=Sélectionnez un fichier à envoyer messaging.action.call=Passer un appel direct messaging.action.send-inline=Envoyer une image en ligne messaging.action.send-file=Envoyer un fichier messaging.warning.title=Attention messaging.warning.description=L'utilisateur est actuellement déconnecté et ne peut recevoir de messages. messaging.tunneling=Tentative d'établissement d'un tunnel... messaging.closing-tunnel.confirm=Fermer cette fenêtre arrêtera la conversation distante et supprimera tout message en cours d'envoi. Etes-vous sûr? # Profiles profiles.delete=Effacer profil # About about.window-title=A propos de {0} about.version=Version: about.title=A propos about.slogan=Une application de connexion ami-à-ami, décentralisée et sécurisée pour la communication et le partage about.authors=Auteurs about.author-by=par about.all-rights-reserved=Tous droits réservés about.report-bugs=Rapport de bugs ou suggestions. about.website=Site web about.wiki=Wiki about.source-code=Code source about.thanks=Remerciements about.license=Licence about.additional-licenses=Licenses additionnelles about.release=Production about.profiles=Profils: # QR Code qr-code.window-title=QR code qr-code.print=Imprimer... qr-code.save-as-png=Sauver en PNG qr-code.download-client=Télécharger le client sur https://xeres.io qr-code.camera.error=Caméra non détectée # Camera camera.window-title=Scanner un QR code # Settings ## Main settings.general=Général settings.network=Réseau settings.transfer=Transfers settings.notifications=Notifications settings.sound=Son settings.remote=Accès distant settings.directory.no-remote=Impossible de choisir un répertoire en mode d'accès distant ## General settings.general.theme=Thème settings.general.system=Système settings.general.startup=Lancer au démarrage du système settings.general.startup.tip=Se lance automatiquement au démarrage du système, minimisé dans le tray. settings.general.startup.not-available=Non disponible. Soit l'OS n'est pas supporté, soit le programme tourne en mode portable. settings.general.update-check=Vérifier les mises à jour automatiquement settings.general.update-check.tip=Vérifie GitHub une fois par jour pour détecter une nouvelle version. ## Network settings.network.hidden-services=Services anonymes settings.network.tor-proxy=Proxy Socks Tor settings.network.tor-proxy.prompt=Serveur Tor settings.network.tor-proxy.tip=L'adresse IP ou le nom d'hôte du Tor SOCKS v5, habituellement 127.0.0.1 s'il tourne sur la même machine. settings.network.tor-port.tip=Le port du Tor SOCKS v5, normalement 9050. settings.network.i2p-proxy=Proxy Socks I2P settings.network.i2p-proxy.prompt=Serveur I2P settings.network.i2p-proxy.tip=L'adresse IP ou le nom d'hôte du I2P SOCKS v5, habituellement 127.0.0.1 s'il tourne sur la même machine. settings.network.i2p-port.tip=Le port du I2P SOCKS v5, normalement 4447. settings.network.use-upnp=Utiliser UPNP settings.network.use-upnp.tip=L'UPNP (Plug & Play Universel) vous permet de configurer les ports entrants de votre routeur automatiquement. Cela améliore la robustesse des connexions de vos pairs. settings.network.external-ip-and-port=IP externe et port settings.network.external-ip-and-port.tip=L'adresse IP et le port externe de votre localisation. Ceci est comment vous apparaissez sur Internet. settings.network.use-broadcast-discovery=Utiliser Broadcast Discovery settings.network.use-broadcast-discovery.tip=Le Broadcast Discovery permet d'annoncer votre IP et votre port aux autres localisations sur le LAN. Ceci améliore la robustesse des connexions avec d'éventuels pairs sur le LAN. settings.network.internal-ip-and-port=IP internet et port settings.network.internal-ip-and-port.tip=L'adresse IP et le port interne de votre localisation. Ceci est comment vous apparaissez sur votre LAN (réseau local). settings.network.use-dht=Utiliser DHT settings.network.use-dht.tip=Le DHT (table de hachage distribuée) permet aux pairs de se trouver leur adresse IP mutuellement. Ceci améliore la connectivité lors des déplacements. ## Remote settings.remote.title=Accès distant settings.remote.username=Nom d'utilisateur settings.remote.password=Mot de passe settings.remote.note=Un mot de passe vide désactive l'authentification. settings.remote.enabled.tip=Active l'accès à distance. Cette instance peut ensuite être accédée depuis une autre instance de Xeres ou le client mobile Android. settings.remote.upnp-set=Avec UPNP settings.remote.upnp-set.tip=Configure le port distant avec UPNP, ce qui le rend accessible depuis Internet. settings.remote.restart=Vous devez redémarrer Xeres pour que les changements d'accès distant soient effectifs. Quitter maintenant? settings.remote.view-api=Consultez l'API ## Transfer settings.transfer.select-incoming=Sélectionnez le répertoire d'arrivée settings.transfer.incoming=Répertoire entrant ## Notifications settings.notifications.show-connections=Montrer les connexions settings.notifications.show-connections.tip=Montre quand une connexion avec un ami a lieu. settings.notifications.show-broadcasts=Montrer les diffusions simultanées settings.notifications.show-broadcasts.tip=Montrer les diffusions simultanées envoyées par vos amis. settings.notifications.show-discovery=Montrer les discovery settings.notifications.show-discovery.tip=Montre quand un client qui envoi des Broadcast Discovery est sur le LAN. ## Sound settings.sound.message=Message reçu settings.sound.message.tip=Joue un son quand un message privé est recu et que la fenêtre est inactive. settings.sound.highlight=Mise en évidence settings.sound.highlight.tip=Joue un son quand quelqu'un vous écrit dans un salon. settings.sound.friend=Ami connecté settings.sound.friend.tip=Joue un son quand un ami se connecte à vous. settings.sound.download=Téléchargement terminé settings.sound.download.tip=Joue un son quand un téléchargement se termine. settings.sound.ringing=Appel settings.sound.ringing.tip=Joue un son lors de la réception ou de l'émission d'un appel. # Share share.window-title=Partages share.select-directory=Sélectionnez le répertoire à partager share.remove=Supprimer le partage share.error.empty-name=Le nom de partage ne peut être vide. Choisissez un nom unique. share.error.empty-path=Le chemin de partage ne peut être vide. Choisissez un chemin de partage. share.error.not-unique=Ce nom de partage existe déjà. Chaque nom de partage doit être unique. share.list.directory=Répertoire partagé share.list.visible-name=Nom visible share.list.searchable=Recherchable share.list.browsable=Navigable share.create=Créer un nouveau partage share.apply=Appliquer et fermer # Tray tray.open=Ouvrir {0} tray.peers=Pairs tray.status=Status # EditorView editor.hyperlink.enter=Entrer URL editor.action.undo=Défaire (Ctrl+Z) editor.action.redo=Refaire (Ctrl+Shift+Z) editor.action.bold=Gras (Ctrl+B) editor.action.italic=Italique (Ctrl+I) editor.action.hyperlink=Hyperlien (Ctrl+L) editor.action.quote=Citation (Ctrl+Q) editor.action.code=Code (Ctrl+K) editor.action.unordered-list=Liste (Ctrl+U) editor.action.ordered-list=Liste ordonnée (Ctrl+Shift+U) editor.action.header=Entête (Ctrl+1) editor.action.preview=Prévisualiser le message (F12) # Search / Download / Uploads search.main.search=Recherche search.main.downloads=Downloads search.main.uploads=Uploads search.main.trends=Tendances search.input.prompt=Entrez les termes de recherche search.input.search.tip=Saisir plusieurs termes cherchera les fichiers qui les contiennent tous. Utilisez " autours des termes pour une recherche exacte. search.searching=Recherche en cours... trends.none=Aucune tendance pour le moment trends.list.terms=Termes trends.list.from=De trends.list.time=Heure download-view.list.none=Pas de downloads download-view.list.state=Etat download-view.list.progress=Progression download-view.list.total-size=Taille totale download-view.show-in-folder=Montrer dans le répertoire download-view.open-error=Impossible d'ouvrir: download-view.show-error=Impossible d'ouvrir dans l'explorateur: download-add.window-title=Télécharger download-add.bytes={0,number,integer} octets upload-view.none=Aucun fichier en cours d'upload file-result.column.type=Type # StatisticsTurtle statistics.window-title=Statistiques statistics.elapsed-time=Temps écoulé (secondes) statistics.turtle.data-in=Données entrantes statistics.turtle.data-in.tip=Le contenu de données reçu (downloads) statistics.turtle.data-out=Données sortantes statistics.turtle.data-out.tip=Le contenu de données envoyées (uploads) statistics.turtle.data-forward=Données réenvoyées statistics.turtle.data-forward.tip=Le contenu de données réenvoyées à d'autres pairs statistics.turtle.tunnel-in=Requêtes tunnels entrantes statistics.turtle.tunnel-in.tip=Requêtes de tunnels entrantes statistics.turtle.tunnel-out=Requêtes tunnels sortantes statistics.turtle.tunnel-out.tip=Requêtes de tunnels réenvoyées et nos propres requêtes statistics.turtle.search-in=Requêtes de recherches entrantes statistics.turtle.search-in.tip=Requêtes de recherche entrantes statistics.turtle.search-out=Requêtes de recherches sortantes statistics.turtle.search-out.tip=Requêtes de recherche sortantes et nos propres requêtes statistics.turtle.bandwidth=Bande passante statistics.turtle.speed=Vitesse (KB/s) statistics.turtle.tip=Le graphique affiche les statistiques du routeur turtle. Il consiste en\nRequêtes de recherche: recherche de fichiers (titre, taille, etc...)\nRequêtes de tunnels: mise en place des tunnels entre les pairs distants pour préparer un transfert de fichiers.\nRequêtes de données: flux de donnée entre les tunnels.\nLa plupart des requêtes de données qui ne nous sont pas destinées sont réenvoyées à d'autres pairs, avec une probabilité variante suivant la distance. statistics.rtt.rtt=Temps de trajet aller-retour statistics.rtt.time=RTT (millisecondes) statistics.rtt.tip=Le RTT (Round Trip Time ou temps de trajet aller-retour), est le temps qu'il faut pour un message pour arriver à destination ainsi que le temps qu'il fout pour la réponse pour revenir à l'envoyeur. Ceci donne une idée de la latence réseau entre les pairs. Il peut y avoir des problèmes si le RTT est trop élevé (au-delà de quelques secondes). statistics.data-counter.title=Utilisation des données statistics.data-counter.data=Données (KB) statistics.data-counter.tip=Ce graphique montre les données qui vont et viennent des pairs. statistics.data-counter.peers=Pairs statistics.turtle=Turtle statistics.rtt=RTT statistics.data-usage=Utilisation données # ContactView contact-view.profile-delete.confirm=Ceci va supprimer et déconnecter le profile {0}. Voulez-vous vraiment continuer? contact-view.avatar-delete.confirm=Voulez-vous vraiment enlever votre image d'avatar? contact-view.location.last-connected.now=Maintenant contact-view.location.last-connected.never=Jamais contact-view.information.linked-to-profile=Identité reliée au profile contact-view.information.profile=Profile contact-view.information.identity=Identité contact-view.information.type=Type contact-view.information.created=Crée contact-view.information.updated=Mis à jour contact-view.information.created-unknown=inconnu contact-view.information.key-information-with-length=Version: {0}\nAlgorithme: {1}\nTaille: {2} bits\nHash de signature: {3} contact-view.information.key-information=Version: {0}\nAlgorithme: {1}\nHash de signature: {2} contact-view.open.identity-not-found=Identité non trouvée contact-view.open.profile-not-found=Profile non trouvé contact-view.information.location.id=ID de localisation: contact-view.information.location.version=Version: contact-view.search.prompt=Chercher personne contact-view.search.show-all=Montrer tous les contacts contact-view.search.no-contacts=Pas de contacts contact-view.badge.own=Soi-même contact-view.badge.own.tip=Ceci est vous-même. contact-view.badge.partial=Partiel contact-view.badge.partial.tip=Un contact partiel n'est pas encore représenté par un profile complet. Une connexion avec lui doit s'effectuer au moins une fois pour qu'il soit contrôlé, et, si cette opération se déroule avec succès, il sera promu en profile complet. contact-view.badge.accepted=Accepté contact-view.badge.accepted.tip=Ce contact est accepté pour les connexions entrantes. Les connexions sortantes seront tentées également. contact-view.badge.not-validated=Pas encore validé contact-view.badge.not-validated.tip=Ce contact n'a pas encore été validé. Sa signature de profile sera verifiée bientôt et, si effectuée avec succès, il sera marqué comme valid. En cas d'échec, il sera effacé (mais il sera peut-être transferré de nouveau, dans ce cas, tentez d'en informer son propriétaire). contact-view.action.chat=Converser contact-view.action.distant-chat=Converser à distance contact-view.action.connect=Tenter de se connecter contact-view.information.locations=Localisations contact-view.column.last-connected=Dernière connexion contact-view.chat.start=Démarrer une conversation en connexion directe contact-view.distant-chat.start=Démarrer une conversation à distance # ImageSelectorView image-selector-view.change-image=Changer image... image-selector-view.change-image-short=Changer image-selector-view.add-image=Ajouter image... # VoIP voip.window-title=Appel voip.action.message=Message voip.action.message.tip=Envoyer un message direct à l'utilisateur voip.action.recall=Rappeler voip.action.recall.tip=Rappeler l'utilisateur voip.action.close.tip=Fermer la fenêtre voip.action.answer=Répondre voip.action.reject=Rejeter voip.action.hangup=Raccrocher voip.action.window-quit=Etes-vous sûr de vouloir abandonner l'appel? voip.status.incoming=Appel entrant... voip.status.calling=Appel... voip.status.ongoing=En appel voip.status.ended=Fin de l'appel # Update update.latest-already=Vous avez déjà la dernière version. update.new-version=Il y a une nouvelle version de disponible ({0}). Télécharger, vérifier et installer? update.new-version-auto=Il y a une nouvelle version de disponible ({0}). update.download-failure=Impossible de télécharger l'url et/ou l'url de vérification update.download-file=Téléchargement du fichier... update.download.title=Mise à jour Xeres update.download.verifying=Vérification du fichier... update.download.install=Installer update.download.install-ready=Prêt à installer! update.download-verification-failed=Echec de vérification! # Stickers stickers.instructions=Ajoutez vos stickers dans {0}\n\nUn répertoire par collection de stickers, chacun contenant des PNGs ou des JPEGs. # ChatCommands chat-command.code=Envoi le texte comme du code chat-command.coin=Pile ou face chat-command.me=Envoi une action à la troisième personne chat-command.pre=Envoi le texte préformaté chat-command.quote=Envoi le texte comme citation chat-command.random=Envoi un nombre aléatoire de 1 à 10 chat-command-send=Envoi {0} # Misc uri.malicious-link=Attention! Ce lien est malicieux, il vous emmènera sur: {0} uri.unsafe-link=Attention! Ce lien est peut-être malicieux, il vous emmènera sur: {0} uri.malicious-link.confirm=Attention! Ceci est un lien malicieux qui va vous emmener sur {0}. Voulez-vous continuer? uri.unsafe-link.confirm=Attention! Ce lien est peut-être malicieux, il va vous emmener sur {0}. Savez-vous ce que c'est et êtes vous confiant du résultat? content-image.exit=Appuyez sur ESC ou cliquez pour sortir websocket.disconnected=Connexion WebSocket perdue. Chat indisponible. Reconnexion? # TrustConverter trust-converter.nobody=Personne trust-converter.everybody=Tout le monde trust-converter.marginal=Confiance marginale trust-converter.full=Confiance totale trust-converter.ultimate=Uniquement moi # Byte units byte-unit.invalid=invalide byte-unit.bytes=octets byte-unit.kb=Ko byte-unit.mb=Mo byte-unit.gb=Go byte-unit.tb=To byte-unit.pb=Po byte-unit.eb=Eo # Help help.back=Revenir dans l'historique help.forward=Avancer dans l'historique help.home=Aller dans la section principale # Enums (beware of the key naming which must be the same as the class!) ## Trust # suppress inspection "UnusedProperty" enum.trust.unknown=Inconnu # suppress inspection "UnusedProperty" enum.trust.never=Jamais # suppress inspection "UnusedProperty" enum.trust.marginal=Marginal # suppress inspection "UnusedProperty" enum.trust.full=Total # suppress inspection "UnusedProperty" enum.trust.ultimate=Ultime ## Availability # suppress inspection "UnusedProperty" enum.availability.available=Disponible # suppress inspection "UnusedProperty" enum.availability.busy=Occupé # suppress inspection "UnusedProperty" enum.availability.away=Absent # suppress inspection "UnusedProperty" enum.availability.offline=Déconnecté ## RoomType # suppress inspection "UnusedProperty" enum.room-type.private=Privé # suppress inspection "UnusedProperty" enum.room-type.public=Public ## FileType # suppress inspection "UnusedProperty" enum.file-type.any=Tous # suppress inspection "UnusedProperty" enum.file-type.audio=Audio # suppress inspection "UnusedProperty" enum.file-type.archive=Archive # suppress inspection "UnusedProperty" enum.file-type.document=Document # suppress inspection "UnusedProperty" enum.file-type.picture=Image # suppress inspection "UnusedProperty" enum.file-type.program=Programme # suppress inspection "UnusedProperty" enum.file-type.video=Vidéo # suppress inspection "UnusedProperty" enum.file-type.subtitles=Sous-titre # suppress inspection "UnusedProperty" enum.file-type.collection=Collection # suppress inspection "UnusedProperty" enum.file-type.directory=Répertoire ## FileProgressDisplay State # suppress inspection "UnusedProperty" enum.file-progress-display.state.searching=Recherche # suppress inspection "UnusedProperty" enum.file-progress-display.state.transferring=Transfert # suppress inspection "UnusedProperty" enum.file-progress-display.state.removing=Retirement # suppress inspection "UnusedProperty" enum.file-progress-display.state.done=Terminé ## FileAttachment State # suppress inspection "UnusedProperty" enum.channel-file.state.hashing=Hachage # suppress inspection "UnusedProperty" enum.channel-file.state.done=Terminé ## Country # suppress inspection "UnusedProperty" enum.country.af=Afghanistan # suppress inspection "UnusedProperty" enum.country.al=Albanie # suppress inspection "UnusedProperty" enum.country.dz=Algérie # suppress inspection "UnusedProperty" enum.country.as=Samoa américaines # suppress inspection "UnusedProperty" enum.country.ad=Andorre # suppress inspection "UnusedProperty" enum.country.ao=Angola # suppress inspection "UnusedProperty" enum.country.ai=Anguille # suppress inspection "UnusedProperty" enum.country.aq=Antarctique # suppress inspection "UnusedProperty" enum.country.ag=Antigua-et-Barbuda # suppress inspection "UnusedProperty" enum.country.ar=Argentine # suppress inspection "UnusedProperty" enum.country.am=Arménie # suppress inspection "UnusedProperty" enum.country.aw=Aruba # suppress inspection "UnusedProperty" enum.country.au=Australie # suppress inspection "UnusedProperty" enum.country.at=Autriche # suppress inspection "UnusedProperty" enum.country.az=Azerbaïdjan # suppress inspection "UnusedProperty" enum.country.ba=Bosnie Herzégovine # suppress inspection "UnusedProperty" enum.country.bs=Bahamas # suppress inspection "UnusedProperty" enum.country.bh=Bahreïn # suppress inspection "UnusedProperty" enum.country.bd=Bangladesh # suppress inspection "UnusedProperty" enum.country.bb=Barbade # suppress inspection "UnusedProperty" enum.country.by=Biélorussie # suppress inspection "UnusedProperty" enum.country.be=Belgique # suppress inspection "UnusedProperty" enum.country.bz=Bélize # suppress inspection "UnusedProperty" enum.country.bj=Bénin # suppress inspection "UnusedProperty" enum.country.bm=Bermudes # suppress inspection "UnusedProperty" enum.country.bt=Bhoutan # suppress inspection "UnusedProperty" enum.country.bo=Bolivie # suppress inspection "UnusedProperty" enum.country.bw=Botswana # suppress inspection "UnusedProperty" enum.country.bv=Île Bouvet # suppress inspection "UnusedProperty" enum.country.br=Brésil # suppress inspection "UnusedProperty" enum.country.io=Territoire britannique de l'océan Indien # suppress inspection "UnusedProperty" enum.country.bn=Brunéi # suppress inspection "UnusedProperty" enum.country.bg=Bulgarie # suppress inspection "UnusedProperty" enum.country.bf=Burkina Faso # suppress inspection "UnusedProperty" enum.country.bi=Burundi # suppress inspection "UnusedProperty" enum.country.kh=Cambodge # suppress inspection "UnusedProperty" enum.country.cm=Cameroun # suppress inspection "UnusedProperty" enum.country.ca=Canada # suppress inspection "UnusedProperty" enum.country.cv=Cap-Vert # suppress inspection "UnusedProperty" enum.country.ky=Îles Caïmans # suppress inspection "UnusedProperty" enum.country.cf=République centrafricaine # suppress inspection "UnusedProperty" enum.country.td=Tchad # suppress inspection "UnusedProperty" enum.country.cl=Chili # suppress inspection "UnusedProperty" enum.country.cn=Chine # suppress inspection "UnusedProperty" enum.country.cx=L'île de noël # suppress inspection "UnusedProperty" enum.country.cc=Îles Cocos (Keeling) # suppress inspection "UnusedProperty" enum.country.co=Colombie # suppress inspection "UnusedProperty" enum.country.km=Comores # suppress inspection "UnusedProperty" enum.country.cg=Congo # suppress inspection "UnusedProperty" enum.country.cd=République démocratique du Congo # suppress inspection "UnusedProperty" enum.country.ck=Les Îles Cook # suppress inspection "UnusedProperty" enum.country.cr=Costa Rica # suppress inspection "UnusedProperty" enum.country.ci=Côte d'Ivoire # suppress inspection "UnusedProperty" enum.country.hr=Croatie # suppress inspection "UnusedProperty" enum.country.cu=Cuba # suppress inspection "UnusedProperty" enum.country.cy=Chypre # suppress inspection "UnusedProperty" enum.country.cz=République tchèque # suppress inspection "UnusedProperty" enum.country.dk=Danemark # suppress inspection "UnusedProperty" enum.country.dj=Djibouti # suppress inspection "UnusedProperty" enum.country.dm=Dominique # suppress inspection "UnusedProperty" enum.country.do=République Dominicaine # suppress inspection "UnusedProperty" enum.country.ec=Equateur # suppress inspection "UnusedProperty" enum.country.eg=Egypte # suppress inspection "UnusedProperty" enum.country.sv=Le Salvador # suppress inspection "UnusedProperty" enum.country.gq=Guinée Équatoriale # suppress inspection "UnusedProperty" enum.country.er=Érythrée # suppress inspection "UnusedProperty" enum.country.ee=Estonie # suppress inspection "UnusedProperty" enum.country.et=Ethiopie # suppress inspection "UnusedProperty" enum.country.fk=Îles Falkland (Malouines) # suppress inspection "UnusedProperty" enum.country.fo=Îles Féroé # suppress inspection "UnusedProperty" enum.country.fj=Fidji # suppress inspection "UnusedProperty" enum.country.fi=Finlande # suppress inspection "UnusedProperty" enum.country.fr=France # suppress inspection "UnusedProperty" enum.country.gf=Guyane Française # suppress inspection "UnusedProperty" enum.country.pf=Polynésie française # suppress inspection "UnusedProperty" enum.country.tf=Terres australes françaises # suppress inspection "UnusedProperty" enum.country.ga=Gabon # suppress inspection "UnusedProperty" enum.country.gm=Gambie # suppress inspection "UnusedProperty" enum.country.ge=Géorgie # suppress inspection "UnusedProperty" enum.country.de=Allemagne # suppress inspection "UnusedProperty" enum.country.gh=Gnana # suppress inspection "UnusedProperty" enum.country.gi=Gibraltar # suppress inspection "UnusedProperty" enum.country.gr=Grèce # suppress inspection "UnusedProperty" enum.country.gl=Groenland # suppress inspection "UnusedProperty" enum.country.gd=Grenade # suppress inspection "UnusedProperty" enum.country.gp=Guadeloupe # suppress inspection "UnusedProperty" enum.country.gu=Guam # suppress inspection "UnusedProperty" enum.country.gt=Guatemala # suppress inspection "UnusedProperty" enum.country.gg=Guernesey # suppress inspection "UnusedProperty" enum.country.gn=Guinée # suppress inspection "UnusedProperty" enum.country.gw=Guinée-Bissau # suppress inspection "UnusedProperty" enum.country.gy=Guyane # suppress inspection "UnusedProperty" enum.country.ht=Haïti # suppress inspection "UnusedProperty" enum.country.hm=Île Heard et îles McDonald # suppress inspection "UnusedProperty" enum.country.va=Saint-Siège (État de la Cité du Vatican) # suppress inspection "UnusedProperty" enum.country.hn=Honduras # suppress inspection "UnusedProperty" enum.country.hk=Hong Kong # suppress inspection "UnusedProperty" enum.country.hu=Hongrie # suppress inspection "UnusedProperty" enum.country.is=Islande # suppress inspection "UnusedProperty" enum.country.in=Inde # suppress inspection "UnusedProperty" enum.country.id=Indonésie # suppress inspection "UnusedProperty" enum.country.ir=Iran # suppress inspection "UnusedProperty" enum.country.iq=Irak # suppress inspection "UnusedProperty" enum.country.ie=Irlande # suppress inspection "UnusedProperty" enum.country.im=île de Man # suppress inspection "UnusedProperty" enum.country.il=Israël # suppress inspection "UnusedProperty" enum.country.it=Italie # suppress inspection "UnusedProperty" enum.country.jm=Jamaïque # suppress inspection "UnusedProperty" enum.country.jp=Japon # suppress inspection "UnusedProperty" enum.country.je=Jersey # suppress inspection "UnusedProperty" enum.country.jo=Jordanie # suppress inspection "UnusedProperty" enum.country.kz=Kazakhstan # suppress inspection "UnusedProperty" enum.country.ke=Kenya # suppress inspection "UnusedProperty" enum.country.ki=Kiribati # suppress inspection "UnusedProperty" enum.country.kp=Corée du nord # suppress inspection "UnusedProperty" enum.country.kr=Corée du sud # suppress inspection "UnusedProperty" enum.country.kw=Koweit # suppress inspection "UnusedProperty" enum.country.kg=Kirghizistan # suppress inspection "UnusedProperty" enum.country.la=République démocratique populaire de Lao # suppress inspection "UnusedProperty" enum.country.lv=Lettonie # suppress inspection "UnusedProperty" enum.country.lb=Liban # suppress inspection "UnusedProperty" enum.country.ls=Lesotho # suppress inspection "UnusedProperty" enum.country.lr=Libéria # suppress inspection "UnusedProperty" enum.country.ly=Libye # suppress inspection "UnusedProperty" enum.country.li=Liechtenstein # suppress inspection "UnusedProperty" enum.country.lt=Lituanie # suppress inspection "UnusedProperty" enum.country.lu=Luxembourg # suppress inspection "UnusedProperty" enum.country.mo=Macao # suppress inspection "UnusedProperty" enum.country.mk=Macédoine # suppress inspection "UnusedProperty" enum.country.mg=Madagascar # suppress inspection "UnusedProperty" enum.country.mw=Malawi # suppress inspection "UnusedProperty" enum.country.my=Malaisie # suppress inspection "UnusedProperty" enum.country.mv=Maldives # suppress inspection "UnusedProperty" enum.country.ml=Mali # suppress inspection "UnusedProperty" enum.country.mt=Malte # suppress inspection "UnusedProperty" enum.country.mh=Iles Marshall # suppress inspection "UnusedProperty" enum.country.mq=Martinique # suppress inspection "UnusedProperty" enum.country.mr=Mauritanie # suppress inspection "UnusedProperty" enum.country.mu=Maurice # suppress inspection "UnusedProperty" enum.country.yt=Mayotte # suppress inspection "UnusedProperty" enum.country.mx=Méxique # suppress inspection "UnusedProperty" enum.country.fm=Micronésie # suppress inspection "UnusedProperty" enum.country.md=Moldavie # suppress inspection "UnusedProperty" enum.country.mc=Monaco # suppress inspection "UnusedProperty" enum.country.mn=Mongolie # suppress inspection "UnusedProperty" enum.country.me=Monténégro # suppress inspection "UnusedProperty" enum.country.ms=Montserrat # suppress inspection "UnusedProperty" enum.country.ma=Maroc # suppress inspection "UnusedProperty" enum.country.mz=Mozambique # suppress inspection "UnusedProperty" enum.country.mm=Birmanie # suppress inspection "UnusedProperty" enum.country.na=Namibie # suppress inspection "UnusedProperty" enum.country.nr=Nauru # suppress inspection "UnusedProperty" enum.country.np=Népal # suppress inspection "UnusedProperty" enum.country.nl=Pays bas # suppress inspection "UnusedProperty" enum.country.an=Antilles néerlandaises # suppress inspection "UnusedProperty" enum.country.nc=Nouvelle-Calédonie # suppress inspection "UnusedProperty" enum.country.nz=Nouvelle-Zélande # suppress inspection "UnusedProperty" enum.country.ni=Nicaragua # suppress inspection "UnusedProperty" enum.country.ne=Niger # suppress inspection "UnusedProperty" enum.country.ng=Nigeria # suppress inspection "UnusedProperty" enum.country.nu=Niué # suppress inspection "UnusedProperty" enum.country.nf=l'ile de Norfolk # suppress inspection "UnusedProperty" enum.country.mp=Îles Mariannes du Nord # suppress inspection "UnusedProperty" enum.country.no=Norvège # suppress inspection "UnusedProperty" enum.country.om=Oman # suppress inspection "UnusedProperty" enum.country.pk=Pakistan # suppress inspection "UnusedProperty" enum.country.pw=Palaos # suppress inspection "UnusedProperty" enum.country.ps=Palestine # suppress inspection "UnusedProperty" enum.country.pa=Panama # suppress inspection "UnusedProperty" enum.country.pg=Papouasie Nouvelle Guinée # suppress inspection "UnusedProperty" enum.country.py=Paraguay # suppress inspection "UnusedProperty" enum.country.pe=Pérou # suppress inspection "UnusedProperty" enum.country.ph=Philippines # suppress inspection "UnusedProperty" enum.country.pn=Pitcairn # suppress inspection "UnusedProperty" enum.country.pl=Pologne # suppress inspection "UnusedProperty" enum.country.pt=Portugal # suppress inspection "UnusedProperty" enum.country.pr=Porto Rico # suppress inspection "UnusedProperty" enum.country.qa=Qatar # suppress inspection "UnusedProperty" enum.country.re=Réunion # suppress inspection "UnusedProperty" enum.country.ro=Roumanie # suppress inspection "UnusedProperty" enum.country.ru=Russie # suppress inspection "UnusedProperty" enum.country.rw=Rwanda # suppress inspection "UnusedProperty" enum.country.sh=Sainte-Hélène, Ascension et Tristan da Cunha # suppress inspection "UnusedProperty" enum.country.kn=Saint-Christophe-et-Niévès # suppress inspection "UnusedProperty" enum.country.lc=Sainte-Lucie # suppress inspection "UnusedProperty" enum.country.pm=Saint Pierre and Miquelon # suppress inspection "UnusedProperty" enum.country.vc=Saint-Vincent-et-les-Grenadines # suppress inspection "UnusedProperty" enum.country.ws=Samoa # suppress inspection "UnusedProperty" enum.country.sm=Saint-Marin # suppress inspection "UnusedProperty" enum.country.st=Sao Tomé et Principe # suppress inspection "UnusedProperty" enum.country.sa=Arabie Saoudite # suppress inspection "UnusedProperty" enum.country.sn=Sénégal # suppress inspection "UnusedProperty" enum.country.rs=Serbie # suppress inspection "UnusedProperty" enum.country.sc=Seychelles # suppress inspection "UnusedProperty" enum.country.sl=Sierra Leone # suppress inspection "UnusedProperty" enum.country.sg=Singapour # suppress inspection "UnusedProperty" enum.country.sk=Slovaquie # suppress inspection "UnusedProperty" enum.country.si=Slovénie # suppress inspection "UnusedProperty" enum.country.sb=îles Salomon # suppress inspection "UnusedProperty" enum.country.so=Somalie # suppress inspection "UnusedProperty" enum.country.za=Afrique du Sud # suppress inspection "UnusedProperty" enum.country.gs=Géorgie du Sud et îles Sandwich du Sud # suppress inspection "UnusedProperty" enum.country.ss=Soudan du Sud # suppress inspection "UnusedProperty" enum.country.es=Espagne # suppress inspection "UnusedProperty" enum.country.lk=Sri Lanka # suppress inspection "UnusedProperty" enum.country.sd=Soudan # suppress inspection "UnusedProperty" enum.country.sr=Suriname # suppress inspection "UnusedProperty" enum.country.sj=Svalbard et Jan Mayen # suppress inspection "UnusedProperty" enum.country.sz=Swaziland # suppress inspection "UnusedProperty" enum.country.se=Suède # suppress inspection "UnusedProperty" enum.country.ch=Suisse # suppress inspection "UnusedProperty" enum.country.sy=Syrie # suppress inspection "UnusedProperty" enum.country.tw=Taïwan # suppress inspection "UnusedProperty" enum.country.tj=Tadjikistan # suppress inspection "UnusedProperty" enum.country.tz=Tanzanie # suppress inspection "UnusedProperty" enum.country.th=Thaïlande # suppress inspection "UnusedProperty" enum.country.tl=Timor oriental # suppress inspection "UnusedProperty" enum.country.tg=Togo # suppress inspection "UnusedProperty" enum.country.tk=Tokélaou # suppress inspection "UnusedProperty" enum.country.to=Tonga # suppress inspection "UnusedProperty" enum.country.tt=Trinité et Tobago # suppress inspection "UnusedProperty" enum.country.tn=Tunisie # suppress inspection "UnusedProperty" enum.country.tr=Turquie # suppress inspection "UnusedProperty" enum.country.tm=Turkménistan # suppress inspection "UnusedProperty" enum.country.tc=îles Turques et Caïques # suppress inspection "UnusedProperty" enum.country.tv=Tuvalu # suppress inspection "UnusedProperty" enum.country.ug=Uganda # suppress inspection "UnusedProperty" enum.country.ua=Ukraine # suppress inspection "UnusedProperty" enum.country.ae=Emirats Arabes Unis # suppress inspection "UnusedProperty" enum.country.gb=Royaume-Uni # suppress inspection "UnusedProperty" enum.country.us=États-Unis # suppress inspection "UnusedProperty" enum.country.um=Îles mineures éloignées des États-Unis # suppress inspection "UnusedProperty" enum.country.uy=Uruguay # suppress inspection "UnusedProperty" enum.country.uz=Ouzbékistan # suppress inspection "UnusedProperty" enum.country.vu=Vanuatu # suppress inspection "UnusedProperty" enum.country.ve=Venezuela # suppress inspection "UnusedProperty" enum.country.vn=Viêt Nam # suppress inspection "UnusedProperty" enum.country.vg=Îles Vierges britanniques # suppress inspection "UnusedProperty" enum.country.vi=Îles Vierges, États-Unis # suppress inspection "UnusedProperty" enum.country.wf=Wallis et Futuna # suppress inspection "UnusedProperty" enum.country.eh=Sahara occidental # suppress inspection "UnusedProperty" enum.country.ye=Yémen # suppress inspection "UnusedProperty" enum.country.zm=Zambie # suppress inspection "UnusedProperty" enum.country.zw=Zimbabwe # suppress inspection "UnusedProperty" enum.country.tor=Tor # suppress inspection "UnusedProperty" enum.country.i2p=I2P # suppress inspection "UnusedProperty" enum.country.lan=LAN ================================================ FILE: common/src/main/resources/i18n/messages_ru.properties ================================================ # # Copyright (c) 2025-2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # Common ok=ОК cancel=Отмена close=Закрыть send=Отправить create=Создать remove=Удалить download=Скачать add=Добавить open=Открыть copy-link=Копировать адрес ссылки copy=Копировать save-as=Сохранить как... paste-id=Вставить свой ID undo=Отменить redo=Повторить cut=Вырезать paste=Вставить delete=Удалить select-all=Выделить все deselect-all=Снять выделение view-fullscreen=Полноэкранный режим copy-image=Копировать изображение save-image-as=Сохранить изображение как... enabled=Включено no-results=Результатов не найдено skip=Пропустить name=Имя help=Справка settings=Настройки exit=Выход profile=Профиль subscribed=Подписаны own=Свои description=Описание subject=Тема hash=Хеш size=Размер trust=Доверие unknown-lc=неизвестно logo=Логотип latest=Последнее update=Обновить edit=Редактировать body=Основной текст (необязательно) text=Текст image=Изображение link=Ссылка mark-read-unread=Отметить как прочитанное/непрочитанное thumbnail=Миниатюра posts-at-remote-nodes=Сообщения на удалённом узле last-activity=Последняя активность state=Состояние ip=IP port=Порт # File Requesters file-requester.profiles=Файлы профилей file-requester.xml=XML файлы file-requester.png=PNG файлы file-requester.sounds=Звуковые файлы file-requester.select-sound-title=Выберите звуковой файл file-requester.images=Файлы изображений file-requester.save-image-title=Выберите место для сохранения изображения file-requester.error=Ошибка с файлом {0}: {1} file-requester.add-files=Выберите файл(ы) для добавления # Main ## Menu main.menu.add-peer=Добавить участника... main.menu.broadcast=Трансляция... main.menu.shares=Настроить общие ресурсы main.menu.statistics=Показать статистику main.menu.tools=Инструменты main.menu.tools.import-from-rs=Импорт друзей из Retroshare... main.menu.tools.export=Экспорт... main.menu.help.about=О Xeres main.menu.help.documentation=Документация main.menu.help.report-bug=Сообщить об ошибке ↗ main.menu.help.check-for-updates=Проверить обновления... ↗ main.friends-import-successful=Успешно импортировано {0} местоположений. main.friends-import-errors=Импортировано {0} местоположений, но {1} с ошибками. main.systray.peers=Подключено участников: {0,number,integer} ## Splash splash.status.database=Загрузка базы данных splash.status.network=Запуск сети ## Content main.home=Главная main.contacts=Контакты main.chats=Чаты main.forums=Форумы main.files=Файлы main.boards=Доски main.channels=Каналы main.home.slogan=Где Дружба Встречает Свободу main.home.share-id=Это ваш Xeres ID. Поделитесь им с другими людьми. main.home.received-id=Вы получили ID от участника? main.home.add-peer=Добавить участника main.home.add-peer.tip=Добавить друга, вставив его ID main.home.need-help=Нужна помощь? main.home.online-help=Онлайн справка ↗ main.home.online-help.tip=Показать онлайн справку (Ctrl+F1) main.home.copy-id.tip=Скопировать Xeres ID в буфер обмена main.home.qrcode.tip=Используйте QR-код для передачи вашего ID. Распечатайте его или сфотографируйте телефоном, затем покажите веб-камере. main.select-avatar=Выбрать изображение аватара main.export-profile=Выберите, куда сохранить ваш профиль main.import-friends=Выберите файл друзей Retroshare main.scanning=Сканирование {0}... main.hashing=Хеширование {0} main.scanning.tip=Ресурс: {0}, файл: {1} ## Status main.status.connections=Подключения: main.status.nat.unknown=Статус еще неизвестен. main.status.nat.firewalled=Клиент недоступен для подключений, инициированных из Интернета. main.status.nat.upnp=UPNP активен и клиент полностью доступен из Интернета. main.status.dht.disabled=DHT отключен. main.status.dht.initializing=DHT в процессе инициализации. Это может занять некоторое время. main.status.dht.running=DHT работает правильно, IP-адрес клиента анонсирован его участникам. main.status.dht.stats=Количество участников: {0,number,integer}\nПолучено пакетов: {1,number,integer} ({2})\nОтправлено пакетов: {3,number,integer} ({4})\nКоличество ключей: {5,number,integer}\nКоличество элементов: {6,number,integer} main.exit.confirm=Вы уверены, что хотите выйти из Xeres? # Account creation account.welcome=Добро пожаловать в Xeres account.welcome.tip=Вам нужно создать профиль и местоположение.\n\nПрофиль - это вы сами, вы можете использовать свое имя или псевдоним, а местоположение - это устройство, на котором вы находитесь.\n\nУ вас может быть несколько местоположений, например, настольный компьютер и ноутбук, которые используют один и тот же профиль (вас).\n\nИспользуйте опцию импорта, чтобы импортировать профиль, который вы уже создали ранее.\n\nВсе всегда хранится локально, поэтому не забудьте сделать резервную копию ваших данных.\n\nНажмите клавишу F1, чтобы прочитать встроенную документацию, и помните, что если вы наведете указатель мыши на элемент пользовательского интерфейса и задержите его на короткое время, появится описание этого элемента. account.profile.prompt=Имя профиля account.profile.tip=Используйте псевдоним или настоящее имя. У профиля может быть несколько местоположений. account.location=Местоположение account.location.prompt=Имя местоположения account.location.tip=Это ваш экземпляр Xeres на этом устройстве. Используйте псевдоним вашего устройства или его модель. account.options=Опции account.generation.profile-keys=Генерация ключей профиля... account.generation.location-keys-and-certificate=Генерация ключей местоположения и сертификата... account.generation.identity=Генерация идентификатора... account.generation.import=Импорт... account.generation.import.tip=Вы можете импортировать 3 вида профилей:\n\nПрофиль, экспортированный из Xeres (xeres_backup.xml).\n\nКлючевое хранилище Retroshare (retroshare_secret_keyring.gpg) или профиль, экспортированный из Retroshare (*.asc). account.generation.import.progress=Импорт профиля... account.generation.import.confirm.title=Импортер Retroshare account.generation.import.confirm.prompt=Введите пароль Retroshare account.generation.import.unknown=Неизвестный формат файла account.generation.profile-load=Выберите файл профиля Xeres (xeres_backup.xml), ключевой контейнер Retroshare (retroshare_secret_keyring.gpg) или профиль Retroshare (*.asc) # Chat ## Common chat.notification.typing={0} печатает ## Room common chat.room.id=ID chat.room.topic=Тема chat.room.security=Безопасность chat.room.users=Пользователи chat.room.info=Тема: {0}\nПользователи: {1,number,integer}\nБезопасность: {2}\nID: {3} chat.room.none=[нет] chat.room.private=приватная chat.room.public=публичная chat.room.signed-only=только подписанные ID chat.room.anonymous-allowed=анонимные ID разрешены chat.room.user-info=Имя: {0}\nID: {1} chat.room.user-menu=Информация chat.room.clear-history=Вы действительно хотите очистить историю? chat.room.copy-selection=Копировать выделенное chat.room.clear-chat-history=Очистить историю чата ## Room create chat.room.create.window-title=Создать комнату чата chat.room.create.name.prompt=Короткое и описательное имя комнаты chat.room.create.name.tip=Название комнаты. Используйте правильные заглавные буквы и пробелы. chat.room.create.topic.prompt=О чем комната chat.room.create.topic.tip=Описание комнаты, о чем она. chat.room.create.visibility=Видимость chat.room.create.visibility.tip=Публичные комнаты видны участникам.\nПриватные комнаты нет и работают только по приглашению. chat.room.create.security.checkbox=Только подписанные идентификаторы chat.room.create.security.tip=Комната, ограниченная для подписанных идентификаторов, более устойчива к спаму, потому что анонимные идентификаторы не могут присоединиться. chat.room.create.tooltip=Создать новую комнату чата ## Room invite chat.room.invite.window-title=Пригласить участника в текущую комнату чата chat.room.invite.button=Пригласить chat.room.invite.tip=Пригласить участников в текущую комнату чата chat.room.invite.request={0} хочет пригласить вас в {1} ({2}) chat.room.join=Присоединиться chat.room.leave=Покинуть chat.room.not-found=Комната не найдена. Вероятно, комната недоступна ни на одном из ваших подключенных друзей. # Forums gxs-group.tree.popular=Популярные gxs-group.tree.other=Другие gxs-group.tree.info=Имя: {0}\nID: {1}\nУдалённые сообщения: {2}\nАктивность на сервере: {3} gxs-group.tree.subscribe=Подписаться gxs-group.tree.unsubscribe=Отписаться forum.new-message.window-title=Новое сообщение forum.create.window-title=Создать форум forum.create.name.prompt=Короткое и описательное имя форума forum.create.name.tip=Название форума. Используйте правильные заглавные буквы и пробелы. forum.create.description.prompt=О чем форум forum.create.description.tip=Описание форума, о чем он. forum.editor.name=Форум forum.editor.name.prompt=Название форума forum.editor.thread.description=Тема ветки forum.editor.cancel=Сообщение форума еще не отправлено! Вы действительно хотите отменить это сообщение? forum.view.create.tip=Создать новый форум forum.view.header.author=Автор forum.view.header.date=Дата forum.view.new-message.tip=Создать новое сообщение forum.view.group.not-found=Форум не найден. Вероятно, он недоступен ни на одном из ваших подключенных друзей. forum.view.message.not-found=Сообщение не найдено. Вероятно, сообщение слишком старое или у отправителя слишком низкая репутация. forum.view.from=От: forum.view.subject=Тема: forum.view.reply=Ответить forum.view.history=Этот переключатель позволяет отображать предыдущие версии сообщений. # Boards board.create.window-title=Создать доску board.create.name.prompt=Краткое и понятное название доски board.create.name.tip=Название доски. Используйте заглавные буквы и пробелы по правилам. board.create.description.prompt=Тематика доски board.create.description.tip=Описание доски, её тематика. board.select-logo=Выбрать изображение доски board.select-image=Выбрать изображение для публикации board.view.create.tip=Создать новую доску board.view.group.not-found=Доска не найдена. Вероятно, она недоступна ни на одном из ваших подключённых серверов. board.new-message.window-title=Новое сообщение на доске board.editor.name=Доска board.editor.name.prompt=Название доски board.editor.thread.title=Заголовок board.editor.post.description=Заголовок сообщения board.editor.cancel=Сообщение на доске ещё не отправлено! Вы действительно хотите отменить его? board.posted-by=Опубликовано board.on=в # Channels channel.view.create.tip=Создать новый канал channel.create.window-title=Создать канал channel.create.name.prompt=Краткое и понятное название канала channel.create.name.tip=Название канала. Используйте заглавные буквы и пробелы по правилам. channel.create.description.prompt=Тематика канала channel.create.description.tip=Описание канала, его тематика. channel.select-image=Выбрать изображение для сообщения в канале channel.view.group.not-found=Канал не найден. Вероятно, он недоступен ни на одном из ваших подключённых серверов. channel.select-logo=Выбрать изображение канала channel.new-message.window-title=Новое сообщение в канале channel.editor.name=Канал channel.editor.name.prompt=Название канала channel.editor.thread.title=Заголовок channel.editor.post.description=Заголовок сообщения channel.editor.cancel=Сообщение в канале ещё не отправлено! Вы действительно хотите отменить его? channel.clipboard.error=Буфер обмена не содержит ссылок на файлы. channel.files=Файлы channel.post=Опубликовать channel.drag-drop=Добавьте файлы или перетащите их сюда channel.add-files=Добавить файл(ы) channel.paste-links=Вставить ссылку(и) channel.remove-files=Удалить файл(ы) # Add RSID rs-id.add.window-title=Добавить участника rs-id.add.textarea.prompt=Вставьте ID участника rs-id.add.textarea.tip=ID - это строка длиной около сотни символов base64. Она кодирует всю информацию, необходимую для подключения к участнику. rs-id.add.details=Детали участника rs-id.add.name.tip=Имя участника, убедитесь, что вы знаете, кто это. rs-id.add.profile=ID профиля rs-id.add.profile.tip=Уникальный ID для проверки правильности профиля вашего участника. rs-id.add.fingerprint=Отпечаток rs-id.add.fingerprint.tip=Криптографическая контрольная сумма для подтверждения подлинности профиля вашего участника. rs-id.add.location=ID местоположения rs-id.add.location.tip=Идентификатор местоположения. У профиля может быть несколько местоположений, и у каждого есть уникальный ID. rs-id.add.addresses=Адреса rs-id.add.addresses.tip=Адреса для подключения. Они все будут пробоваться по очереди, но вы можете предварительно выбрать лучший для более быстрого первоначального подключения.\nАдреса, оканчивающиеся на .onion, требуют использования прокси Tor.\nАдреса, оканчивающиеся на .i2p, требуют использования прокси I2P. rs-id.add.trust.tip=Уровень доверия к участнику.\nНеизвестно: нет мнения.\nНикогда: нет или минимальное, встретились онлайн недавно.\nЧастичное: более или менее доверенный, знакомый.\nПолное: очень доверенный, хороший друг. rs-id.add.invalid=Неверный ID rs-id.add.scan=Сканируйте код QR с помощью камеры. # Broadcast broadcast.window-title=Трансляция broadcast.send.explanation=Отправить сообщение всем текущим подключенным участникам. broadcast.send.warning-header=Предупреждение: broadcast.send.warning=не злоупотребляйте этой функцией. Используйте ее только в чрезвычайных или исключительных ситуациях. # Messaging messaging.prompt=Введите сообщение messaging.file-requester.send-picture=Выберите изображение для отправки в сообщении messaging.file-requester.send-file=Выберите файл для отправки messaging.send-picture=Выберите изображение для отправки в сообщении messaging.send-sticker=Отправить стикер messaging.send.file=Выберите файл для отправки messaging.action.call=Совершить прямой звонок messaging.action.send-inline=Отправить изображение встроенным messaging.action.send-file=Отправить файл messaging.warning.title=Предупреждение messaging.warning.description=Пользователь в настоящее время не в сети и не может получать сообщения. messaging.tunneling=Попытка установить туннель... messaging.closing-tunnel.confirm=Закрытие этого окна завершит удаленный чат и удалит все неотправленные сообщения. Вы уверены? # Profiles profiles.delete=Удалить профиль # About about.window-title=О {0} about.version=Версия: about.title=О программе about.slogan=Друг-к-Другу, децентрализованное и безопасное приложение для общения и обмена about.authors=Авторы about.author-by=от about.all-rights-reserved=Все права защищены about.report-bugs=Сообщить об ошибках или предложить улучшения. about.website=Веб-сайт about.wiki=Вики about.source-code=Исходный код about.thanks=Благодарности about.license=Лицензия about.additional-licenses=Дополнительные лицензии about.release=Релиз about.profiles=Профили: # QR Code qr-code.window-title=QR код qr-code.print=Печать... qr-code.save-as-png=Сохранить как PNG qr-code.download-client=Скачать клиент на https://xeres.io qr-code.camera.error=Камера не обнаружена # Camera camera.window-title=Сканировать QR код # Settings ## Main settings.general=Общие settings.network=Сеть settings.transfer=Передача settings.notifications=Уведомления settings.sound=Звук settings.remote=Удаленный доступ settings.directory.no-remote=Невозможно выбрать каталог в удаленном режиме ## General settings.general.theme=Тема settings.general.system=Системная settings.general.startup=Запускать при загрузке системы settings.general.startup.tip=Запускать автоматически при запуске системы, свернутым в трей. settings.general.startup.not-available=Недоступно. Либо ОС не поддерживается, либо вы работаете в портативном режиме. settings.general.update-check=Автоматически проверять обновления settings.general.update-check.tip=Автоматически проверяет GitHub раз в день на наличие нового релиза. ## Network settings.network.hidden-services=Скрытые сервисы settings.network.tor-proxy=Socks прокси Tor settings.network.tor-proxy.prompt=Сервер Tor settings.network.tor-proxy.tip=IP-адрес или имя хоста Tor SOCKS v5, обычно 127.0.0.1, если работает на том же хосте. settings.network.tor-port.tip=Порт Tor SOCKS v5, обычно 9050. settings.network.i2p-proxy=Socks прокси I2P settings.network.i2p-proxy.prompt=Сервер I2P settings.network.i2p-proxy.tip=IP-адрес или имя хоста I2P SOCKS v5, обычно 127.0.0.1, если работает на том же хосте. settings.network.i2p-port.tip=Порт I2P SOCKS v5, обычно 4447. settings.network.use-upnp=Использовать UPNP settings.network.use-upnp.tip=UPNP (Universal Plug and Play) позволяет автоматически настроить правильные входящие порты в вашем маршрутизаторе. Это улучшает надежность подключения от ваших участников. settings.network.external-ip-and-port=Внешний IP и порт settings.network.external-ip-and-port.tip=Внешний IP-адрес и порт вашего местоположения. Так ваше подключение выглядит в Интернете. settings.network.use-broadcast-discovery=Включить обнаружение по широковещательной рассылке settings.network.use-broadcast-discovery.tip=Обнаружение по широковещательной рассылке позволяет сообщать ваш IP и порт другим местоположениям в вашей локальной сети. Это улучшает надежность подключения от возможных участников в вашей локальной сети. settings.network.internal-ip-and-port=Внутренний IP и порт settings.network.internal-ip-and-port.tip=Внутренний IP-адрес и порт вашего местоположения. Так ваше подключение выглядит в вашей локальной сети (LAN). settings.network.use-dht=Включить DHT settings.network.use-dht.tip=DHT (Distributed Hash Table) позволяет участникам находить IP-адреса друг друга, когда они меняются. Это улучшает связность при роуминге. ## Remote settings.remote.title=Удаленный доступ settings.remote.username=Имя пользователя settings.remote.password=Пароль settings.remote.note=Установка пустого пароля отключает аутентификацию. settings.remote.enabled.tip=Включить удаленный доступ. Этот экземпляр затем может быть доступен либо из другого экземпляра Xeres, либо из клиента Android. settings.remote.upnp-set=Установить с UPNP settings.remote.upnp-set.tip=Установить удаленный порт с UPNP, сделав его доступным из WAN. settings.remote.restart=Вам необходимо перезапустить Xeres, чтобы изменения удаленного доступа вступили в силу. Выйти сейчас? settings.remote.view-api=Просматривать API ## Transfer settings.transfer.select-incoming=Выбрать папку загрузок settings.transfer.incoming=Папка загрузок ## Notifications settings.notifications.show-connections=Показывать подключения settings.notifications.show-connections.tip=Показывает, когда устанавливается соединение с другом. settings.notifications.show-broadcasts=Показывать трансляции settings.notifications.show-broadcasts.tip=Показывает трансляции сообщений, отправленные друзьями. settings.notifications.show-discovery=Показывать обнаружение settings.notifications.show-discovery.tip=Показывает, когда в локальной сети появляется клиент с включенным обнаружением по широковещательной рассылке. ## Sound settings.sound.message=Получено сообщение settings.sound.message.tip=Воспроизводит звук, когда получено личное сообщение и окно неактивно. settings.sound.highlight=Упоминание settings.sound.highlight.tip=Воспроизводит звук, когда кто-то обращается к вам в комнате чата. settings.sound.friend=Друг подключился settings.sound.friend.tip=Воспроизводит звук, когда друг подключается к вам. settings.sound.download=Загрузка завершена settings.sound.download.tip=Воспроизводит звук, когда загрузка завершена. settings.sound.ringing=Запрос settings.sound.ringing.tip=Воспроизводит звук при приеме или совершении звонка. # Share share.window-title=Общие ресурсы share.select-directory=Выберите каталог для общего доступа share.remove=Удалить общий ресурс share.error.empty-name=Имя общего ресурса не может быть пустым. Установите уникальное имя. share.error.empty-path=Путь общего ресурса не может быть пустым. Установите путь общего ресурса. share.error.not-unique=Имя общего ресурса уже существует. Каждое имя общего ресурса должно быть уникальным. share.list.directory=Общий каталог share.list.visible-name=Видимое имя share.list.searchable=Доступно для поиска share.list.browsable=Доступно для просмотра share.create=Создать новый общий ресурс share.apply=Применить и закрыть # Tray tray.open=Открыть {0} tray.peers=Участники tray.status=Статус # EditorView editor.hyperlink.enter=Введите URL editor.action.undo=Отменить (Ctrl+Z) editor.action.redo=Повторить (Ctrl+Shift+Z) editor.action.bold=Жирный (Ctrl+B) editor.action.italic=Курсив (Ctrl+I) editor.action.hyperlink=Ссылка (Ctrl+L) editor.action.quote=Цитата (Ctrl+Q) editor.action.code=Код (Ctrl+K) editor.action.unordered-list=Маркированный список (Ctrl+U) editor.action.ordered-list=Нумерованный список (Ctrl+Shift+U) editor.action.header=Заголовок (Ctrl+1) editor.action.preview=Предпросмотр сообщения (F12) # Search / Download / Uploads search.main.search=Поиск search.main.downloads=Загрузки search.main.uploads=Отправки search.main.trends=Тренды search.input.prompt=Введите условия поиска search.input.search.tip=Ввод нескольких условий поиска будет искать файлы, содержащие все их. Используйте \" вокруг условий поиска для точного совпадения. search.searching=Поиск... trends.none=Пока нет трендов trends.list.terms=Термины trends.list.from=От trends.list.time=Время download-view.list.none=Нет загрузок download-view.list.state=Состояние download-view.list.progress=Прогресс download-view.list.total-size=Общий размер download-view.show-in-folder=Показать в папке download-view.open-error=Не удалось открыть: download-view.show-error=Не удалось показать в проводнике: download-add.window-title=Добавить загрузку download-add.bytes={0,number,integer} байт upload-view.none=Нет отправляемых файлов file-result.column.type=Тип # StatisticsTurtle statistics.window-title=Статистика statistics.elapsed-time=Прошедшее время (секунды) statistics.turtle.data-in=Входящие данные statistics.turtle.data-in.tip=Полученные данные контента (загрузки) statistics.turtle.data-out=Исходящие данные statistics.turtle.data-out.tip=Отправленные данные контента (отправки) statistics.turtle.data-forward=Переданные данные statistics.turtle.data-forward.tip=Данные контента, переданные другим участникам statistics.turtle.tunnel-in=Входящие туннельные запросы statistics.turtle.tunnel-in.tip=Входящие запросы на туннелирование statistics.turtle.tunnel-out=Исходящие туннельные запросы statistics.turtle.tunnel-out.tip=Переданные запросы на туннелирование и собственные запросы statistics.turtle.search-in=Входящие поисковые запросы statistics.turtle.search-in.tip=Входящие поисковые запросы statistics.turtle.search-out=Исходящие поисковые запросы statistics.turtle.search-out.tip=Переданные поисковые запросы и наши собственные запросы statistics.turtle.bandwidth=Пропускная способность statistics.turtle.speed=Скорость (КБ/с) statistics.turtle.tip=На графике показана статистика turtle маршрутизатора. Она состоит из:\nПоисковые запросы: поиск файлов (название, размер и т.д.).\nТуннельные запросы: настройка туннелей между удаленными участниками для подготовки передачи файлов.\nЗапросы данных: данные, которые передаются внутри туннелей.\nБольшинство запросов и данных, не предназначенных для нашего собственного узла, передаются другим участникам в пределах заданной вероятности, которая варьируется в зависимости от расстояния. statistics.rtt.rtt=Время кругового пути statistics.rtt.time=RTT (миллисекунды) statistics.rtt.tip=RTT (Round Trip Time) - это время, необходимое для отправки сообщения получателю и получения ответа обратно отправителю. Это дает представление о сетевой задержке между участниками.\nОжидайте проблем, если RTT слишком высок (более нескольких секунд). statistics.data-counter.title=Использование данных statistics.data-counter.data=Данные (КБ) statistics.data-counter.tip=На этом графике показано количество данных, поступающих от участников и отправляемых им. statistics.data-counter.peers=Участники statistics.turtle=Turtle statistics.rtt=RTT statistics.data-usage=Использование данных # ContactView contact-view.profile-delete.confirm=Это удалит и отключит вас от профиля {0}. Вы действительно хотите это сделать? contact-view.avatar-delete.confirm=Вы действительно хотите удалить изображение вашего аватара? contact-view.location.last-connected.now=Сейчас contact-view.location.last-connected.never=Никогда contact-view.information.linked-to-profile=Идентификатор привязан к профилю contact-view.information.profile=Профиль contact-view.information.identity=Идентификатор contact-view.information.type=Тип contact-view.information.created=Создан contact-view.information.updated=Обновлен contact-view.information.created-unknown=неизвестно contact-view.information.key-information-with-length=Версия: {0}\nАлгоритм: {1}\nДлина: {2} бит\nХеш подписи: {3} contact-view.information.key-information=Версия: {0}\nАлгоритм: {1}\nХеш подписи: {2} contact-view.open.identity-not-found=Идентификатор не найден contact-view.open.profile-not-found=Профиль не найден contact-view.information.location.id=ID местоположения: contact-view.information.location.version=Версия: contact-view.search.prompt=Поиск людей contact-view.search.show-all=Показать все контакты contact-view.search.no-contacts=Нет контактов contact-view.badge.own=Свой contact-view.badge.own.tip=Это вы сами. contact-view.badge.partial=Частичный contact-view.badge.partial.tip=Частичный контакт еще не подкреплен полным профилем. Необходимо подключиться к нему хотя бы один раз, затем он будет проверен и, в случае успеха, повышен до полного профиля. contact-view.badge.accepted=Принят contact-view.badge.accepted.tip=Этот контакт принят для входящих подключений, и исходящие подключения к нему также attempted. contact-view.badge.not-validated=Еще не проверен contact-view.badge.not-validated.tip=Контакт еще не проверен. Его подпись профиля будет проверена в ближайшее время и, в случае успеха, будет помечен как действительный. Если неудачно, он будет удален (но может быть передан снова, если так, попробуйте сообщить его владельцу о проблеме). contact-view.action.chat=Чат contact-view.action.distant-chat=Удаленный чат contact-view.action.connect=Попытаться подключиться contact-view.information.locations=Местоположения contact-view.column.last-connected=Последнее подключение contact-view.chat.start=Начать прямой чат contact-view.distant-chat.start=Начать удаленный чат # ImageSelectorView image-selector-view.change-image=Изменить изображение... image-selector-view.change-image-short=Изменить image-selector-view.add-image=Добавить изображение... # VoIP voip.window-title=Звонок voip.action.message=Сообщение voip.action.message.tip=Отправить прямое сообщение чата пользователю voip.action.recall=Позвонить снова voip.action.recall.tip=Перезвонить пользователю voip.action.close.tip=Закрыть окно voip.action.answer=Ответить voip.action.reject=Отклонить voip.action.hangup=Завершить звонок voip.action.window-quit=Вы уверены, что хотите прервать звонок? voip.status.incoming=Входящий звонок... voip.status.calling=Звонок... voip.status.ongoing=В разговоре voip.status.ended=Звонок завершен # Update update.latest-already=У вас уже установлена последняя версия. update.new-version=Доступна новая версия ({0}). Скачать, проверить и установить? update.new-version-auto=Доступна новая версия ({0}). update.download-failure=Не удалось скачать URL и/или URL подписи update.download-file=Загрузка файла... update.download.title=Обновление Xeres update.download.verifying=Проверка файла... update.download.install=Установить update.download.install-ready=Готово к установке! update.download-verification-failed=Проверка не удалась! # Stickers stickers.instructions=Добавьте свои стикеры в {0}\n\nОдна папка на коллекцию стикеров, каждая содержит PNG или JPEG. # ChatCommands chat-command.code=Отправить текст как блок кода chat-command.coin=Подбросить монетку chat-command.me=Отправить сообщение-действие от третьего лица chat-command.pre=Отправить текст как предформатированный chat-command.quote=Отправить текст как цитату chat-command.random=Отправить случайное число от 1 до 10 chat-command-send=Отправить {0} # Misc uri.malicious-link=Предупреждение! Это вредоносная ссылка, нажатие приведет вас к: {0} uri.unsafe-link=Внимание! Эта ссылка может быть небезопасной. Переход по ней приведёт вас на: {0} uri.malicious-link.confirm=Предупреждение! Это вредоносная ссылка, она приведет вас к {0}. Вы действительно хотите перейти? uri.unsafe-link.confirm=Внимание! Эта ссылка может быть небезопасной. Она ведёт на {0}. Вы знаете, что это такое, и доверяете этому? content-image.exit=Нажмите ESC или щелкните для выхода websocket.disconnected=Соединение WebSocket разорвано. Чат недоступен. Переподключиться? # TrustConverter trust-converter.nobody=Никто trust-converter.everybody=Все trust-converter.marginal=Частичные доверенные лица trust-converter.full=Полные доверенные лица trust-converter.ultimate=Только я # Byte units byte-unit.invalid=недействительно byte-unit.bytes=байт byte-unit.kb=КБ byte-unit.mb=МБ byte-unit.gb=ГБ byte-unit.tb=ТБ byte-unit.pb=ПБ byte-unit.eb=ЭБ # Help help.back=Назад по истории help.forward=Вперед по истории help.home=На главную # Enums (beware of the key naming which must be the same as the class!) ## Trust # suppress inspection "UnusedProperty" enum.trust.unknown=Неизвестно # suppress inspection "UnusedProperty" enum.trust.never=Никогда # suppress inspection "UnusedProperty" enum.trust.marginal=Частичное # suppress inspection "UnusedProperty" enum.trust.full=Полное # suppress inspection "UnusedProperty" enum.trust.ultimate=Абсолютное ## Availability # suppress inspection "UnusedProperty" enum.availability.available=Доступен # suppress inspection "UnusedProperty" enum.availability.busy=Занят # suppress inspection "UnusedProperty" enum.availability.away=Отошел # suppress inspection "UnusedProperty" enum.availability.offline=Не в сети ## RoomType # suppress inspection "UnusedProperty" enum.room-type.private=Приватная # suppress inspection "UnusedProperty" enum.room-type.public=Публичная ## FileType # suppress inspection "UnusedProperty" enum.file-type.any=Любой # suppress inspection "UnusedProperty" enum.file-type.audio=Аудио # suppress inspection "UnusedProperty" enum.file-type.archive=Архив # suppress inspection "UnusedProperty" enum.file-type.document=Документ # suppress inspection "UnusedProperty" enum.file-type.picture=Изображение # suppress inspection "UnusedProperty" enum.file-type.program=Программа # suppress inspection "UnusedProperty" enum.file-type.video=Видео # suppress inspection "UnusedProperty" enum.file-type.subtitles=Субтитры # suppress inspection "UnusedProperty" enum.file-type.collection=Коллекция # suppress inspection "UnusedProperty" enum.file-type.directory=Каталог ## FileProgressDisplay State # suppress inspection "UnusedProperty" enum.file-progress-display.state.searching=Поиск # suppress inspection "UnusedProperty" enum.file-progress-display.state.transferring=Передача # suppress inspection "UnusedProperty" enum.file-progress-display.state.removing=Удаление # suppress inspection "UnusedProperty" enum.file-progress-display.state.done=Готово ## FileAttachment State # suppress inspection "UnusedProperty" enum.channel-file.state.hashing=Хеширование # suppress inspection "UnusedProperty" enum.channel-file.state.done=Готово ## Country # suppress inspection "UnusedProperty" enum.country.af=Афганистан # suppress inspection "UnusedProperty" enum.country.al=Албания # suppress inspection "UnusedProperty" enum.country.dz=Алжир # suppress inspection "UnusedProperty" enum.country.as=Американское Самоа # suppress inspection "UnusedProperty" enum.country.ad=Андорра # suppress inspection "UnusedProperty" enum.country.ao=Ангола # suppress inspection "UnusedProperty" enum.country.ai=Ангилья # suppress inspection "UnusedProperty" enum.country.aq=Антарктида # suppress inspection "UnusedProperty" enum.country.ag=Антигуа и Барбуда # suppress inspection "UnusedProperty" enum.country.ar=Аргентина # suppress inspection "UnusedProperty" enum.country.am=Армения # suppress inspection "UnusedProperty" enum.country.aw=Аруба # suppress inspection "UnusedProperty" enum.country.au=Австралия # suppress inspection "UnusedProperty" enum.country.at=Австрия # suppress inspection "UnusedProperty" enum.country.az=Азербайджан # suppress inspection "UnusedProperty" enum.country.bs=Багамы # suppress inspection "UnusedProperty" enum.country.bh=Бахрейн # suppress inspection "UnusedProperty" enum.country.bd=Бангладеш # suppress inspection "UnusedProperty" enum.country.bb=Барбадос # suppress inspection "UnusedProperty" enum.country.by=Беларусь # suppress inspection "UnusedProperty" enum.country.be=Бельгия # suppress inspection "UnusedProperty" enum.country.bz=Белиз # suppress inspection "UnusedProperty" enum.country.bj=Бенин # suppress inspection "UnusedProperty" enum.country.bm=Бермуды # suppress inspection "UnusedProperty" enum.country.bt=Бутан # suppress inspection "UnusedProperty" enum.country.bo=Боливия # suppress inspection "UnusedProperty" enum.country.ba=Босния и Герцеговина # suppress inspection "UnusedProperty" enum.country.bw=Ботсвана # suppress inspection "UnusedProperty" enum.country.bv=Остров Буве # suppress inspection "UnusedProperty" enum.country.br=Бразилия # suppress inspection "UnusedProperty" enum.country.io=Британская территория в Индийском океане # suppress inspection "UnusedProperty" enum.country.bn=Бруней # suppress inspection "UnusedProperty" enum.country.bg=Болгария # suppress inspection "UnusedProperty" enum.country.bf=Буркина-Фасо # suppress inspection "UnusedProperty" enum.country.bi=Бурунди # suppress inspection "UnusedProperty" enum.country.kh=Камбоджа # suppress inspection "UnusedProperty" enum.country.cm=Камерун # suppress inspection "UnusedProperty" enum.country.ca=Канада # suppress inspection "UnusedProperty" enum.country.cv=Кабо-Верде # suppress inspection "UnusedProperty" enum.country.ky=Каймановы острова # suppress inspection "UnusedProperty" enum.country.cf=Центральноафриканская Республика # suppress inspection "UnusedProperty" enum.country.td=Чад # suppress inspection "UnusedProperty" enum.country.cl=Чили # suppress inspection "UnusedProperty" enum.country.cn=Китай # suppress inspection "UnusedProperty" enum.country.cx=Остров Рождества # suppress inspection "UnusedProperty" enum.country.cc=Кокосовые (Килинг) острова # suppress inspection "UnusedProperty" enum.country.co=Колумбия # suppress inspection "UnusedProperty" enum.country.km=Коморы # suppress inspection "UnusedProperty" enum.country.cg=Конго # suppress inspection "UnusedProperty" enum.country.cd=Демократическая Республика Конго # suppress inspection "UnusedProperty" enum.country.ck=Острова Кука # suppress inspection "UnusedProperty" enum.country.cr=Коста-Рика # suppress inspection "UnusedProperty" enum.country.ci=Кот-д'Ивуар # suppress inspection "UnusedProperty" enum.country.hr=Хорватия # suppress inspection "UnusedProperty" enum.country.cu=Куба # suppress inspection "UnusedProperty" enum.country.cy=Кипр # suppress inspection "UnusedProperty" enum.country.cz=Чехия # suppress inspection "UnusedProperty" enum.country.dk=Дания # suppress inspection "UnusedProperty" enum.country.dj=Джибути # suppress inspection "UnusedProperty" enum.country.dm=Доминика # suppress inspection "UnusedProperty" enum.country.do=Доминиканская Республика # suppress inspection "UnusedProperty" enum.country.ec=Эквадор # suppress inspection "UnusedProperty" enum.country.eg=Египет # suppress inspection "UnusedProperty" enum.country.sv=Сальвадор # suppress inspection "UnusedProperty" enum.country.gq=Экваториальная Гвинея # suppress inspection "UnusedProperty" enum.country.er=Эритрея # suppress inspection "UnusedProperty" enum.country.ee=Эстония # suppress inspection "UnusedProperty" enum.country.et=Эфиопия # suppress inspection "UnusedProperty" enum.country.fk=Фолклендские острова (Мальвинские) # suppress inspection "UnusedProperty" enum.country.fo=Фарерские острова # suppress inspection "UnusedProperty" enum.country.fj=Фиджи # suppress inspection "UnusedProperty" enum.country.fi=Финляндия # suppress inspection "UnusedProperty" enum.country.fr=Франция # suppress inspection "UnusedProperty" enum.country.gf=Французская Гвиана # suppress inspection "UnusedProperty" enum.country.pf=Французская Полинезия # suppress inspection "UnusedProperty" enum.country.tf=Французские Южные территории # suppress inspection "UnusedProperty" enum.country.ga=Габон # suppress inspection "UnusedProperty" enum.country.gm=Гамбия # suppress inspection "UnusedProperty" enum.country.ge=Грузия # suppress inspection "UnusedProperty" enum.country.de=Германия # suppress inspection "UnusedProperty" enum.country.gh=Гана # suppress inspection "UnusedProperty" enum.country.gi=Гибралтар # suppress inspection "UnusedProperty" enum.country.gr=Греция # suppress inspection "UnusedProperty" enum.country.gl=Гренландия # suppress inspection "UnusedProperty" enum.country.gd=Гренада # suppress inspection "UnusedProperty" enum.country.gp=Гваделупа # suppress inspection "UnusedProperty" enum.country.gu=Гуам # suppress inspection "UnusedProperty" enum.country.gt=Гватемала # suppress inspection "UnusedProperty" enum.country.gg=Гернси # suppress inspection "UnusedProperty" enum.country.gn=Гвинея # suppress inspection "UnusedProperty" enum.country.gw=Гвинея-Бисау # suppress inspection "UnusedProperty" enum.country.gy=Гайана # suppress inspection "UnusedProperty" enum.country.ht=Гаити # suppress inspection "UnusedProperty" enum.country.hm=Остров Херд и острова Макдональд # suppress inspection "UnusedProperty" enum.country.va=Святой Престол (Ватикан) # suppress inspection "UnusedProperty" enum.country.hn=Гондурас # suppress inspection "UnusedProperty" enum.country.hk=Гонконг # suppress inspection "UnusedProperty" enum.country.hu=Венгрия # suppress inspection "UnusedProperty" enum.country.is=Исландия # suppress inspection "UnusedProperty" enum.country.in=Индия # suppress inspection "UnusedProperty" enum.country.id=Индонезия # suppress inspection "UnusedProperty" enum.country.ir=Иран # suppress inspection "UnusedProperty" enum.country.iq=Ирак # suppress inspection "UnusedProperty" enum.country.ie=Ирландия # suppress inspection "UnusedProperty" enum.country.im=Остров Мэн # suppress inspection "UnusedProperty" enum.country.il=Израиль # suppress inspection "UnusedProperty" enum.country.it=Италия # suppress inspection "UnusedProperty" enum.country.jm=Ямайка # suppress inspection "UnusedProperty" enum.country.jp=Япония # suppress inspection "UnusedProperty" enum.country.je=Джерси # suppress inspection "UnusedProperty" enum.country.jo=Иордания # suppress inspection "UnusedProperty" enum.country.kz=Казахстан # suppress inspection "UnusedProperty" enum.country.ke=Кения # suppress inspection "UnusedProperty" enum.country.ki=Кирибати # suppress inspection "UnusedProperty" enum.country.kp=Корейская Народно-Демократическая Республика # suppress inspection "UnusedProperty" enum.country.kr=Республика Корея # suppress inspection "UnusedProperty" enum.country.kw=Кувейт # suppress inspection "UnusedProperty" enum.country.kg=Кыргызстан # suppress inspection "UnusedProperty" enum.country.la=Лаос # suppress inspection "UnusedProperty" enum.country.lv=Латвия # suppress inspection "UnusedProperty" enum.country.lb=Ливан # suppress inspection "UnusedProperty" enum.country.ls=Лесото # suppress inspection "UnusedProperty" enum.country.lr=Либерия # suppress inspection "UnusedProperty" enum.country.ly=Ливия # suppress inspection "UnusedProperty" enum.country.li=Лихтенштейн # suppress inspection "UnusedProperty" enum.country.lt=Литва # suppress inspection "UnusedProperty" enum.country.lu=Люксембург # suppress inspection "UnusedProperty" enum.country.mo=Макао # suppress inspection "UnusedProperty" enum.country.mk=Северная Македония # suppress inspection "UnusedProperty" enum.country.mg=Мадагаскар # suppress inspection "UnusedProperty" enum.country.mw=Малави # suppress inspection "UnusedProperty" enum.country.my=Малайзия # suppress inspection "UnusedProperty" enum.country.mv=Мальдивы # suppress inspection "UnusedProperty" enum.country.ml=Мали # suppress inspection "UnusedProperty" enum.country.mt=Мальта # suppress inspection "UnusedProperty" enum.country.mh=Маршалловы Острова # suppress inspection "UnusedProperty" enum.country.mq=Мартиника # suppress inspection "UnusedProperty" enum.country.mr=Мавритания # suppress inspection "UnusedProperty" enum.country.mu=Маврикий # suppress inspection "UnusedProperty" enum.country.yt=Майотта # suppress inspection "UnusedProperty" enum.country.mx=Мексика # suppress inspection "UnusedProperty" enum.country.fm=Микронезия # suppress inspection "UnusedProperty" enum.country.md=Молдова # suppress inspection "UnusedProperty" enum.country.mc=Монако # suppress inspection "UnusedProperty" enum.country.mn=Монголия # suppress inspection "UnusedProperty" enum.country.me=Черногория # suppress inspection "UnusedProperty" enum.country.ms=Монтсеррат # suppress inspection "UnusedProperty" enum.country.ma=Марокко # suppress inspection "UnusedProperty" enum.country.mz=Мозамбик # suppress inspection "UnusedProperty" enum.country.mm=Мьянма # suppress inspection "UnusedProperty" enum.country.na=Намибия # suppress inspection "UnusedProperty" enum.country.nr=Науру # suppress inspection "UnusedProperty" enum.country.np=Непал # suppress inspection "UnusedProperty" enum.country.nl=Нидерланды # suppress inspection "UnusedProperty" enum.country.an=Нидерландские Антильские острова # suppress inspection "UnusedProperty" enum.country.nc=Новая Каледония # suppress inspection "UnusedProperty" enum.country.nz=Новая Зеландия # suppress inspection "UnusedProperty" enum.country.ni=Никарагуа # suppress inspection "UnusedProperty" enum.country.ne=Нигер # suppress inspection "UnusedProperty" enum.country.ng=Нигерия # suppress inspection "UnusedProperty" enum.country.nu=Ниуэ # suppress inspection "UnusedProperty" enum.country.nf=Остров Норфолк # suppress inspection "UnusedProperty" enum.country.mp=Северные Марианские острова # suppress inspection "UnusedProperty" enum.country.no=Норвегия # suppress inspection "UnusedProperty" enum.country.om=Оман # suppress inspection "UnusedProperty" enum.country.pk=Пакистан # suppress inspection "UnusedProperty" enum.country.pw=Палау # suppress inspection "UnusedProperty" enum.country.ps=Палестина # suppress inspection "UnusedProperty" enum.country.pa=Панама # suppress inspection "UnusedProperty" enum.country.pg=Папуа — Новая Гвинея # suppress inspection "UnusedProperty" enum.country.py=Парагвай # suppress inspection "UnusedProperty" enum.country.pe=Перу # suppress inspection "UnusedProperty" enum.country.ph=Филиппины # suppress inspection "UnusedProperty" enum.country.pn=Острова Питкэрн # suppress inspection "UnusedProperty" enum.country.pl=Польша # suppress inspection "UnusedProperty" enum.country.pt=Португалия # suppress inspection "UnusedProperty" enum.country.pr=Пуэрто-Рико # suppress inspection "UnusedProperty" enum.country.qa=Катар # suppress inspection "UnusedProperty" enum.country.re=Реюньон # suppress inspection "UnusedProperty" enum.country.ro=Румыния # suppress inspection "UnusedProperty" enum.country.ru=Россия # suppress inspection "UnusedProperty" enum.country.rw=Руанда # suppress inspection "UnusedProperty" enum.country.sh=Острова Святой Елены, Вознесения и Тристан-да-Кунья # suppress inspection "UnusedProperty" enum.country.kn=Сент-Китс и Невис # suppress inspection "UnusedProperty" enum.country.lc=Сент-Люсия # suppress inspection "UnusedProperty" enum.country.pm=Сен-Пьер и Микелон # suppress inspection "UnusedProperty" enum.country.vc=Сент-Винсент и Гренадины # suppress inspection "UnusedProperty" enum.country.ws=Самоа # suppress inspection "UnusedProperty" enum.country.sm=Сан-Марино # suppress inspection "UnusedProperty" enum.country.st=Сан-Томе и Принсипи # suppress inspection "UnusedProperty" enum.country.sa=Саудовская Аравия # suppress inspection "UnusedProperty" enum.country.sn=Сенегал # suppress inspection "UnusedProperty" enum.country.rs=Сербия # suppress inspection "UnusedProperty" enum.country.sc=Сейшельские Острова # suppress inspection "UnusedProperty" enum.country.sl=Сьерра-Леоне # suppress inspection "UnusedProperty" enum.country.sg=Сингапур # suppress inspection "UnusedProperty" enum.country.sk=Словакия # suppress inspection "UnusedProperty" enum.country.si=Словения # suppress inspection "UnusedProperty" enum.country.sb=Соломоновы Острова # suppress inspection "UnusedProperty" enum.country.so=Сомали # suppress inspection "UnusedProperty" enum.country.za=Южная Африка # suppress inspection "UnusedProperty" enum.country.gs=Южная Георгия и Южные Сандвичевы острова # suppress inspection "UnusedProperty" enum.country.ss=Южный Судан # suppress inspection "UnusedProperty" enum.country.es=Испания # suppress inspection "UnusedProperty" enum.country.lk=Шри-Ланка # suppress inspection "UnusedProperty" enum.country.sd=Судан # suppress inspection "UnusedProperty" enum.country.sr=Суринам # suppress inspection "UnusedProperty" enum.country.sj=Шпицберген и Ян-Майен # suppress inspection "UnusedProperty" enum.country.sz=Эсватини # suppress inspection "UnusedProperty" enum.country.se=Швеция # suppress inspection "UnusedProperty" enum.country.ch=Швейцария # suppress inspection "UnusedProperty" enum.country.sy=Сирия # suppress inspection "UnusedProperty" enum.country.tw=Тайвань # suppress inspection "UnusedProperty" enum.country.tj=Таджикистан # suppress inspection "UnusedProperty" enum.country.tz=Танзания # suppress inspection "UnusedProperty" enum.country.th=Таиланд # suppress inspection "UnusedProperty" enum.country.tl=Восточный Тимор # suppress inspection "UnusedProperty" enum.country.tg=Того # suppress inspection "UnusedProperty" enum.country.tk=Токелау # suppress inspection "UnusedProperty" enum.country.to=Тонга # suppress inspection "UnusedProperty" enum.country.tt=Тринидад и Тобаго # suppress inspection "UnusedProperty" enum.country.tn=Тунис # suppress inspection "UnusedProperty" enum.country.tr=Турция # suppress inspection "UnusedProperty" enum.country.tm=Туркменистан # suppress inspection "UnusedProperty" enum.country.tc=Острова Теркс и Кайкос # suppress inspection "UnusedProperty" enum.country.tv=Тувалу # suppress inspection "UnusedProperty" enum.country.ug=Уганда # suppress inspection "UnusedProperty" enum.country.ua=Украина # suppress inspection "UnusedProperty" enum.country.ae=Объединенные Арабские Эмираты # suppress inspection "UnusedProperty" enum.country.gb=Великобритания # suppress inspection "UnusedProperty" enum.country.us=Соединенные Штаты # suppress inspection "UnusedProperty" enum.country.um=Внешние малые острова США # suppress inspection "UnusedProperty" enum.country.uy=Уругвай # suppress inspection "UnusedProperty" enum.country.uz=Узбекистан # suppress inspection "UnusedProperty" enum.country.vu=Вануату # suppress inspection "UnusedProperty" enum.country.ve=Венесуэла # suppress inspection "UnusedProperty" enum.country.vn=Вьетнам # suppress inspection "UnusedProperty" enum.country.vg=Виргинские острова (Британские) # suppress inspection "UnusedProperty" enum.country.vi=Виргинские острова (США) # suppress inspection "UnusedProperty" enum.country.wf=Уоллис и Футуна # suppress inspection "UnusedProperty" enum.country.eh=Западная Сахара # suppress inspection "UnusedProperty" enum.country.ye=Йемен # suppress inspection "UnusedProperty" enum.country.zm=Замбия # suppress inspection "UnusedProperty" enum.country.zw=Зимбабве # suppress inspection "UnusedProperty" enum.country.tor=Tor # suppress inspection "UnusedProperty" enum.country.i2p=I2P # suppress inspection "UnusedProperty" enum.country.lan=Локальная сеть ================================================ FILE: common/src/main/resources/i18n/messages_zh.properties ================================================ # # Copyright (c) 2019-2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # Common ok=确定 cancel=取消 close=关闭 send=发送 create=创建 remove=移除 download=下载 add=添加 open=打开 copy-link=复制链接地址 copy=复制 save-as=另存为... paste-id=粘贴自己的ID undo=撤销 redo=重做 cut=剪切 paste=粘贴 delete=删除 select-all=全选 deselect-all=取消全选 view-fullscreen=全屏查看 copy-image=复制图片 save-image-as=另存图片为... enabled=已启用 no-results=未找到结果 skip=跳过 name=名称 help=帮助 settings=设置 exit=退出 profile=个人资料 subscribed=已订阅 own=自己的 description=描述 subject=主题 hash=哈希值 size=大小 trust=信任 unknown-lc=未知 logo=标志 latest=最新 update=更新 edit=编辑 body=正文(可选) text=文本 image=图片 link=链接 mark-read-unread=标记为已读/未读 thumbnail=缩略图 posts-at-remote-nodes=远程节点上的帖子 last-activity=最近活动 state=状态 ip=IP port=端口 # File Requesters file-requester.profiles=个人资料文件 file-requester.xml=XML文件 file-requester.png=PNG文件 file-requester.sounds=声音文件 file-requester.select-sound-title=选择声音文件 file-requester.images=图像文件 file-requester.save-image-title=选择保存图像的位置 file-requester.error=文件 {0} 错误:{1} file-requester.add-files=选择要添加的文件 # Main ## Menu main.menu.add-peer=添加节点... main.menu.broadcast=广播... main.menu.shares=配置共享 main.menu.statistics=显示统计信息 main.menu.tools=工具 main.menu.tools.import-from-rs=从Retroshare导入好友... main.menu.tools.export=导出... main.menu.help.about=关于Xeres main.menu.help.documentation=文档 main.menu.help.report-bug=报告错误 ↗ main.menu.help.check-for-updates=检查更新... ↗ main.friends-import-successful=成功导入 {0} 个位置。 main.friends-import-errors=导入了 {0} 个位置,但 {1} 个有错误。 main.systray.peers=已连接 {0,number,integer} 个节点 ## Splash splash.status.database=正在加载数据库 splash.status.network=正在启动网络 ## Content main.home=主页 main.contacts=联系人 main.chats=聊天 main.forums=论坛 main.files=文件 main.boards=留言板 main.channels=频道 main.home.slogan=友谊与自由交汇之地 main.home.share-id=这是您的Xeres ID。请将其分享给其他人。 main.home.received-id=您是否收到了来自节点的ID? main.home.add-peer=添加节点 main.home.add-peer.tip=通过粘贴其ID来添加好友 main.home.need-help=需要帮助? main.home.online-help=在线帮助 ↗ main.home.online-help.tip=显示在线帮助 (Ctrl+F1) main.home.copy-id.tip=将Xeres ID复制到剪贴板 main.home.qrcode.tip=使用QR码传输您的ID。将其打印或用手机拍照,然后显示给网络摄像头。 main.select-avatar=选择头像图片 main.export-profile=选择保存个人资料的位置 main.import-friends=选择Retroshare好友文件 main.scanning=正在扫描 {0}... main.hashing=正在计算 {0} 的哈希值 main.scanning.tip=共享:{0},文件:{1} ## Status main.status.connections=连接数: main.status.nat.unknown=状态尚不可知。 main.status.nat.firewalled=客户端无法从互联网发起的连接访问。 main.status.nat.upnp=UPNP已激活,客户端可从互联网完全访问。 main.status.dht.disabled=DHT已禁用。 main.status.dht.initializing=DHT正在初始化。这可能需要一些时间。 main.status.dht.running=DHT工作正常,客户端的IP地址已向其节点公布。 main.status.dht.stats=节点数:{0,number,integer}\n接收数据包:{1,number,integer} ({2})\n发送数据包:{3,number,integer} ({4})\n密钥数:{5,number,integer}\n项目数:{6,number,integer} main.exit.confirm=确定要退出Xeres吗? # Account creation account.welcome=欢迎来到Xeres account.welcome.tip=您需要创建一个个人资料和一个位置。\n\n个人资料代表您自己,可以使用您的姓名或昵称,而位置是您当前使用的设备。\n\n您可以拥有多个位置,例如桌面电脑和笔记本电脑,它们都使用同一个个人资料(即您)。\n\n使用导入选项来导入您之前已创建的个人资料。\n\n所有内容始终存储在本地,因此请不要忘记备份您的数据。\n\n按F1键阅读内置文档,并记住将鼠标指针短暂悬停在用户界面元素上会显示其说明。 account.profile.prompt=个人资料名称 account.profile.tip=使用昵称或真实姓名。一个个人资料可以有多个位置。 account.location=位置 account.location.prompt=位置名称 account.location.tip=这是您在此设备上的Xeres实例。使用您设备的昵称或型号。 account.options=选项 account.generation.profile-keys=正在生成个人资料密钥... account.generation.location-keys-and-certificate=正在生成位置密钥和证书... account.generation.identity=正在生成身份... account.generation.profile-load=选择Xeres个人资料文件 (xeres_backup.xml)、Retroshare密钥环 (retroshare_secret_keyring.gpg) 或Retroshare个人资料 (*.asc) account.generation.import=导入... account.generation.import.tip=您可以导入3种类型的个人资料:\n\n从Xeres导出的个人资料 (xeres_backup.xml)。\n\nRetroshare密钥环 (retroshare_secret_keyring.gpg) 或从Retroshare导出的个人资料 (*.asc)。 account.generation.import.progress=正在导入个人资料... account.generation.import.confirm.title=Retroshare导入器 account.generation.import.confirm.prompt=输入Retroshare密码 account.generation.import.unknown=未知的文件格式 # Chat ## Common chat.notification.typing={0} 正在输入 ## Room common chat.room.id=ID chat.room.topic=主题 chat.room.security=安全性 chat.room.users=用户数 chat.room.info=主题:{0}\n用户:{1,number,integer}\n安全性:{2}\nID:{3} chat.room.none=[无] chat.room.private=私密 chat.room.public=公开 chat.room.signed-only=仅限已签名的ID chat.room.anonymous-allowed=允许匿名ID chat.room.user-info=名称:{0}\nID:{1} chat.room.user-menu=信息 chat.room.clear-history=您确定要清除历史记录吗? chat.room.copy-selection=复制选中内容 chat.room.clear-chat-history=清除聊天历史记录 ## Room create chat.room.create.window-title=创建聊天室 chat.room.create.name.prompt=简短且描述性的房间名称 chat.room.create.name.tip=房间的名称。请使用正确的大小写和空格。 chat.room.create.topic.prompt=房间的主题 chat.room.create.topic.tip=房间的描述,即其主题内容。 chat.room.create.visibility=可见性 chat.room.create.visibility.tip=公开房间对节点可见。\n私密房间不可见,仅通过邀请加入。 chat.room.create.security.checkbox=仅限已签名的身份 chat.room.create.security.tip=限制为已签名身份的房间更能抵抗垃圾信息,因为匿名身份无法加入。 chat.room.create.tooltip=创建一个新的聊天室 ## Room invite chat.room.invite.window-title=邀请节点加入当前聊天室 chat.room.invite.button=邀请 chat.room.invite.tip=邀请节点加入当前聊天室 chat.room.invite.request={0} 想要邀请您加入 {1} ({2}) chat.room.join=加入 chat.room.leave=离开 chat.room.not-found=未找到房间。可能是因为该房间在您任何已连接的好友处都不可用。 # Forums gxs-group.tree.popular=热门 gxs-group.tree.other=其他 gxs-group.tree.info=名称:{0}\nID:{1}\n远程消息:{2}\n远程活动:{3} gxs-group.tree.subscribe=订阅 gxs-group.tree.unsubscribe=取消订阅 forum.new-message.window-title=新消息 forum.create.window-title=创建论坛 forum.create.name.prompt=简短且描述性的论坛名称 forum.create.name.tip=论坛的名称。请使用正确的大小写和空格。 forum.create.description.prompt=论坛的主题 forum.create.description.tip=论坛的描述,即其主题内容。 forum.editor.name=论坛 forum.editor.name.prompt=论坛名称 forum.editor.thread.description=帖子主题 forum.editor.cancel=论坛消息尚未发送!确定要放弃此消息吗? forum.view.create.tip=创建新论坛 forum.view.header.author=作者 forum.view.header.date=日期 forum.view.new-message.tip=创建新消息 forum.view.group.not-found=未找到论坛。可能是因为它在您任何已连接的好友处都不可用。 forum.view.message.not-found=未找到消息。可能是因为消息太旧或发布者信誉过低。 forum.view.from=来自: forum.view.subject=主题: forum.view.reply=回复 forum.view.history=此选择器用于显示之前的消息版本。 # Boards board.create.window-title=创建留言板 board.create.name.prompt=简短且描述性的留言板名称 board.create.name.tip=留言板的名称。请使用正确的大小写和空格。 board.create.description.prompt=留言板的主题 board.create.description.tip=留言板的描述,即其主题内容。 board.select-logo=选择留言板图片 board.select-image=选择要发布的图片 board.view.create.tip=创建新留言板 board.view.group.not-found=未找到板块。这可能是因为您在已连接的好友中均未提供该板块。 board.new-message.window-title=新帖子 board.editor.name=留言板 board.editor.name.prompt=留言板名称 board.editor.thread.title=标题 board.editor.post.description=帖子标题 board.editor.cancel=留言板帖子尚未发送!确定要放弃此帖子吗? board.posted-by=发布者 board.on=发布于 # Channels channel.view.create.tip=创建新频道 channel.create.window-title=创建频道 channel.create.name.prompt=频道的简短且描述性名称 channel.create.name.tip=频道名称。请使用适当的大小写和空格。 channel.create.description.prompt=频道的内容主题 channel.create.description.tip=频道的描述,说明其内容主题。 channel.select-image=为频道帖子选择图片 channel.view.group.not-found=未找到频道。这可能是因为您在已连接的好友中均未提供该频道。 channel.select-logo=选择频道图片 channel.new-message.window-title=新建频道帖子 channel.editor.name=频道 channel.editor.name.prompt=频道的名称 channel.editor.thread.title=标题 channel.editor.post.description=帖子标题 channel.editor.cancel=频道帖子尚未发送!您确定要放弃此帖子吗? channel.clipboard.error=剪贴板中不包含文件链接。 channel.files=文件 channel.post=发布 channel.drag-drop=添加文件或拖放至此 channel.add-files=添加文件 channel.paste-links=粘贴链接 channel.remove-files=移除文件 # Add RSID rs-id.add.window-title=添加节点 rs-id.add.textarea.prompt=粘贴节点的ID rs-id.add.textarea.tip=ID是一串大约一百个base64字符。它编码了连接到一个节点所需的所有信息。 rs-id.add.details=节点详细信息 rs-id.add.name.tip=节点的名称,请确保您知道它是谁。 rs-id.add.profile=个人资料ID rs-id.add.profile.tip=唯一ID,用于验证您节点的个人资料是否正确。 rs-id.add.fingerprint=指纹 rs-id.add.fingerprint.tip=密码学校验和,用于认证您节点个人资料的真实性。 rs-id.add.location=位置ID rs-id.add.location.tip=位置标识符。一个个人资料可以有多个位置,每个都有唯一的ID。 rs-id.add.addresses=地址 rs-id.add.addresses.tip=用于连接的地址。将依次尝试所有这些地址,但您可以选择最佳地址以加快初始连接速度。\n以 .onion 结尾的地址需要使用Tor代理。\n以 .i2p 结尾的地址需要使用I2P代理。 rs-id.add.trust.tip=您对该节点的信任级别。\n未知:无意见。\n从不:无或极低信任度,最近在线遇到。\n边缘:较可信,熟人。\n完全:非常可信,好朋友。 rs-id.add.invalid=无效的ID rs-id.add.scan=使用摄像头扫描QR码。 # Broadcast broadcast.window-title=广播 broadcast.send.explanation=向所有当前连接的节点发送消息。 broadcast.send.warning-header=警告: broadcast.send.warning=请勿滥用此功能。仅用于紧急情况或特殊情况。 # Messaging messaging.prompt=输入消息 messaging.file-requester.send-picture=选择要内联发送的图片 messaging.file-requester.send-file=选择要发送的文件 messaging.send-picture=选择要内联发送的图片 messaging.send-sticker=发送贴纸 messaging.send.file=选择要发送的文件 messaging.action.call=进行直接通话 messaging.action.send-inline=内联发送图片 messaging.action.send-file=发送文件 messaging.warning.title=警告 messaging.warning.description=用户当前离线,无法接收消息。 messaging.tunneling=正在尝试建立隧道... messaging.closing-tunnel.confirm=关闭此窗口将结束远程聊天并丢弃所有未发送的消息。确定吗? # Profiles profiles.delete=删除个人资料 # About about.window-title=关于 {0} about.version=版本: about.title=关于 about.slogan=一个点对点、去中心化、安全的通信与共享应用程序 about.authors=作者 about.author-by=由 about.all-rights-reserved=保留所有权利 about.report-bugs=报告错误或提出改进建议。 about.website=网站 about.wiki=维基 about.source-code=源代码 about.thanks=致谢 about.license=许可证 about.additional-licenses=附加许可证 about.release=发布 about.profiles=个人资料: # QR Code qr-code.window-title=QR码 qr-code.print=打印... qr-code.save-as-png=另存为PNG qr-code.download-client=在 https://xeres.io 下载客户端 qr-code.camera.error=未检测到摄像头 # Camera camera.window-title=扫描QR码 # Settings ## Main settings.general=通用 settings.network=网络 settings.transfer=传输 settings.notifications=通知 settings.sound=声音 settings.remote=远程 settings.directory.no-remote=在远程模式下无法选择目录 ## General settings.general.theme=主题 settings.general.system=系统 settings.general.startup=系统启动时启动 settings.general.startup.tip=系统启动时自动运行,最小化到托盘。 settings.general.startup.not-available=不可用。要么操作系统不支持,要么您正在便携模式下运行。 settings.general.update-check=自动检查更新 settings.general.update-check.tip=每天自动检查GitHub以查看是否有新版本。 ## Network settings.network.hidden-services=隐藏服务 settings.network.tor-proxy=Tor Socks代理 settings.network.tor-proxy.prompt=Tor服务器 settings.network.tor-proxy.tip=Tor SOCKS v5 IP地址或主机名,如果在同一主机上运行,通常为127.0.0.1。 settings.network.tor-port.tip=Tor SOCKS v5端口,通常为9050。 settings.network.i2p-proxy=I2P Socks代理 settings.network.i2p-proxy.prompt=I2P服务器 settings.network.i2p-proxy.tip=I2P SOCKS v5 IP地址或主机名,如果在同一主机上运行,通常为127.0.0.1。 settings.network.i2p-port.tip=I2P SOCKS v5端口,通常为4447。 settings.network.use-upnp=使用UPNP settings.network.use-upnp.tip=UPNP(通用即插即用)允许自动在路由器中设置正确的传入端口。这提高了来自您节点的连接可靠性。 settings.network.external-ip-and-port=外部IP和端口 settings.network.external-ip-and-port.tip=您位置的外部IP地址和端口。这是您在互联网上的连接方式。 settings.network.use-broadcast-discovery=启用广播发现 settings.network.use-broadcast-discovery.tip=广播发现允许将您的IP和端口告知您局域网上的其他位置。这提高了来自您局域网上可能节点的连接可靠性。 settings.network.internal-ip-and-port=内部IP和端口 settings.network.internal-ip-and-port.tip=您位置的内部IP地址和端口。这是您在局域网(LAN)上的连接方式。 settings.network.use-dht=启用DHT settings.network.use-dht.tip=DHT(分布式哈希表)允许节点在IP地址更改时找到彼此。这提高了漫游时的连接性。 ## Remote settings.remote.title=远程访问 settings.remote.username=用户名 settings.remote.password=密码 settings.remote.note=设置空密码将禁用身份验证。 settings.remote.enabled.tip=启用远程访问。然后可以从另一个Xeres实例或Android客户端访问此实例。 settings.remote.upnp-set=使用UPNP设置 settings.remote.upnp-set.tip=使用UPNP设置远程端口,使其可从广域网访问。 settings.remote.restart=您需要重新启动Xeres才能使远程访问更改生效。现在退出吗? settings.remote.view-api=查看API ## Transfer settings.transfer.select-incoming=选择接收目录 settings.transfer.incoming=接收目录 ## Notifications settings.notifications.show-connections=显示连接 settings.notifications.show-connections.tip=显示与好友建立连接时。 settings.notifications.show-broadcasts=显示广播 settings.notifications.show-broadcasts.tip=显示好友发送的广播消息。 settings.notifications.show-discovery=显示发现 settings.notifications.show-discovery.tip=显示启用了广播发现的客户端出现在局域网上时。 ## Sound settings.sound.message=收到消息 settings.sound.message.tip=当收到私信且窗口处于非活动状态时播放声音。 settings.sound.highlight=被提及 settings.sound.highlight.tip=当有人在聊天室中提及您时播放声音。 settings.sound.friend=好友已连接 settings.sound.friend.tip=当好友连接到您时播放声音。 settings.sound.download=下载完成 settings.sound.download.tip=当下载完成时播放声音。 settings.sound.ringing=通话 settings.sound.ringing.tip=当收到或拨打电话时播放声音。 # Share share.window-title=共享 share.select-directory=选择要共享的目录 share.remove=移除共享 share.error.empty-name=共享名称不能为空。请设置一个唯一的名称。 share.error.empty-path=共享路径不能为空。请设置一个共享路径。 share.error.not-unique=共享名称已存在。每个共享名称必须是唯一的。 share.list.directory=共享目录 share.list.visible-name=可见名称 share.list.searchable=可搜索 share.list.browsable=可浏览 share.create=创建新共享 share.apply=应用并关闭 # Tray tray.open=打开 {0} tray.peers=节点 tray.status=状态 # EditorView editor.hyperlink.enter=输入URL editor.action.undo=撤销 (Ctrl+Z) editor.action.redo=重做 (Ctrl+Shift+Z) editor.action.bold=粗体 (Ctrl+B) editor.action.italic=斜体 (Ctrl+I) editor.action.hyperlink=链接 (Ctrl+L) editor.action.quote=引用 (Ctrl+Q) editor.action.code=代码 (Ctrl+K) editor.action.unordered-list=无序列表 (Ctrl+U) editor.action.ordered-list=有序列表 (Ctrl+Shift+U) editor.action.header=标题 (Ctrl+1) editor.action.preview=预览消息 (F12) # Search / Download / Uploads search.main.search=搜索 search.main.downloads=下载 search.main.uploads=上传 search.main.trends=趋势 search.input.prompt=输入搜索词 search.input.search.tip=输入多个搜索词将搜索包含所有这些词的文件。使用引号包围搜索词进行精确匹配。 search.searching=正在搜索... trends.none=尚无趋势 trends.list.terms=搜索词 trends.list.from=来自 trends.list.time=时间 download-view.list.none=无下载 download-view.list.state=状态 download-view.list.progress=进度 download-view.list.total-size=总大小 download-view.show-in-folder=在文件夹中显示 download-view.open-error=打开失败: download-view.show-error=在资源管理器中显示失败: download-add.window-title=添加下载 download-add.bytes={0,number,integer} 字节 upload-view.none=无正在上传的文件 file-result.column.type=类型 # StatisticsTurtle statistics.window-title=统计信息 statistics.elapsed-time=运行时间(秒) statistics.turtle.data-in=数据输入 statistics.turtle.data-in.tip=接收的内容数据(下载) statistics.turtle.data-out=数据输出 statistics.turtle.data-out.tip=发送的内容数据(上传) statistics.turtle.data-forward=数据转发 statistics.turtle.data-forward.tip=转发给其他节点的内容数据 statistics.turtle.tunnel-in=隧道请求输入 statistics.turtle.tunnel-in.tip=传入的隧道请求 statistics.turtle.tunnel-out=隧道请求输出 statistics.turtle.tunnel-out.tip=转发的隧道请求和自身请求 statistics.turtle.search-in=搜索请求输入 statistics.turtle.search-in.tip=传入的搜索请求 statistics.turtle.search-out=搜索请求输出 statistics.turtle.search-out.tip=转发的搜索请求和自身请求 statistics.turtle.bandwidth=带宽 statistics.turtle.speed=速度 (KB/s) statistics.turtle.tip=图表显示turtle路由器统计信息。包括:\n搜索请求:文件搜索(标题、大小等)。\n隧道请求:远程节点之间的隧道设置,为文件传输做准备。\n数据请求:在隧道内流动的数据。\n大多数不针对我们自身节点的请求和数据都会以一定的概率转发给其他节点,该概率随距离而变化。 statistics.rtt.rtt=往返时间 statistics.rtt.time=RTT (毫秒) statistics.rtt.tip=RTT(往返时间)是指消息发送到目的地并收到回复所需的时间。它反映了节点之间的网络延迟。\n如果RTT过高(超过几秒),可能会出现连接问题。 statistics.data-counter.title=数据使用量 statistics.data-counter.data=数据 (KB) statistics.data-counter.tip=此图表显示与节点之间传入和传出的数据量。 statistics.data-counter.peers=节点数 statistics.turtle=乌龟 statistics.rtt=RTT statistics.data-usage=数据使用量 # ContactView contact-view.profile-delete.confirm=这将移除并断开您与个人资料 {0} 的连接。确定吗? contact-view.avatar-delete.confirm=确定要移除您的头像图片吗? contact-view.location.last-connected.now=现在 contact-view.location.last-connected.never=从未 contact-view.information.linked-to-profile=身份链接到的个人资料 contact-view.information.profile=个人资料 contact-view.information.identity=身份 contact-view.information.type=类型 contact-view.information.created=创建于 contact-view.information.updated=更新于 contact-view.information.created-unknown=未知 contact-view.information.key-information-with-length=版本:{0}\n算法:{1}\n长度:{2} 位\n签名哈希:{3} contact-view.information.key-information=版本:{0}\n算法:{1}\n签名哈希:{2} contact-view.open.identity-not-found=未找到身份 contact-view.open.profile-not-found=未找到个人资料 contact-view.information.location.id=位置ID: contact-view.information.location.version=版本: contact-view.search.prompt=搜索联系人 contact-view.search.show-all=显示所有联系人 contact-view.search.no-contacts=无联系人 contact-view.badge.own=自己的 contact-view.badge.own.tip=这是您自己。 contact-view.badge.partial=部分的 contact-view.badge.partial.tip=部分联系人尚未有完整的个人资料支持。需要至少连接一次,然后会进行检查,如果成功,将升级为完整的个人资料。 contact-view.badge.accepted=已接受 contact-view.badge.accepted.tip=此联系人已被接受用于传入连接,并且也会尝试与其建立传出连接。 contact-view.badge.not-validated=尚未验证 contact-view.badge.not-validated.tip=联系人尚未验证。将很快验证其个人资料签名,如果成功,将标记为有效。如果失败,将被删除(但可能会再次传输,如果是,请尝试通知其所有者此问题)。 contact-view.action.chat=聊天 contact-view.action.distant-chat=远程聊天 contact-view.action.connect=尝试连接 contact-view.information.locations=位置 contact-view.column.last-connected=上次连接 contact-view.chat.start=开始直接聊天 contact-view.distant-chat.start=开始远程聊天 # ImageSelectorView image-selector-view.change-image=更改图片... image-selector-view.change-image-short=更改 image-selector-view.add-image=添加图片... # VoIP voip.window-title=通话 voip.action.message=消息 voip.action.message.tip=向用户发送直接聊天消息 voip.action.recall=再次呼叫 voip.action.recall.tip=回拨用户 voip.action.close.tip=关闭窗口 voip.action.answer=接听 voip.action.reject=拒绝 voip.action.hangup=挂断 voip.action.window-quit=确定要中止通话吗? voip.status.incoming=来电... voip.status.calling=正在呼叫... voip.status.ongoing=通话中 voip.status.ended=通话结束 # Update update.latest-already=您已安装最新版本。 update.new-version=有新版本可用 ({0})。是否下载、验证并安装? update.new-version-auto=有新版本可用 ({0})。 update.download-failure=无法下载URL和/或签名URL update.download-file=正在下载文件... update.download.title=Xeres更新程序 update.download.verifying=正在验证文件... update.download.install=安装 update.download.install-ready=准备就绪,可以安装! update.download-verification-failed=验证失败! # Stickers stickers.instructions=将您的贴纸添加到 {0}\n\n每个贴纸集合一个目录,每个目录包含PNG或JPEG文件。 # ChatCommands chat-command.code=将文本作为代码块发送 chat-command.coin=抛硬币 chat-command.me=以第三人称发送动作消息 chat-command.pre=将文本作为预格式化文本发送 chat-command.quote=将文本作为引用发送 chat-command.random=发送1到10之间的随机数 chat-command-send=发送 {0} # Misc uri.malicious-link=警告!这是恶意链接,点击将跳转到:{0} uri.unsafe-link=警告!此链接可能不安全,点击后将进入:{0} uri.malicious-link.confirm=警告!这是恶意链接,将跳转到 {0}。确定要继续吗? uri.unsafe-link.confirm=警告!此链接可能不安全,点击后将进入 {0}。您了解这是什么链接并且信任它吗? content-image.exit=按ESC键或点击退出 websocket.disconnected=WebSocket连接已断开。聊天不可用。重新连接? # TrustConverter trust-converter.nobody=无人 trust-converter.everybody=所有人 trust-converter.marginal=边缘信任人 trust-converter.full=完全信任人 trust-converter.ultimate=仅我自己 # Byte units byte-unit.invalid=无效 byte-unit.bytes=字节 byte-unit.kb=KB byte-unit.mb=MB byte-unit.gb=GB byte-unit.tb=TB byte-unit.pb=PB byte-unit.eb=EB # Help help.back=在历史记录中后退 help.forward=在历史记录中前进 help.home=转到首页部分 # Enums (beware of the key naming which must be the same as the class!) ## Trust # suppress inspection "UnusedProperty" enum.trust.unknown=未知 # suppress inspection "UnusedProperty" enum.trust.never=从不 # suppress inspection "UnusedProperty" enum.trust.marginal=边缘 # suppress inspection "UnusedProperty" enum.trust.full=完全 # suppress inspection "UnusedProperty" enum.trust.ultimate=绝对 ## Availability # suppress inspection "UnusedProperty" enum.availability.available=在线 # suppress inspection "UnusedProperty" enum.availability.busy=忙碌 # suppress inspection "UnusedProperty" enum.availability.away=离开 # suppress inspection "UnusedProperty" enum.availability.offline=离线 ## RoomType # suppress inspection "UnusedProperty" enum.room-type.private=私密 # suppress inspection "UnusedProperty" enum.room-type.public=公开 ## FileType # suppress inspection "UnusedProperty" enum.file-type.any=任意 # suppress inspection "UnusedProperty" enum.file-type.audio=音频 # suppress inspection "UnusedProperty" enum.file-type.archive=压缩包 # suppress inspection "UnusedProperty" enum.file-type.document=文档 # suppress inspection "UnusedProperty" enum.file-type.picture=图片 # suppress inspection "UnusedProperty" enum.file-type.program=程序 # suppress inspection "UnusedProperty" enum.file-type.video=视频 # suppress inspection "UnusedProperty" enum.file-type.subtitles=字幕 # suppress inspection "UnusedProperty" enum.file-type.collection=集合 # suppress inspection "UnusedProperty" enum.file-type.directory=目录 ## FileProgressDisplay State # suppress inspection "UnusedProperty" enum.file-progress-display.state.searching=搜索中 # suppress inspection "UnusedProperty" enum.file-progress-display.state.transferring=传输中 # suppress inspection "UnusedProperty" enum.file-progress-display.state.removing=移除中 # suppress inspection "UnusedProperty" enum.file-progress-display.state.done=完成 ## FileAttachment State # suppress inspection "UnusedProperty" enum.channel-file.state.hashing=正在哈希 # suppress inspection "UnusedProperty" enum.channel-file.state.done=已完成 ## Country # suppress inspection "UnusedProperty" enum.country.af=阿富汗 # suppress inspection "UnusedProperty" enum.country.al=阿尔巴尼亚 # suppress inspection "UnusedProperty" enum.country.dz=阿尔及利亚 # suppress inspection "UnusedProperty" enum.country.as=美属萨摩亚 # suppress inspection "UnusedProperty" enum.country.ad=安道尔 # suppress inspection "UnusedProperty" enum.country.ao=安哥拉 # suppress inspection "UnusedProperty" enum.country.ai=安圭拉 # suppress inspection "UnusedProperty" enum.country.aq=南极洲 # suppress inspection "UnusedProperty" enum.country.ag=安提瓜和巴布达 # suppress inspection "UnusedProperty" enum.country.ar=阿根廷 # suppress inspection "UnusedProperty" enum.country.am=亚美尼亚 # suppress inspection "UnusedProperty" enum.country.aw=阿鲁巴 # suppress inspection "UnusedProperty" enum.country.au=澳大利亚 # suppress inspection "UnusedProperty" enum.country.at=奥地利 # suppress inspection "UnusedProperty" enum.country.az=阿塞拜疆 # suppress inspection "UnusedProperty" enum.country.bs=巴哈马 # suppress inspection "UnusedProperty" enum.country.bh=巴林 # suppress inspection "UnusedProperty" enum.country.bd=孟加拉国 # suppress inspection "UnusedProperty" enum.country.bb=巴巴多斯 # suppress inspection "UnusedProperty" enum.country.by=白俄罗斯 # suppress inspection "UnusedProperty" enum.country.be=比利时 # suppress inspection "UnusedProperty" enum.country.bz=伯利兹 # suppress inspection "UnusedProperty" enum.country.bj=贝宁 # suppress inspection "UnusedProperty" enum.country.bm=百慕大 # suppress inspection "UnusedProperty" enum.country.bt=不丹 # suppress inspection "UnusedProperty" enum.country.bo=玻利维亚 # suppress inspection "UnusedProperty" enum.country.ba=波斯尼亚和黑塞哥维那 # suppress inspection "UnusedProperty" enum.country.bw=博茨瓦纳 # suppress inspection "UnusedProperty" enum.country.bv=布韦岛 # suppress inspection "UnusedProperty" enum.country.br=巴西 # suppress inspection "UnusedProperty" enum.country.io=英属印度洋领地 # suppress inspection "UnusedProperty" enum.country.bn=文莱 # suppress inspection "UnusedProperty" enum.country.bg=保加利亚 # suppress inspection "UnusedProperty" enum.country.bf=布基纳法索 # suppress inspection "UnusedProperty" enum.country.bi=布隆迪 # suppress inspection "UnusedProperty" enum.country.kh=柬埔寨 # suppress inspection "UnusedProperty" enum.country.cm=喀麦隆 # suppress inspection "UnusedProperty" enum.country.ca=加拿大 # suppress inspection "UnusedProperty" enum.country.cv=佛得角 # suppress inspection "UnusedProperty" enum.country.ky=开曼群岛 # suppress inspection "UnusedProperty" enum.country.cf=中非共和国 # suppress inspection "UnusedProperty" enum.country.td=乍得 # suppress inspection "UnusedProperty" enum.country.cl=智利 # suppress inspection "UnusedProperty" enum.country.cn=中国 # suppress inspection "UnusedProperty" enum.country.cx=圣诞岛 # suppress inspection "UnusedProperty" enum.country.cc=科科斯(基林)群岛 # suppress inspection "UnusedProperty" enum.country.co=哥伦比亚 # suppress inspection "UnusedProperty" enum.country.km=科摩罗 # suppress inspection "UnusedProperty" enum.country.cg=刚果 # suppress inspection "UnusedProperty" enum.country.cd=刚果民主共和国 # suppress inspection "UnusedProperty" enum.country.ck=库克群岛 # suppress inspection "UnusedProperty" enum.country.cr=哥斯达黎加 # suppress inspection "UnusedProperty" enum.country.ci=科特迪瓦 # suppress inspection "UnusedProperty" enum.country.hr=克罗地亚 # suppress inspection "UnusedProperty" enum.country.cu=古巴 # suppress inspection "UnusedProperty" enum.country.cy=塞浦路斯 # suppress inspection "UnusedProperty" enum.country.cz=捷克共和国 # suppress inspection "UnusedProperty" enum.country.dk=丹麦 # suppress inspection "UnusedProperty" enum.country.dj=吉布提 # suppress inspection "UnusedProperty" enum.country.dm=多米尼克 # suppress inspection "UnusedProperty" enum.country.do=多米尼加共和国 # suppress inspection "UnusedProperty" enum.country.ec=厄瓜多尔 # suppress inspection "UnusedProperty" enum.country.eg=埃及 # suppress inspection "UnusedProperty" enum.country.sv=萨尔瓦多 # suppress inspection "UnusedProperty" enum.country.gq=赤道几内亚 # suppress inspection "UnusedProperty" enum.country.er=厄立特里亚 # suppress inspection "UnusedProperty" enum.country.ee=爱沙尼亚 # suppress inspection "UnusedProperty" enum.country.et=埃塞俄比亚 # suppress inspection "UnusedProperty" enum.country.fk=福克兰群岛(马尔维纳斯群岛) # suppress inspection "UnusedProperty" enum.country.fo=法罗群岛 # suppress inspection "UnusedProperty" enum.country.fj=斐济 # suppress inspection "UnusedProperty" enum.country.fi=芬兰 # suppress inspection "UnusedProperty" enum.country.fr=法国 # suppress inspection "UnusedProperty" enum.country.gf=法属圭亚那 # suppress inspection "UnusedProperty" enum.country.pf=法属波利尼西亚 # suppress inspection "UnusedProperty" enum.country.tf=法属南方领地 # suppress inspection "UnusedProperty" enum.country.ga=加蓬 # suppress inspection "UnusedProperty" enum.country.gm=冈比亚 # suppress inspection "UnusedProperty" enum.country.ge=格鲁吉亚 # suppress inspection "UnusedProperty" enum.country.de=德国 # suppress inspection "UnusedProperty" enum.country.gh=加纳 # suppress inspection "UnusedProperty" enum.country.gi=直布罗陀 # suppress inspection "UnusedProperty" enum.country.gr=希腊 # suppress inspection "UnusedProperty" enum.country.gl=格陵兰 # suppress inspection "UnusedProperty" enum.country.gd=格林纳达 # suppress inspection "UnusedProperty" enum.country.gp=瓜德罗普 # suppress inspection "UnusedProperty" enum.country.gu=关岛 # suppress inspection "UnusedProperty" enum.country.gt=危地马拉 # suppress inspection "UnusedProperty" enum.country.gg=根西岛 # suppress inspection "UnusedProperty" enum.country.gn=几内亚 # suppress inspection "UnusedProperty" enum.country.gw=几内亚比绍 # suppress inspection "UnusedProperty" enum.country.gy=圭亚那 # suppress inspection "UnusedProperty" enum.country.ht=海地 # suppress inspection "UnusedProperty" enum.country.hm=赫德岛和麦克唐纳群岛 # suppress inspection "UnusedProperty" enum.country.va=梵蒂冈(梵蒂冈城国) # suppress inspection "UnusedProperty" enum.country.hn=洪都拉斯 # suppress inspection "UnusedProperty" enum.country.hk=中国香港特别行政区 # suppress inspection "UnusedProperty" enum.country.hu=匈牙利 # suppress inspection "UnusedProperty" enum.country.is=冰岛 # suppress inspection "UnusedProperty" enum.country.in=印度 # suppress inspection "UnusedProperty" enum.country.id=印度尼西亚 # suppress inspection "UnusedProperty" enum.country.ir=伊朗 # suppress inspection "UnusedProperty" enum.country.iq=伊拉克 # suppress inspection "UnusedProperty" enum.country.ie=爱尔兰 # suppress inspection "UnusedProperty" enum.country.im=马恩岛 # suppress inspection "UnusedProperty" enum.country.il=以色列 # suppress inspection "UnusedProperty" enum.country.it=意大利 # suppress inspection "UnusedProperty" enum.country.jm=牙买加 # suppress inspection "UnusedProperty" enum.country.jp=日本 # suppress inspection "UnusedProperty" enum.country.je=泽西岛 # suppress inspection "UnusedProperty" enum.country.jo=约旦 # suppress inspection "UnusedProperty" enum.country.kz=哈萨克斯坦 # suppress inspection "UnusedProperty" enum.country.ke=肯尼亚 # suppress inspection "UnusedProperty" enum.country.ki=基里巴斯 # suppress inspection "UnusedProperty" enum.country.kp=朝鲜 # suppress inspection "UnusedProperty" enum.country.kr=韩国 # suppress inspection "UnusedProperty" enum.country.kw=科威特 # suppress inspection "UnusedProperty" enum.country.kg=吉尔吉斯斯坦 # suppress inspection "UnusedProperty" enum.country.la=老挝人民民主共和国 # suppress inspection "UnusedProperty" enum.country.lv=拉脱维亚 # suppress inspection "UnusedProperty" enum.country.lb=黎巴嫩 # suppress inspection "UnusedProperty" enum.country.ls=莱索托 # suppress inspection "UnusedProperty" enum.country.lr=利比里亚 # suppress inspection "UnusedProperty" enum.country.ly=利比亚 # suppress inspection "UnusedProperty" enum.country.li=列支敦士登 # suppress inspection "UnusedProperty" enum.country.lt=立陶宛 # suppress inspection "UnusedProperty" enum.country.lu=卢森堡 # suppress inspection "UnusedProperty" enum.country.mo=中国澳门特别行政区 # suppress inspection "UnusedProperty" enum.country.mk=马其顿 # suppress inspection "UnusedProperty" enum.country.mg=马达加斯加 # suppress inspection "UnusedProperty" enum.country.mw=马拉维 # suppress inspection "UnusedProperty" enum.country.my=马来西亚 # suppress inspection "UnusedProperty" enum.country.mv=马尔代夫 # suppress inspection "UnusedProperty" enum.country.ml=马里 # suppress inspection "UnusedProperty" enum.country.mt=马耳他 # suppress inspection "UnusedProperty" enum.country.mh=马绍尔群岛 # suppress inspection "UnusedProperty" enum.country.mq=马提尼克 # suppress inspection "UnusedProperty" enum.country.mr=毛里塔尼亚 # suppress inspection "UnusedProperty" enum.country.mu=毛里求斯 # suppress inspection "UnusedProperty" enum.country.yt=马约特 # suppress inspection "UnusedProperty" enum.country.mx=墨西哥 # suppress inspection "UnusedProperty" enum.country.fm=密克罗尼西亚 # suppress inspection "UnusedProperty" enum.country.md=摩尔多瓦 # suppress inspection "UnusedProperty" enum.country.mc=摩纳哥 # suppress inspection "UnusedProperty" enum.country.mn=蒙古 # suppress inspection "UnusedProperty" enum.country.me=黑山 # suppress inspection "UnusedProperty" enum.country.ms=蒙特塞拉特 # suppress inspection "UnusedProperty" enum.country.ma=摩洛哥 # suppress inspection "UnusedProperty" enum.country.mz=莫桑比克 # suppress inspection "UnusedProperty" enum.country.mm=缅甸 # suppress inspection "UnusedProperty" enum.country.na=纳米比亚 # suppress inspection "UnusedProperty" enum.country.nr=瑙鲁 # suppress inspection "UnusedProperty" enum.country.np=尼泊尔 # suppress inspection "UnusedProperty" enum.country.nl=荷兰 # suppress inspection "UnusedProperty" enum.country.an=荷属安的列斯 # suppress inspection "UnusedProperty" enum.country.nc=新喀里多尼亚 # suppress inspection "UnusedProperty" enum.country.nz=新西兰 # suppress inspection "UnusedProperty" enum.country.ni=尼加拉瓜 # suppress inspection "UnusedProperty" enum.country.ne=尼日尔 # suppress inspection "UnusedProperty" enum.country.ng=尼日利亚 # suppress inspection "UnusedProperty" enum.country.nu=纽埃 # suppress inspection "UnusedProperty" enum.country.nf=诺福克岛 # suppress inspection "UnusedProperty" enum.country.mp=北马里亚纳群岛 # suppress inspection "UnusedProperty" enum.country.no=挪威 # suppress inspection "UnusedProperty" enum.country.om=阿曼 # suppress inspection "UnusedProperty" enum.country.pk=巴基斯坦 # suppress inspection "UnusedProperty" enum.country.pw=帕劳 # suppress inspection "UnusedProperty" enum.country.ps=巴勒斯坦 # suppress inspection "UnusedProperty" enum.country.pa=巴拿马 # suppress inspection "UnusedProperty" enum.country.pg=巴布亚新几内亚 # suppress inspection "UnusedProperty" enum.country.py=巴拉圭 # suppress inspection "UnusedProperty" enum.country.pe=秘鲁 # suppress inspection "UnusedProperty" enum.country.ph=菲律宾 # suppress inspection "UnusedProperty" enum.country.pn=皮特凯恩群岛 # suppress inspection "UnusedProperty" enum.country.pl=波兰 # suppress inspection "UnusedProperty" enum.country.pt=葡萄牙 # suppress inspection "UnusedProperty" enum.country.pr=波多黎各 # suppress inspection "UnusedProperty" enum.country.qa=卡塔尔 # suppress inspection "UnusedProperty" enum.country.re=留尼汪 # suppress inspection "UnusedProperty" enum.country.ro=罗马尼亚 # suppress inspection "UnusedProperty" enum.country.ru=俄罗斯 # suppress inspection "UnusedProperty" enum.country.rw=卢旺达 # suppress inspection "UnusedProperty" enum.country.sh=圣赫勒拿、阿森松和特里斯坦-达库尼亚 # suppress inspection "UnusedProperty" enum.country.kn=圣基茨和尼维斯 # suppress inspection "UnusedProperty" enum.country.lc=圣卢西亚 # suppress inspection "UnusedProperty" enum.country.pm=圣皮埃尔和密克隆 # suppress inspection "UnusedProperty" enum.country.vc=圣文森特和格林纳丁斯 # suppress inspection "UnusedProperty" enum.country.ws=萨摩亚 # suppress inspection "UnusedProperty" enum.country.sm=圣马力诺 # suppress inspection "UnusedProperty" enum.country.st=圣多美和普林西比 # suppress inspection "UnusedProperty" enum.country.sa=沙特阿拉伯 # suppress inspection "UnusedProperty" enum.country.sn=塞内加尔 # suppress inspection "UnusedProperty" enum.country.rs=塞尔维亚 # suppress inspection "UnusedProperty" enum.country.sc=塞舌尔 # suppress inspection "UnusedProperty" enum.country.sl=塞拉利昂 # suppress inspection "UnusedProperty" enum.country.sg=新加坡 # suppress inspection "UnusedProperty" enum.country.sk=斯洛伐克 # suppress inspection "UnusedProperty" enum.country.si=斯洛文尼亚 # suppress inspection "UnusedProperty" enum.country.sb=所罗门群岛 # suppress inspection "UnusedProperty" enum.country.so=索马里 # suppress inspection "UnusedProperty" enum.country.za=南非 # suppress inspection "UnusedProperty" enum.country.gs=南乔治亚和南桑威奇群岛 # suppress inspection "UnusedProperty" enum.country.ss=南苏丹 # suppress inspection "UnusedProperty" enum.country.es=西班牙 # suppress inspection "UnusedProperty" enum.country.lk=斯里兰卡 # suppress inspection "UnusedProperty" enum.country.sd=苏丹 # suppress inspection "UnusedProperty" enum.country.sr=苏里南 # suppress inspection "UnusedProperty" enum.country.sj=斯瓦尔巴和扬马延 # suppress inspection "UnusedProperty" enum.country.sz=斯威士兰 # suppress inspection "UnusedProperty" enum.country.se=瑞典 # suppress inspection "UnusedProperty" enum.country.ch=瑞士 # suppress inspection "UnusedProperty" enum.country.sy=叙利亚 # suppress inspection "UnusedProperty" enum.country.tw=中国台湾省 # suppress inspection "UnusedProperty" enum.country.tj=塔吉克斯坦 # suppress inspection "UnusedProperty" enum.country.tz=坦桑尼亚 # suppress inspection "UnusedProperty" enum.country.th=泰国 # suppress inspection "UnusedProperty" enum.country.tl=东帝汶 # suppress inspection "UnusedProperty" enum.country.tg=多哥 # suppress inspection "UnusedProperty" enum.country.tk=托克劳 # suppress inspection "UnusedProperty" enum.country.to=汤加 # suppress inspection "UnusedProperty" enum.country.tt=特立尼达和多巴哥 # suppress inspection "UnusedProperty" enum.country.tn=突尼斯 # suppress inspection "UnusedProperty" enum.country.tr=土耳其 # suppress inspection "UnusedProperty" enum.country.tm=土库曼斯坦 # suppress inspection "UnusedProperty" enum.country.tc=特克斯和凯科斯群岛 # suppress inspection "UnusedProperty" enum.country.tv=图瓦卢 # suppress inspection "UnusedProperty" enum.country.ug=乌干达 # suppress inspection "UnusedProperty" enum.country.ua=乌克兰 # suppress inspection "UnusedProperty" enum.country.ae=阿联酋 # suppress inspection "UnusedProperty" enum.country.gb=英国 # suppress inspection "UnusedProperty" enum.country.us=美国 # suppress inspection "UnusedProperty" enum.country.um=美国本土外小岛屿 # suppress inspection "UnusedProperty" enum.country.uy=乌拉圭 # suppress inspection "UnusedProperty" enum.country.uz=乌兹别克斯坦 # suppress inspection "UnusedProperty" enum.country.vu=瓦努阿图 # suppress inspection "UnusedProperty" enum.country.ve=委内瑞拉 # suppress inspection "UnusedProperty" enum.country.vn=越南 # suppress inspection "UnusedProperty" enum.country.vg=英属维尔京群岛 # suppress inspection "UnusedProperty" enum.country.vi=美属维尔京群岛 # suppress inspection "UnusedProperty" enum.country.wf=瓦利斯和富图纳 # suppress inspection "UnusedProperty" enum.country.eh=西撒哈拉 # suppress inspection "UnusedProperty" enum.country.ye=也门 # suppress inspection "UnusedProperty" enum.country.zm=赞比亚 # suppress inspection "UnusedProperty" enum.country.zw=津巴布韦 # suppress inspection "UnusedProperty" enum.country.tor=Tor # suppress inspection "UnusedProperty" enum.country.i2p=I2P # suppress inspection "UnusedProperty" enum.country.lan=局域网 ================================================ FILE: common/src/test/java/io/xeres/common/AppNameTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common; import io.xeres.testutils.TestUtils; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; class AppNameTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(AppName.class); } @Test void Name_NotBlank() { assertTrue(StringUtils.isNotBlank(AppName.NAME)); } } ================================================ FILE: common/src/test/java/io/xeres/common/CommonCodingRulesTest.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common; import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.domain.JavaModifier; import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import io.xeres.common.id.GxsId; import io.xeres.common.id.Identifier; import io.xeres.common.id.MsgId; import org.slf4j.Logger; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; import static com.tngtech.archunit.library.GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING; @SuppressWarnings("unused") @AnalyzeClasses(packagesOf = AppName.class, importOptions = ImportOption.DoNotIncludeTests.class) class CommonCodingRulesTest { @ArchTest private final ArchRule noJavaUtilLogging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING; /** * The serializer uses the 'LENGTH' field of identifiers to be able to deserialize them. * Make sure they all implement one. */ @ArchTest private final ArchRule identifierPublicLengthField = classes() .that().areAssignableTo(Identifier.class) .and().doNotBelongToAnyOf(Identifier.class) .should(new ArchCondition<>("have a public field called LENGTH") { @Override public void check(JavaClass javaClass, ConditionEvents events) { boolean satisfied = javaClass.getField("LENGTH").getModifiers().contains(JavaModifier.PUBLIC); String message = javaClass.getDescription() + (satisfied ? " has" : " does not have") + " a public field called LENGTH"; events.add(new SimpleConditionEvent(javaClass, satisfied, message)); } }); @ArchTest private final ArchRule loggersShouldBeFinalAndStatic = fields().that().haveRawType(Logger.class) .should().bePrivate().orShould().beProtected() .andShould().beStatic().orShould().beProtected() .andShould().beFinal() .because("we agreed on this convention"); @ArchTest private final ArchRule utilityClass = classes() .that().haveSimpleNameEndingWith("Utils") .should(new ArchCondition<>("have a private constructor without parameters") { @Override public void check(JavaClass javaClass, ConditionEvents events) { boolean satisfied = javaClass.getConstructors().stream() .anyMatch(constructor -> constructor.getModifiers().contains(JavaModifier.PRIVATE) && constructor.getParameters().isEmpty() ); String message = javaClass.getDescription() + (satisfied ? " has" : " does not have") + " a private constructor without parameters"; events.add(new SimpleConditionEvent(javaClass, satisfied, message)); } } ) .andShould().haveModifier(JavaModifier.FINAL); @ArchTest private final ArchRule gxsIdFieldNaming = fields().that().haveRawType(GxsId.class) .should().haveNameEndingWith("GxsId") .orShould().haveName("gxsId") .because("The name could be confused with database IDs"); @ArchTest private final ArchRule msgIdFieldNaming = fields().that().haveRawType(MsgId.class) .should().haveNameEndingWith("MsgId") .orShould().haveName("msgId") .because("The name could be confused with database IDs"); } ================================================ FILE: common/src/test/java/io/xeres/common/file/FileTypeTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.file; import org.junit.jupiter.api.Test; import java.util.HashSet; import java.util.Set; import static io.xeres.common.file.FileType.*; import static org.junit.jupiter.api.Assertions.assertEquals; class FileTypeTest { @Test void GetTypeByExtension_MissingExtension_Success() { assertEquals(ANY, getTypeByExtension("foobar.")); } @Test void GetTypeByExtension_NoExtension_Success() { assertEquals(ANY, getTypeByExtension("foobar")); } @Test void GetTypeByExtension_Variants_Success() { assertEquals(AUDIO, getTypeByExtension("foobar.aac")); assertEquals(AUDIO, getTypeByExtension("foobar.mp3")); assertEquals(ARCHIVE, getTypeByExtension("foobar.tar")); assertEquals(DOCUMENT, getTypeByExtension("foobar.doc")); assertEquals(PICTURE, getTypeByExtension("foobar.jpg")); assertEquals(PROGRAM, getTypeByExtension("foobar.exe")); assertEquals(VIDEO, getTypeByExtension("foobar.avi")); assertEquals(SUBTITLES, getTypeByExtension("foobar.srt")); assertEquals(COLLECTION, getTypeByExtension("foobar.rscollection")); } @Test void GetTypeByExtension_NotFound_Success() { assertEquals(ANY, getTypeByExtension("foobar.dtc")); } /** * Makes sure that no extension is in more than one group. */ @Test void GetExtensions_NoCrossMatches() { Set all = new HashSet<>(); all.addAll(AUDIO.getExtensions()); all.addAll(ARCHIVE.getExtensions()); all.addAll(DOCUMENT.getExtensions()); all.addAll(PICTURE.getExtensions()); all.addAll(PROGRAM.getExtensions()); all.addAll(VIDEO.getExtensions()); all.addAll(SUBTITLES.getExtensions()); all.addAll(COLLECTION.getExtensions()); assertEquals(all.size(), AUDIO.getExtensions().size() + ARCHIVE.getExtensions().size() + DOCUMENT.getExtensions().size() + PICTURE.getExtensions().size() + PROGRAM.getExtensions().size() + VIDEO.getExtensions().size() + SUBTITLES.getExtensions().size() + COLLECTION.getExtensions().size(), "There's a file extension which is in more than one group"); } } ================================================ FILE: common/src/test/java/io/xeres/common/id/IdTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.id; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import java.math.BigInteger; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; class IdTest { @Test void Instance_Throws() throws NoSuchMethodException { TestUtils.assertUtilityClass(Id.class); } @Test void ToString_FromBytes_Success() { var value = "13352839ab34093f"; var id = new BigInteger(value, 16); var result = Id.toString(id.toByteArray()); assertEquals(value, result); } @Test void ToString_FromBytes_Null_Success() { var result = Id.toString((byte[]) null); assertEquals("", result); } @Test void ToString_FromBytes_Empty_Success() { var result = Id.toString(new byte[0]); assertEquals("", result); } @Test void ToBytes_FromString_Success() { var id = "e40f238ecb395023"; var result = Id.toBytes(id); assertArrayEquals(new byte[]{(byte) 0xe4, 0xf, 0x23, (byte) 0x8e, (byte) 0xcb, 0x39, 0x50, 0x23}, result); } @Test void ToBytes_FromString_Null_Success() { var result = Id.toBytes(null); assertArrayEquals(new byte[0], result); } @Test void ToBytes_FromString_Empty_Success() { var result = Id.toBytes(""); assertArrayEquals(new byte[0], result); } @Test void ToString_FromLong_Success() { var id = 0x843303842344ab38L; var result = Id.toString(id); assertEquals("843303842344AB38", result); } @Test void ToString_FromLong_Negative_LowerCase_Success() { var id = 0xf43303842344ab38L; var result = Id.toStringLowerCase(id); assertEquals("f43303842344ab38", result); } @Test void ToString_FromLong_Negative_Success() { var id = 0xf43303842344ab38L; var result = Id.toString(id); assertEquals("F43303842344AB38", result); } @Test void ToString_FromLong_ZeroPrefix_Success() { var id = 0x0344ab38L; var result = Id.toString(id); assertEquals("000000000344AB38", result); } @Test void ToString_FromIdentifier_Success() { var gxsId = new GxsId(new byte[]{0x32, 0x5e, 0x38, 0x1, (byte) 0x98, (byte) 0x8a, 0x34, 0x73, 0x47, (byte) 0xef, 0x3e, 0x5a, (byte) 0xe2, 0x4a, 0x63, (byte) 0xba}); var result = Id.toString(gxsId); assertEquals("325e3801988a347347ef3e5ae24a63ba", result); } @Test void AsciiToBytes_Success() { byte[] id = {0x30, 0x30, 0x35, 0x36, 0x33, 0x65, 0x38, 0x36, 0x61, 0x31, 0x64, 0x62, 0x36, 0x61, 0x61, 0x30, 0x32, 0x64, 0x36, 0x62, 0x36, 0x65, 0x38, 0x66, 0x37, 0x64, 0x61, 0x32, 0x62, 0x36, 0x39, 0x35}; var result = Id.asciiToBytes(id); assertArrayEquals(new byte[]{0x0, 0x56, 0x3e, (byte) 0x86, (byte) 0xa1, (byte) 0xdb, 0x6a, (byte) 0xa0, 0x2d, 0x6b, 0x6e, (byte) 0x8f, 0x7d, (byte) 0xa2, (byte) 0xb6, (byte) 0x95}, result); } @Test void IdentifierToAscii_Success() { var gxsId = new GxsId(new byte[]{0x32, 0x5e, 0x38, 0x1, (byte) 0x98, (byte) 0x8a, 0x34, 0x73, 0x47, (byte) 0xef, 0x3e, 0x5a, (byte) 0xe2, 0x4a, 0x63, (byte) 0xba}); var result = Id.toAsciiBytes(gxsId); assertArrayEquals(new byte[]{0x33, 0x32, 0x35, 0x65, 0x33, 0x38, 0x30, 0x31, 0x39, 0x38, 0x38, 0x61, 0x33, 0x34, 0x37, 0x33, 0x34, 0x37, 0x65, 0x66, 0x33, 0x65, 0x35, 0x61, 0x65, 0x32, 0x34, 0x61, 0x36, 0x33, 0x62, 0x61}, result); } } ================================================ FILE: common/src/test/java/io/xeres/common/identity/TypeTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.identity; import org.junit.jupiter.api.Test; import static io.xeres.common.identity.Type.*; import static org.junit.jupiter.api.Assertions.assertEquals; class TypeTest { @Test void Enum_Order_Fixed() { assertEquals(0, OTHER.ordinal()); assertEquals(1, OWN.ordinal()); assertEquals(2, FRIEND.ordinal()); assertEquals(3, BANNED.ordinal()); assertEquals(4, values().length); } } ================================================ FILE: common/src/test/java/io/xeres/common/pgp/TrustTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.pgp; import org.junit.jupiter.api.Test; import static io.xeres.common.pgp.Trust.*; import static org.junit.jupiter.api.Assertions.assertEquals; class TrustTest { @Test void Enum_Order_Fixed() { assertEquals(0, UNKNOWN.ordinal()); assertEquals(1, NEVER.ordinal()); assertEquals(2, MARGINAL.ordinal()); assertEquals(3, FULL.ordinal()); assertEquals(4, ULTIMATE.ordinal()); assertEquals(5, values().length); } } ================================================ FILE: common/src/test/java/io/xeres/common/protocol/HostPortTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; class HostPortTest { @Test void Parse_Success() { var host = "hey.foobar.com"; var port = 1234; var hostPort = HostPort.parse(host + ":" + port); assertEquals(host, hostPort.host()); assertEquals(port, hostPort.port()); } @Test void Parse_WrongFormat_ThrowsException() { var host = "hey.foobar.com"; assertThrows(IllegalArgumentException.class, () -> HostPort.parse(host), "Input is not in \"host:port\" format: hey.foobar.com"); } @Test void Parse_MissingHost_ThrowsException() { var host = ""; assertThrows(IllegalArgumentException.class, () -> HostPort.parse(host), "Host is missing"); } @Test void Parse_MissingPort_ThrowsException() { var host = "hey.foobar.com"; assertThrows(IllegalArgumentException.class, () -> HostPort.parse(host + ":"), "Port is not a number: "); } @Test void Parse_PortNotANumber_ThrowsException() { var host = "hey.foobar.com"; var port = "plop"; assertThrows(IllegalArgumentException.class, () -> HostPort.parse(host + ":" + port), "Port is not a number: " + port); } @ParameterizedTest @ValueSource(ints = {-1, 65536}) void Parse_PortOutOfRange_ThrowsException(int port) { var host = "hey.foobar.com"; assertThrows(IllegalArgumentException.class, () -> HostPort.parse(host + ":" + port), "Port is out of range: " + port); } } ================================================ FILE: common/src/test/java/io/xeres/common/protocol/NetModeTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol; import org.junit.jupiter.api.Test; import static io.xeres.common.protocol.NetMode.*; import static org.junit.jupiter.api.Assertions.assertEquals; class NetModeTest { @Test void Enum_Order_Fixed() { assertEquals(0, UNKNOWN.ordinal()); assertEquals(1, UDP.ordinal()); assertEquals(2, UPNP.ordinal()); assertEquals(3, EXT.ordinal()); assertEquals(4, HIDDEN.ordinal()); assertEquals(5, UNREACHABLE.ordinal()); assertEquals(6, values().length); } } ================================================ FILE: common/src/test/java/io/xeres/common/protocol/dns/DNSTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.dns; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import java.io.IOException; import java.net.InetAddress; import static org.junit.jupiter.api.Assertions.assertEquals; class DNSTest { /** * This test verifies that myip.opendns.com works for finding one's own IP when UPNP is not working. * It also tests that akamai works, in case opendns is removed, and we need to fall back to something else. * It only runs on my machine because of the chicken & egg problem on knowing one's own IP. * */ @Test @EnabledIfEnvironmentVariable(named = "COMPUTERNAME", matches = "B650") void Resolve_Success() throws IOException { var ip1 = DNS.resolve("myip.opendns.com", "208.67.222.222"); // resolver1.opendns.com var ip2 = DNS.resolve("myip.opendns.com", "208.67.220.220"); // resolver2.opendns.com var ip3 = DNS.resolve("myip.opendns.com", "208.67.222.220"); // resolver3.opendns.com var ip4 = DNS.resolve("myip.opendns.com", "208.67.220.222"); // resolver4.opendns.com var ip5 = DNS.resolve("whoami.akamai.net", "193.108.88.1"); // ns1-1.akamaitech.net var realIp = InetAddress.getByName("core.zapek.com"); assertEquals(realIp, ip1); assertEquals(realIp, ip2); assertEquals(realIp, ip3); assertEquals(realIp, ip4); assertEquals(realIp, ip5); } } ================================================ FILE: common/src/test/java/io/xeres/common/protocol/ip/IPTest.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.protocol.ip; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class IPTest { @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(IP.class); } @Test void GetFreeLocalPort_Success() { var port = IP.getFreeLocalPort(); assertTrue(port >= 1025 && port <= 32766); } @Test void GetLocalIPAddress_Success() { var ip = IP.getLocalIpAddress(); assertNotNull(ip); } @Test void IsLanIP_Various_Success() { assertTrue(IP.isLanIp("10.0.0.0")); assertTrue(IP.isLanIp("10.255.255.255")); assertTrue(IP.isLanIp("172.16.0.0")); assertTrue(IP.isLanIp("172.31.255.255")); assertTrue(IP.isLanIp("192.168.0.0")); assertTrue(IP.isLanIp("192.168.255.255")); assertTrue(IP.isLanIp("192.168.1.5")); assertTrue(IP.isLanIp("172.16.0.5")); assertTrue(IP.isLanIp("10.0.0.5")); } @Test void IsLanIP_WAN_Failure() { assertFalse(IP.isLanIp("85.1.2.78")); } @Test void IsLanIP_Empty_Failure() { assertFalse(IP.isLanIp("")); } @Test void IsLanIP_Null_Failure() { assertFalse(IP.isLanIp(null)); } @Test void IsPublicIP_WAN_Success() { assertTrue(IP.isPublicIp("85.1.2.78")); } @Test void IsPublicIP_LAN_Failure() { assertFalse(IP.isPublicIp("192.168.1.5")); } @Test void IsPublicIP_Empty_Failure() { assertFalse(IP.isPublicIp("")); } @Test void IsPublicIP_Null_Failure() { assertFalse(IP.isPublicIp(null)); } } ================================================ FILE: common/src/test/java/io/xeres/common/rest/notification/StatusNotificationTest.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.rest.notification; import io.xeres.common.rest.notification.status.DhtInfo; import io.xeres.common.rest.notification.status.DhtStatus; import io.xeres.common.rest.notification.status.NatStatus; import io.xeres.common.rest.notification.status.StatusNotification; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; class StatusNotificationTest { private final StatusNotification response1 = new StatusNotification(0, 1, NatStatus.UPNP, DhtInfo.fromStatus(DhtStatus.OFF)); private final StatusNotification response2 = new StatusNotification(0, 1, NatStatus.UPNP, DhtInfo.fromStatus(DhtStatus.OFF)); private final StatusNotification response3 = new StatusNotification(0, 1, NatStatus.FIREWALLED, DhtInfo.fromStatus(DhtStatus.OFF)); @Test void Equals_Success() { assertEquals(response1, response2); } @Test void Equals_Variant1_Failure() { assertNotEquals(response1, response3); } @Test void Equals_Variant2_Failure() { assertNotEquals(response2, response3); } } ================================================ FILE: common/src/test/java/io/xeres/common/util/ByteUnitUtilsTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import org.junit.jupiter.api.Test; import static io.xeres.common.util.ByteUnitUtils.fromBytes; import static org.junit.jupiter.api.Assertions.assertEquals; class ByteUnitUtilsTest { @Test void FromBytes_Various_Success() { assertEquals("invalid", fromBytes(-1)); assertEquals("0 bytes", fromBytes(0)); assertEquals("512 bytes", fromBytes(512)); assertEquals("1023 bytes", fromBytes(1023)); assertEquals("1024 bytes", fromBytes(1024)); assertEquals("1152 bytes", fromBytes(1152)); assertEquals("1 MB", fromBytes(1024 * 1024)); assertEquals("1.12 MB", fromBytes(1152 * 1024)); assertEquals("1 GB", fromBytes(1024 * 1024 * 1024)); assertEquals("1 TB", fromBytes(1024L * 1024 * 1024 * 1024)); assertEquals("1 PB", fromBytes(1024L * 1024 * 1024 * 1024 * 1024)); assertEquals("1 EB", fromBytes(1024L * 1024 * 1024 * 1024 * 1024 * 1024)); } } ================================================ FILE: common/src/test/java/io/xeres/common/util/FileNameUtilsTest.java ================================================ package io.xeres.common.util; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; class FileNameUtilsTest { @ParameterizedTest @CsvSource({ "foo.jpg,foo (1).jpg", "foo,foo (1)", "foo.tar.gz,foo (1).tar.gz", "foo.bla.tgz,foo.bla (1).tgz", "foo.blabla.gz,foo.blabla (1).gz", "foo.bar.plop.tar.gz,foo.bar.plop (1).tar.gz", "foo (1).jpg,foo (2).jpg", "foo (9).jpg,foo (10).jpg", "foo (bar).jpg,foo (bar) (1).jpg", "foo (1)(2).jpg,foo (1)(3).jpg", "foo ().jpg,foo () (1).jpg" }) void Rename_Various_Success(String input, String expected) { var result = FileNameUtils.rename(input); assertEquals(expected, result); } @Test void Rename_Empty_ThrowsException() { assertThrows(IllegalArgumentException.class, () -> FileNameUtils.rename("")); } } ================================================ FILE: common/src/test/java/io/xeres/common/util/SecureRandomUtilsTest.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class SecureRandomUtilsTest { @Test void NextPassword_Empty_ThrowsException() { char[] password = new char[0]; assertThrows(IllegalArgumentException.class, () -> SecureRandomUtils.nextPassword(password)); } @Test void NextPassword_Short_Success() { char[] password = new char[1]; SecureRandomUtils.nextPassword(password); assertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit)); } @Test void NextPassword_Small_Success() { char[] password = new char[2]; SecureRandomUtils.nextPassword(password); assertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit)); } @Test void NextPassword_Minimal_Success() { char[] password = new char[3]; SecureRandomUtils.nextPassword(password); assertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit)); assertTrue(String.valueOf(password).chars().anyMatch(Character::isLowerCase)); assertTrue(String.valueOf(password).chars().anyMatch(Character::isUpperCase)); } @Test void NextPassword_Normal_Success() { var password = new char[20]; SecureRandomUtils.nextPassword(password); assertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit)); assertTrue(String.valueOf(password).chars().anyMatch(Character::isLowerCase)); assertTrue(String.valueOf(password).chars().anyMatch(Character::isUpperCase)); } } ================================================ FILE: common/src/test/java/io/xeres/common/util/image/ImageUtilsTest.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.util.image; import io.xeres.testutils.TestUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.MediaType; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.IOException; import java.util.Objects; import static org.junit.jupiter.api.Assertions.*; class ImageUtilsTest { private static BufferedImage opaqueImage; private static BufferedImage transparentImage; @BeforeAll static void setup() throws IOException { opaqueImage = ImageIO.read(Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream("/image/ours.png"))); transparentImage = ImageIO.read(Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream("/image/logo_transparent.png"))); } @Test void Instance_ThrowsException() throws NoSuchMethodException { TestUtils.assertUtilityClass(ImageUtils.class); } @Test void WriteImageAsPngData_Image_Success() { var pngImage = ImageUtils.writeImageAsPngData(ImageUtilsTest.opaqueImage, 2048); assertTrue(pngImage.startsWith("data:image/png;base64,iVBOR")); } @Test void WriteImageAsJpegData_Success() { var jpegImage = ImageUtils.writeImageAsJpegData(opaqueImage, 2048); assertTrue(jpegImage.startsWith("data:image/jpeg;base64,/9j/")); } @Test void WriteImageAsJpegDataWithLimit_Success() { var jpegImage = ImageUtils.writeImageAsJpegData(opaqueImage, 256); assertTrue(jpegImage.startsWith("data:image/jpeg;base64,/9j/")); } @Test void WriteImageAsBestPossibleWhichIsJpeg_Success() { var bestImage = ImageUtils.writeImage(opaqueImage, 2048); assertTrue(bestImage.startsWith("data:image/jpeg;base64,/9j/")); } @Test void WriteImageAsBestPossibleWhichIsPng_Success() { // This image is effectively transparent and must be written as so. var bestImage = ImageUtils.writeImage(transparentImage, 2048); assertTrue(bestImage.startsWith("data:image/png;base64,iVBOR")); } @Test void LimitMaximumImageSize_Success() { var scaledImage = ImageUtils.limitMaximumImageSize(opaqueImage, 128); assertTrue(scaledImage.getWidth() * scaledImage.getHeight() <= 128); } @Test void DetectJpeg_Success() throws IOException { var jpegArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream("/image/hamster.jpg")).readAllBytes(); assertEquals(MediaType.IMAGE_JPEG, ImageUtils.getImageMimeType(jpegArray)); } @Test void DetectPng_Success() throws IOException { var pngArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream("/image/ours.png")).readAllBytes(); assertEquals(MediaType.IMAGE_PNG, ImageUtils.getImageMimeType(pngArray)); } @Test void DetectGif_Success() throws IOException { var gifArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream("/image/v3_anim.gif")).readAllBytes(); assertEquals(MediaType.IMAGE_GIF, ImageUtils.getImageMimeType(gifArray)); } @Test void DetectWebP_Success() throws IOException { var webpArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream("/image/gaudie.webp")).readAllBytes(); assertEquals(MediaType.parseMediaType("image/webp"), ImageUtils.getImageMimeType(webpArray)); } @ParameterizedTest @ValueSource(strings = {"image/png", "image/webp", "image/svg+xml", "image/gif", "image/x-icon"}) void isPossiblyTransparent_Yes(String input) { assertTrue(ImageUtils.isPossiblyTransparent(input)); } @ParameterizedTest @ValueSource(strings = {"image/jpeg", "image/bmp", "image/iff"}) // Supported mime types by WebClient are looked up from the file extension and are there: https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/resources/org/springframework/http/mime.types void isPossiblyTransparent_No(String input) { assertFalse(ImageUtils.isPossiblyTransparent(input)); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/chat/ChatIdentityDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import io.xeres.testutils.IdFakes; import io.xeres.testutils.StringFakes; public final class ChatIdentityDTOFakes { private ChatIdentityDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static ChatIdentityDTO createChatIdentityDTO() { return new ChatIdentityDTO(StringFakes.createNickname(), IdFakes.createGxsId(), 10L); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/chat/ChatRoomContextDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; public final class ChatRoomContextDTOFakes { private ChatRoomContextDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static ChatRoomContextDTO createChatRoomContextDTO() { return new ChatRoomContextDTO(ChatRoomsDTOFakes.createChatRoomsDTO(), ChatIdentityDTOFakes.createChatIdentityDTO()); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/chat/ChatRoomDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import io.xeres.common.message.chat.RoomType; public final class ChatRoomDTOFakes { private ChatRoomDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static ChatRoomDTO createChatRoomDTO() { return new ChatRoomDTO(1L, "Foobar", RoomType.PUBLIC, "hello", 5, true); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/chat/ChatRoomsDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.chat; import java.util.List; public final class ChatRoomsDTOFakes { private ChatRoomsDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static ChatRoomsDTO createChatRoomsDTO() { return new ChatRoomsDTO(List.of(ChatRoomDTOFakes.createChatRoomDTO()), List.of(ChatRoomDTOFakes.createChatRoomDTO())); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/connection/ConnectionDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.connection; import java.time.Instant; public final class ConnectionDTOFakes { private ConnectionDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static ConnectionDTO createConnectionDTO() { return new ConnectionDTO(1L, "88.89.10.11:1234", Instant.now(), true); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/identity/IdentityDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.identity; import io.xeres.common.identity.Type; import io.xeres.testutils.*; public final class IdentityDTOFakes { private IdentityDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static IdentityDTO createIdentityDTO() { return new IdentityDTO(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createGxsId(), TimeFakes.createInstant(), EnumFakes.create(Type.class), BooleanFakes.create()); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/location/LocationDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.location; import io.xeres.common.dto.connection.ConnectionDTOFakes; import io.xeres.common.location.Availability; import io.xeres.testutils.BooleanFakes; import io.xeres.testutils.IdFakes; import io.xeres.testutils.StringFakes; import io.xeres.testutils.TimeFakes; import java.util.List; public final class LocationDTOFakes { private LocationDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static LocationDTO create() { return new LocationDTO(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createLocationIdentifier().getBytes(), StringFakes.createNickname(), List.of(ConnectionDTOFakes.createConnectionDTO()), BooleanFakes.create(), TimeFakes.createInstant(), Availability.AVAILABLE, "Xeres 2.3.2"); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/profile/ProfileDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.profile; import io.xeres.common.dto.location.LocationDTOFakes; import io.xeres.common.pgp.Trust; import io.xeres.testutils.BooleanFakes; import io.xeres.testutils.EnumFakes; import io.xeres.testutils.IdFakes; import io.xeres.testutils.StringFakes; import java.time.Instant; import java.util.List; public final class ProfileDTOFakes { private ProfileDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static ProfileDTO create() { return new ProfileDTO(IdFakes.createLong(), StringFakes.createNickname(), Long.toString(IdFakes.createLong()), Instant.now(), new byte[20], new byte[1], BooleanFakes.create(), EnumFakes.create(Trust.class), List.of(LocationDTOFakes.create())); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/settings/SettingsDTOFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.settings; import io.xeres.testutils.BooleanFakes; import io.xeres.testutils.IdFakes; import org.apache.commons.lang3.RandomStringUtils; public final class SettingsDTOFakes { private SettingsDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static SettingsDTO create() { return new SettingsDTO(RandomStringUtils.secure().nextAlphanumeric(30), IdFakes.createInt(), RandomStringUtils.secure().nextAlphanumeric(30), IdFakes.createInt(), BooleanFakes.create(), BooleanFakes.create(), BooleanFakes.create(), BooleanFakes.create(), "/foo/bar", "foobar1234", BooleanFakes.create(), BooleanFakes.create(), IdFakes.createInt()); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/common/dto/share/ShareDTOFakes.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.common.dto.share; import io.xeres.common.pgp.Trust; import io.xeres.testutils.BooleanFakes; import io.xeres.testutils.EnumFakes; import io.xeres.testutils.IdFakes; import io.xeres.testutils.StringFakes; import java.time.Instant; public final class ShareDTOFakes { private ShareDTOFakes() { throw new UnsupportedOperationException("Utility class"); } public static ShareDTO createShareDTO() { return new ShareDTO(IdFakes.createLong(), StringFakes.createNickname(), "C:\\foobar", BooleanFakes.create(), EnumFakes.create(Trust.class), Instant.now()); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/testutils/BooleanFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import java.util.concurrent.ThreadLocalRandom; public final class BooleanFakes { private BooleanFakes() { throw new UnsupportedOperationException("Utility class"); } public static boolean create() { return ThreadLocalRandom.current().nextBoolean(); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/testutils/EnumFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import java.util.concurrent.ThreadLocalRandom; public final class EnumFakes { private EnumFakes() { throw new UnsupportedOperationException("Utility class"); } public static > T create(Class enumClass) { var i = ThreadLocalRandom.current().nextInt(enumClass.getEnumConstants().length); return enumClass.getEnumConstants()[i]; } } ================================================ FILE: common/src/testFixtures/java/io/xeres/testutils/IdFakes.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import io.xeres.common.id.GxsId; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.MsgId; import org.apache.commons.lang3.RandomUtils; public final class IdFakes { private IdFakes() { throw new UnsupportedOperationException("Utility class"); } public static GxsId createGxsId() { return new GxsId(RandomUtils.insecure().randomBytes(GxsId.LENGTH)); } public static GxsId createGxsId(byte[] gxsId) { return new GxsId(gxsId); } public static MsgId createMsgId() { return new MsgId(RandomUtils.insecure().randomBytes(MsgId.LENGTH)); } public static LocationIdentifier createLocationIdentifier() { return new LocationIdentifier(RandomUtils.insecure().randomBytes(LocationIdentifier.LENGTH)); } public static long createLong() { return RandomUtils.insecure().randomLong(1, Long.MAX_VALUE); } public static int createInt() { return RandomUtils.insecure().randomInt(1, Integer.MAX_VALUE); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/testutils/Sha1SumFakes.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import io.xeres.common.id.Sha1Sum; import org.apache.commons.lang3.RandomUtils; public final class Sha1SumFakes { private Sha1SumFakes() { throw new UnsupportedOperationException("Utility class"); } public static Sha1Sum createSha1Sum() { return new Sha1Sum(RandomUtils.insecure().randomBytes(Sha1Sum.LENGTH)); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/testutils/StringFakes.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import org.apache.commons.lang3.RandomStringUtils; import java.util.Locale; import java.util.concurrent.ThreadLocalRandom; public final class StringFakes { private static final String[] FIRSTNAME = { "Jean", "Alexander", "Fernando", "Rubens", "Valtteri", "Jenson", "Zhou", "Lewis", "Robert", "Charles", "Kevin", "Felipe", "Nikita", "Lando", "Esteban", "Sergio", "Nelson", "Alain", "George", "Carlos", "Michael", "Ayrton", "Lance", "Jarno", "Yuki", "Max", "Sebastian" }; private static final String[] LASTNAME = { "Alesi", "Albon", "Alonso", "Barrichello", "Bottas", "Button", "Guanyu", "Hamilton", "Kubica", "Leclerc", "Magnussen", "Massa", "Mazepin", "Norris", "Ocon", "Perez", "Piquet", "Prost", "Russell", "Sainz", "Schumacher", "Senna", "Stroll", "Trulli", "Tsunoda", "Verstappen", "Vettel" }; private StringFakes() { throw new UnsupportedOperationException("Utility class"); } public static String createNickname() { var s = RandomStringUtils.insecure().nextAlphabetic(5, 10); return s.substring(0, 1).toUpperCase(Locale.ROOT) + s.substring(1); } public static String createFirstName() { return FIRSTNAME[ThreadLocalRandom.current().nextInt(FIRSTNAME.length)]; } public static String createLastName() { return LASTNAME[ThreadLocalRandom.current().nextInt(LASTNAME.length)]; } public static String createFullName() { return createFirstName() + " " + createLastName(); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/testutils/TestUtils.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import java.lang.reflect.InvocationTargetException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertFalse; public final class TestUtils { private TestUtils() { throw new UnsupportedOperationException("Utility class"); } public static void assertUtilityClass(Class javaClass) throws NoSuchMethodException { var declaredConstructor = javaClass.getDeclaredConstructor(); assertFalse(declaredConstructor.canAccess(null)); declaredConstructor.setAccessible(true); assertThatThrownBy(declaredConstructor::newInstance) .isInstanceOf(InvocationTargetException.class) .hasCauseInstanceOf(UnsupportedOperationException.class); } } ================================================ FILE: common/src/testFixtures/java/io/xeres/testutils/TimeFakes.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.testutils; import java.time.Instant; import java.time.ZonedDateTime; import java.util.concurrent.ThreadLocalRandom; public final class TimeFakes { private TimeFakes() { throw new UnsupportedOperationException("Utility class"); } public static Instant createInstant() { var end = ZonedDateTime.now(); var start = end.minusYears(5); var random = ThreadLocalRandom.current().nextLong(start.toInstant().getEpochSecond(), end.toInstant().getEpochSecond()); return Instant.ofEpochSecond(random); } } ================================================ FILE: docker-compose.yml ================================================ version: '2.4' services: xeres: image: zapek/xeres:0.8.0 ports: - "6232:6232" - "3335:3335" environment: - SPRING_PROFILES_ACTIVE=cloud - XERES_SERVER_PORT=3335 - XERES_DATA_DIR=/tmp - "JAVA_TOOL_OPTIONS=-Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8" mem_limit: 1G ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # # Copyright (c) 2019-2023 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # org.gradle.parallel=true org.gradle.configuration-cache=true org.gradle.configuration-cache.parallel=true org.gradle.caching=true org.gradle.configureondemand=true ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 "%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 "%COMSPEC%" /c exit 1 :execute @rem Setup the command line @rem Execute Gradle @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% ================================================ FILE: qodana.yaml ================================================ #-------------------------------------------------------------------------------# # Qodana analysis is configured by qodana.yaml file # # https://www.jetbrains.com/help/qodana/qodana-yaml.html # #-------------------------------------------------------------------------------# ################################################################################# # WARNING: Do not store sensitive information in this file, # # as its contents will be included in the Qodana report. # ################################################################################# version: "1.0" #Specify inspection profile for code analysis profile: base: name: qodana.starter inspections: - inspection: LoggingSimilarMessage options: myMinTextLength: 49 #Enable inspections #include: # - name: #Disable inspections #exclude: # - name: # paths: # - projectJDK: "25" #(Applied in CI/CD pipeline) #Execute shell command before Qodana execution (Applied in CI/CD pipeline) #bootstrap: sh ./prepare-qodana.sh #Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) #plugins: # - id: #(plugin id can be found at https://plugins.jetbrains.com) # Quality gate. Will fail the CI/CD pipeline if any condition is not met # severityThresholds - configures maximum thresholds for different problem severities # testCoverageThresholds - configures minimum code coverage on a whole project and newly added code # Code Coverage is available in Ultimate and Ultimate Plus plans #failureConditions: # severityThresholds: # any: 15 # critical: 5 # testCoverageThresholds: # fresh: 70 # total: 50 #Specify Qodana linter for analysis (Applied in CI/CD pipeline) linter: jetbrains/qodana-jvm-community:2026.1 ================================================ FILE: scripts/api/user.js ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ // This is an example user script for Xeres. // // Requirements: ECMA script version 2025 in strict mode. // // The script has to be placed in: // Windows: %APPDATA%\Xeres\Scripts\user.js // Linux: /home//.local/share/Xeres/Scripts/user.js // macOS: /Users//Library/Application Support/Xeres/Scripts/user.js // There are many events that you can register for below. // Called when receiving a chat room message xeresAPI.registerEventHandler("chatRoomMessage", function (data) { console.log(`Received chat room message from ${data.nickname} with content: ${data.content}`); if (data.content === '!f1') { xeresAPI.sendChatRoomMessage(data.roomId, `${data.nickname}: ${getF1Prediction()}`); } else if (data.content === '!bullshit') { xeresAPI.sendChatRoomMessage(data.roomId, generateBullshit()); } else if (/all your .+ are belong to .+$/i.test(data.content)) { const ayb = [ "What happen?", "Someone set up us the bomb", "We get signal", "Main screen turn on.", "How are you gentlemen!!", "You are on the way to destruction", "What you say?", "You have no chance to survive make your time", "Take off every 'ZIG'!!", "Move 'ZIG'.", "For great justice.", "It's you!!" ]; xeresAPI.sendChatRoomMessage(data.roomId, getRandomString(ayb)); } console.log(`availability: ${xeresAPI.getAvailability()}`); }); // Called when receiving a private chat message xeresAPI.registerEventHandler("chatPrivateMessage", function (data) { console.log(`Received private message from ${data.location} with content: ${data.content}`); switch (xeresAPI.getAvailability()) { case "AWAY": xeresAPI.sendPrivateMessage(data.location, "Sorry but I'm away. I'll reply when I'm back."); break; case "BUSY": xeresAPI.sendPrivateMessage(data.location, "Sorry but I'm busy right now. I'll reply when I'm available again."); break; } }); // Called when receiving a distant chat message xeresAPI.registerEventHandler("chatDistantMessage", function (data) { console.log(`Received distant message from ${data.gxsId} with content: ${data.content}`); switch (xeresAPI.getAvailability()) { case "AWAY": xeresAPI.sendDistantMessage(data.gxsId, "Sorry but I'm away. I'll reply when I'm back."); break; case "BUSY": xeresAPI.sendDistantMessage(data.gxsId, "Sorry but I'm busy right now. I'll reply when I'm available again."); break; } }); // Called when someone joins a room xeresAPI.registerEventHandler("chatRoomJoin", function (data) { console.log(`User ${data.nickname} (${data.gxsId}) joined chat room ${data.roomId}`); if (xeresAPI.getAvailability() === "AVAILABLE") { xeresAPI.sendChatRoomMessage(data.roomId, `welcome ${data.nickname}!`); } }); // Called when getting a chat room invitation xeresAPI.registerEventHandler("chatRoomInvite", function (data) { console.log(`Location ${data.location} invited you to room id ${data.roomId}. Name: ${data.roomName}, topic: ${data.roomTopic}, public: ${data.roomIsPublic}, user count: ${data.roomUserCount}, signed: ${data.roomIsSigned}`); }); // Initialization code console.log(`User script loaded and ready.\nECMA Script version: ${Graal.versionECMAScript}\nGraal version: ${Graal.versionGraalVM}\nHotCode: ${Graal.isGraalRuntime()}`); // // Helper functions follows // // Predicts the next F1 move using a very advanced and complex system function getF1Prediction() { const drivers = ['Verstappen', 'Hamilton', 'Leclerc', 'Alonso', 'Norris', 'Russell', 'Gasly', 'Albon', 'Hadjar', 'Hülkenberg', 'Ocon', 'Tsunoda', 'Piastri', 'Antonelli', 'Stroll', 'Colapinto', 'Sainz', 'Lawson', 'Bortoleto', 'Bearman']; const actions = ['overtakes', 'crashes into', 'blocks', 'dive bombs', 'undercuts', 'swaps position with'] return getRandomString(drivers) + " " + getRandomString(actions) + " " + getRandomString(drivers); } // Great function to help create PowerPoint slides function generateBullshit() { const fle0 = [ "aggregate", "architect", "benchmark", "brand", "cultivate", "deliver", "deploy", "disintermediate", "drive", "e-enable", "embrace", "empower", "enable", "engage", "engineer", "enhance", "envisioneer", "evolve", "expedite", "exploit", "extend", "facilitate", "generate", "grow", "harness", "implement", "incentivize", "incubate", "innovate", "integrate", "iterate", "leverage", "matrix", "maximize", "mesh", "monetize", "morph", "optimize", "orchestrate", "productize", "recontextualize", "reintermediate", "reinvent", "repurpose", "revolutionize", "scale", "seize", "strategize", "streamline", "syndicate", "synergize", "synthesize", "target", "transform", "transition", "unleash", "utilize", "visualize", "whiteboard" ]; const fle1 = [ "24/365", "24/7", "B2B", "B2C", "back-end", "best-of-breed", "bleeding-edge", "bricks-and-clicks", "clicks-and-mortar", "collaborative", "compelling", "cross-platform", "cross-media", "customized", "cutting-edge", "distributed", "dot-com", "dynamic", "e-business", "efficient", "end-to-end", "enterprise", "extensible", "frictionless", "front-end", "global", "granular", "holistic", "impactful", "innovative", "integrated", "interactive", "intuitive", "killer", "leading-edge", "magnetic", "mission-critical", "next-generation", "one-to-one", "plug-and-play", "proactive", "real-time", "revolutionary", "robust", "scalable", "seamless", "sexy", "sticky", "strategic", "synergistic", "transparent", "turn-key", "ubiquitous", "user-centric", "value-added", "vertical", "viral", "virtual", "visionary", "web-enabled", "wireless", "world-class" ]; const fle2 = [ "action-items", "AI", "applications", "architectures", "bandwidth", "channels", "cloud", "communities", "content", "convergence", "deliverables", "e-business", "e-commerce", "e-markets", "e-services", "e-tailers", "experiences", "eyeballs", "functionalities", "infomediaries", "infrastructures", "initiatives", "interfaces", "markets", "methodologies", "metrics", "mindshare", "models", "networks", "niches", "paradigms", "partnerships", "platforms", "portals", "relationships", "ROI", "synergies", "web-readiness", "schemas", "solutions", "supply-chains", "systems", "technologies", "users", "vortals", "web services" ]; return getRandomString(fle0) + " " + getRandomString(fle1) + " " + getRandomString(fle2); } function getRandomString(array) { return array[Math.floor(Math.random() * array.length)]; } ================================================ FILE: scripts/bot/Dockerfile ================================================ FROM python:3.10-slim AS builder WORKDIR /app/ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY bot.py . COPY config.json . ENV PYTHONUNBUFFERED=1 CMD [ "python", "./bot.py" ] ================================================ FILE: scripts/bot/README.md ================================================ # Xeres Bot This is a simple python script demonstrating how to use a Xeres instance as a bot. It is supposed to use a LLM running locally. ## Installation You need the following: - a running Xeres instance - a running Ollama instance (also works with llamafile) - `pip install requests stomp.py cachetools` ## Running Xeres Either run it standalone with the `--no-gui` option or with a docker compose like that: ``` services: xeres: image: zapek/xeres:0.8.0 user: 0:0 environment: - SPRING_PROFILES_ACTIVE=cloud - XERES_SERVER_PORT=3333 - XERES_DATA_DIR=/tmp/xeres - XERES_HTTPS=false - XERES_CONTROL_PASSWORD=false - "JAVA_TOOL_OPTIONS=-Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8" volumes: xeres-bot-data:/tmp/xeres mem_limit: 1G restart: unless-stopped network_mode: host volumes: xeres-bot-data: ``` ## Running with ollama Get ollama from here: https://ollama.com/ Then use `ollama run llama2` ## Running with llamafile Get llamafile from here: https://github.com/mozilla-Ocho/llamafile Run it with something like that (use the name of the llamafile you downloaded): ### Windows `.\llamafile.exe --server --port 11434 --nobrowser` ### Linux `llamafile --server --port 11434 --nobrowser` ### Docker See https://github.com/iverly/llamafile-docker ## Writing the configuration file You need a `config.json` file in the same directory which looks like the following: ``` { "xeres": { "api_url": "http://localhost:6232", "profile_name": "YourBotName", "location_name": "YourLocationName", "friend_ids": [ "a Retroshare ID or Xeres ID of a friend's node" ], "room_names": [ "the name of a chat room to join" ] }, "openai": { "api_url": "http://localhost:11434/v1/chat/completions", "temperature": 0.7, "model": "llama2", "prompt": "You are an assistant and your name is {assistant}. You are helpful, kind, obedient, honest and know your own limits. You answer to {user}." }, "context": { "max_users": 256, "max_time": 7200, "interactions": 6 } } ``` ### Running the script `python3 bot.py` It will automatically configure the running Xeres instance and then take control of it. The bot will join the configured chat rooms and answer to users when being addressed directly. It also answers to direct messages between nodes. If there's an `avatar.png` present in the same directory during configuration, it'll be used as the bot's avatar picture. ================================================ FILE: scripts/bot/bot.py ================================================ #!/usr/bin/env python3 # Copyright (c) 2024 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . import json import os import requests import stomp import time from cachetools import TTLCache from urllib.parse import urlparse try: with open('config.json', encoding="utf-8") as config_file: config = json.load(config_file) except FileNotFoundError: print("Missing configuration file 'config.json' in the same directory. See the README.md file for more information.") exit(1) XERES_API_URL = config['xeres']['api_url'] XERES_API_PREFIX = "/api/v1" XERES_API_HOST = urlparse(XERES_API_URL).hostname XERES_API_PORT = urlparse(XERES_API_URL).port PROFILE_NAME = config['xeres']['profile_name'] LOCATION_NAME = config['xeres']['location_name'] FRIEND_IDS = config['xeres']['friend_ids'] ROOM_NAMES = config['xeres']['room_names'] OPENAI_URL = config['openai']['api_url'] TEMPERATURE = config['openai']['temperature'] MODEL = config['openai']['model'] if 'model' in config['openai'] else None PROMPT = config['openai']['prompt'] AVATAR = "avatar.png" CHAT_CACHE = TTLCache(config['context']['max_users'], config['context']['max_time']) INTERACTIONS = config['context']['interactions'] def has_profile(): r = requests.get(XERES_API_URL + XERES_API_PREFIX + "/profiles/1") return r.status_code == 200 def create_profile(): r = requests.post(XERES_API_URL + XERES_API_PREFIX + "/config/profile", json={'name': PROFILE_NAME}) if r.status_code != 201: raise RuntimeError(f"Couldn't create profile: {r.status_code}") def create_location(): r = requests.post(XERES_API_URL + XERES_API_PREFIX + "/config/location", json={'name': LOCATION_NAME}) if r.status_code != 201: raise RuntimeError(f"Couldn't create location: {r.status_code}") def create_identity(): r = requests.post(XERES_API_URL + XERES_API_PREFIX + "/config/identity", json={'name': PROFILE_NAME}) if r.status_code != 201: raise RuntimeError(f"Couldn't create identity: {r.status_code}") def get_own_profile(): r = requests.get(XERES_API_URL + XERES_API_PREFIX + "/profiles/1") if r.status_code != 200: raise RuntimeError("Couldn't get own profile") return json.loads(r.text) def get_own_identity(): r = requests.get(XERES_API_URL + XERES_API_PREFIX + "/identities/1") if r.status_code != 200: raise RuntimeError("Couldn't get own identity") return json.loads(r.text) def get_own_location(): r = requests.get(XERES_API_URL + XERES_API_PREFIX + "/locations/1") if r.status_code != 200: raise RuntimeError("Couldn't get own location") return json.loads(r.text) def get_own_rsid(): r = requests.get(XERES_API_URL + XERES_API_PREFIX + "/locations/1/rs-id") if r.status_code != 200: raise RuntimeError(f"Couldn't get own RsId: {r.status_code}") return json.loads(r.text).get("rsId") def add_friend(id): r = requests.post(XERES_API_URL + XERES_API_PREFIX + "/profiles?trust=FULL", json={'rsId': id}) if r.status_code != 201: raise RuntimeError(f"Couldn't add friend: {r.status_code}") def synchronize_chatrooms(rooms): print("Syncing chatrooms...") remaining_rooms = rooms.copy() while len(remaining_rooms) > 0: for name in remaining_rooms: context = get_chat_rooms() id = find_chat_room(name, context['chatRooms']['subscribed']) if id != 0: remaining_rooms.remove(name) break id = find_chat_room(name, context['chatRooms']['available']) if id != 0: print(f"Subscribing to room {name} with id {id}", name, id) r = requests.put(XERES_API_URL + XERES_API_PREFIX + "/chat/rooms/" + str(id) + "/subscription") if r.status_code != 200: raise RuntimeError(f"Couldn't subscribe to chatroom: {r.status_code}") remaining_rooms.remove(name) break time.sleep(10) context = get_chat_rooms() for room in context['chatRooms']['subscribed']: if room['name'] not in rooms: leave_room(room['name']) def get_chat_rooms(): r = requests.get(XERES_API_URL + XERES_API_PREFIX + "/chat/rooms") if r.status_code != 200: raise RuntimeError(f"Couldn't get chatrooms: {r.status_code}") return json.loads(r.text) def find_chat_room(name, room_array): for room in room_array: if room['name'] == name: return room['id'] return 0 def leave_room(id): r = requests.delete(XERES_API_URL + XERES_API_PREFIX + "/chat/rooms/" + str(id) + "/subscription") if r.status_code != 204: raise RuntimeError(f"Couldn't leave room: {r.status_code}") def upload_avatar(path): with open(path, 'rb') as img: files = [('file', (AVATAR, img, "image/png"))] r = requests.post(XERES_API_URL + XERES_API_PREFIX + "/identities/1/image", data={}, files=files) if r.status_code != 201: raise RuntimeError(f"Couldn't upload avatar: {r.status_code}") def connect_and_subscribe(conn): conn.connect(wait=True, with_connect_command=True) conn.subscribe(destination='/topic/chat/private', id=1, ack='auto') conn.subscribe(destination='/topic/chat/room', id=2, ack='auto') class StompListener(stomp.ConnectionListener): def __init__(self, conn, own_id): self.conn = conn self.own_id = own_id def on_error(self, frame): print('received an error "%s"' % frame.body) def on_message(self, frame): # print(f'frame is {frame}') headers = frame.headers data = json.loads(frame.body) if headers['messageType'] == "CHAT_ROOM_MESSAGE" and data['senderNickname']: handle_incoming_room_message(self.conn, self.own_id, headers['destinationId'], data['roomId'], data['senderNickname'], data['gxsId']['bytes'], data['content']) elif headers['messageType'] == "CHAT_PRIVATE_MESSAGE" and data['own'] == False: handle_incoming_private_message(self.conn, self.own_id, headers['destinationId'], data['content']) def on_disconnected(self): print('disconnected') def handle_chat(own_id): conn = stomp.WSStompConnection([(XERES_API_HOST, XERES_API_PORT)], ws_path='/ws') conn.set_listener('', StompListener(conn, own_id)) connect_and_subscribe(conn) while True: time.sleep(60) def handle_incoming_room_message(conn, own_id, destination_id, room_id, sender, gxs_sender, content): own_nickname = own_id['name'].lower() # We need to do that because what we say in the room is echoed back, obviously if gxs_sender == own_id['gxsId']['bytes']: return lower_content = content.lower() if not (lower_content.startswith("@" + own_nickname + " ") or lower_content.startswith(own_nickname + ": ") or lower_content.startswith("@" + own_nickname + ": ")): return content = content[(len(own_nickname) + (3 if lower_content.startswith("@" + own_nickname + ": ") else 2)):] # print(f"Handling message from {sender} in {room_id}: {content}") content = openai_api_send(content, own_id['name'], sender, gxs_sender, lambda: send_chat_room_typing_notification(conn, own_id, destination_id, room_id)) conn.send(destination="/app/chat/room", content_type="application/json", headers={"messageType": "CHAT_ROOM_MESSAGE", "destinationId": f"{destination_id}"}, body=json.dumps({"roomId": room_id, "senderNickname": PROFILE_NAME, "gxsId": {"bytes": f"{own_id['gxsId']['bytes']}"}, "content": f"{sender}: {content}"}) ) def handle_incoming_private_message(conn, own_id, destination_id, content): # user is not really the destination_id, we should fetch it content = openai_api_send(content, own_id['name'], destination_id, destination_id, lambda: send_private_typing_notification(conn, destination_id)) conn.send(destination="/app/chat/private", content_type="application/json", headers={"messageType": "CHAT_PRIVATE_MESSAGE", "destinationId": f"{destination_id}"}, body=json.dumps({"content": f"{content}"}) ) def send_chat_room_typing_notification(conn, own_id, destination_id, room_id): conn.send(destination="/app/chat/room", content_type="application/json", headers={"messageType": "CHAT_ROOM_TYPING_NOTIFICATION", "destinationId": f"{destination_id}"}, body=json.dumps({"roomId": room_id, "senderNickname": PROFILE_NAME, "gxsId": {"bytes": f"{own_id['gxsId']['bytes']}"}, "content": ""}) ) def send_private_typing_notification(conn, destination_id): conn.send(destination="/app/chat/private", content_type="application/json", headers={"messageType": "CHAT_TYPING_NOTIFICATION", "destinationId": f"{destination_id}"}, body=json.dumps({"content": ""}) ) def strip_nickname_prefix(message, nickname): if message.startswith(nickname + ": "): return message[(len(nickname) + 2):] return message def evict_cache(messages): if (len(messages)) > INTERACTIONS * 2: messages.pop(0) messages.pop(0) def get_cache_messages(user_id): messages = CHAT_CACHE.get(user_id, list()) CHAT_CACHE[user_id] = messages return messages def create_query_for_openai_api(prompt, messages): model = { "messages": [ { "role": "system", "content": f"{prompt}" } ], "temperature": TEMPERATURE, "stream": True } if MODEL: model['model'] = MODEL idx = 0 for message in messages: model['messages'].append({ "role": "user" if idx % 2 == 0 else "assistant", "content": f"{message}" }) idx += 1 return model # user_id can be gxs or location_id, doesn't matter, it's for the cache def openai_api_send(message, assistant, user, user_id, _callback=None): print(f"<{user}> {assistant}: {message}") start = time.time() _callback() messages = get_cache_messages(user_id) messages.append(message) prompt = PROMPT.format(assistant=assistant, user=user) query = create_query_for_openai_api(prompt, messages) # print(f"Query: {query}") r = requests.post(OPENAI_URL, json=query, stream=True) if r.status_code != 200: raise RuntimeError(f"Couldn't send message to openai API server ({r.status_code}): {r.text}") output = "" for line in r.iter_lines(): if line: line = line.decode("utf-8") if line == "data: [DONE]": break line = remove_prefix(line, "data: ") o = json.loads(line) if 'content' in o['choices'][0]['delta']: output += o['choices'][0]['delta']['content'] if _callback and time.time() - start > 5.0: _callback() start = time.time() response = strip_nickname_prefix(output, assistant) # idiot AI sometimes inserts itself in the reply print(f"<{assistant}> {user}: {response}") messages.append(response) evict_cache(messages) return response def remove_prefix(text, prefix): if text.startswith(prefix): return text[len(prefix):] return text if not has_profile(): create_profile() create_location() create_identity() if os.path.isfile(AVATAR): upload_avatar(AVATAR) for friend in FRIEND_IDS: add_friend(friend) print("Xeres Bot v1.0\n") own_id = get_own_identity() print(f"I am {own_id.get('name')}") print(f"This is my RS ID (paste it in friends I have to connect to):\n{get_own_rsid()}") synchronize_chatrooms(ROOM_NAMES) print("Ready and awaiting to be addressed to.") handle_chat(own_id) ================================================ FILE: scripts/bot/requirements.txt ================================================ cachetools==7.1.1 certifi==2026.4.22 charset-normalizer==3.4.7 docopt==0.6.2 idna==3.15 requests==2.34.2 stomp.py==9.0.0 urllib3==2.7.0 websocket-client==1.9.0 ================================================ FILE: scripts/helper/i18n_find_dupe.py ================================================ #!/usr/bin/env python3 # Copyright (c) 2026 by David Gerber - https://zapek.com # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . # # This file is part of Xeres. # # Xeres is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Xeres is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Xeres. If not, see . def find_duplicate_lines(filename): """ Find duplicate lines in property files. Useful for checking internationalization files. Args: filename (str): Path to the input file Returns: dict: Dictionary with duplicate values as keys and list of line numbers as values """ # Dictionary to store values and their line numbers values_dict = {} try: with open(filename, 'r') as file: for line_num, line in enumerate(file, 1): line = line.strip() # Skip empty lines if not line: continue # Find the '=' sign and get the value after it if '=' in line: # Split on first '=' to get the value part parts = line.split('=', 1) if len(parts) == 2: value = parts[1].strip() # Store line numbers for each value if value not in values_dict: values_dict[value] = [] values_dict[value].append(line_num) except FileNotFoundError: print(f"Error: File '{filename}' not found.") return {} except Exception as e: print(f"Error reading file: {e}") return {} # Filter to only show duplicates (values that appear more than once) duplicates = {value: lines for value, lines in values_dict.items() if len(lines) > 1} return duplicates def main(): # Get filename from user filename = input("Enter the filename: ").strip() # Find duplicates duplicates = find_duplicate_lines(filename) # Display results if duplicates: print("\nDuplicate values found:") print("-" * 50) for value, line_numbers in duplicates.items(): print(f"Value: '{value}'") print(f"Lines: {', '.join(map(str, line_numbers))}") print() else: print("No duplicate values found.") if __name__ == "__main__": main() ================================================ FILE: settings.gradle ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ plugins { id 'com.gradle.develocity' version '4.4.1' } develocity { buildScan { publishing.onlyIf { System.getenv("CI") != null } termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use" termsOfUseAgree = "yes" tag "CI" uploadInBackground = false } } rootProject.name = 'Xeres' include 'ui' include 'app' include 'common' ================================================ FILE: transifex.yml ================================================ git: filters: - filter_type: file file_format: UNICODEPROPERTIES source_language: en source_file: common/src/main/resources/i18n/messages.properties translation_files_expression: 'common/src/main/resources/i18n/messages_.properties' ================================================ FILE: ui/build.gradle ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { id 'org.openjfx.javafxplugin' version '0.0.14' id 'com.bakdata.mockito' } javafx { version = "26.0.1" modules = ['javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.media'] } test { useJUnitPlatform() test.jvmArgs "-ea", "-Djava.net.preferIPv4Stack=true", "-Dfile.encoding=UTF-8", "-Djava.awt.headless=true", "-Dtestfx.robot=glass", "-Dtestfx.headless=true", "-Dprism.order=sw", "-Dprism.verbose=true" } jacocoTestReport { reports { xml.required = true html.required = false } } javadoc { options.overview = "src/main/javadoc/overview.html" } dependencies { implementation(platform(SpringBootPlugin.BOM_COORDINATES)) implementation project(':common') // Always keep the following in sync with the app module implementation('org.springframework.boot:spring-boot-starter-webclient') { exclude group: 'io.netty', module: 'netty-transport-native-epoll' exclude group: 'io.netty', module: 'netty-codec-native-quic' } implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'commons-io:commons-io:2.22.0' implementation 'net.rgielen:javafx-weaver-spring-boot-starter:2.0.1' implementation 'tools.jackson.datatype:jackson-datatype-jakarta-jsonp' implementation 'org.fxmisc.flowless:flowless:0.7.4' implementation "org.apache.commons:commons-lang3:$apacheCommonsLangVersion" implementation "org.apache.commons:commons-collections4:$apacheCommonsCollectionsVersion" implementation "org.jsoup:jsoup:$jsoupVersion" implementation 'com.github.sarxos:webcam-capture:0.3.12' implementation "com.google.zxing:javase:$zxingVersion" implementation 'io.github.mkpaz:atlantafx-base:2.1.0' implementation "net.java.dev.jna:jna-platform:$jnaVersion" implementation platform('org.kordamp.ikonli:ikonli-bom:12.4.0') implementation 'org.kordamp.ikonli:ikonli-javafx' implementation 'org.kordamp.ikonli:ikonli-materialdesign2-pack' implementation "org.commonmark:commonmark:$commonMarkVersion" implementation "org.commonmark:commonmark-ext-autolink:$commonMarkVersion" implementation "org.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion" testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: "com.vaadin.external.google", module: "android-json" } testImplementation(testFixtures(project(":common"))) testImplementation 'org.testfx:testfx-core:4.0.18' testImplementation 'org.testfx:testfx-junit5:4.0.18' testImplementation 'org.testfx:openjfx-monocle:21.0.2' testImplementation "com.tngtech.archunit:archunit-junit5:$archunitVersion" } ================================================ FILE: ui/src/main/java/io/xeres/ui/JavaFxApplication.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui; import io.xeres.common.mui.MUI; import io.xeres.ui.event.StageReadyEvent; import io.xeres.ui.support.util.UiUtils; import javafx.application.Application; import javafx.application.HostServices; import javafx.stage.Stage; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.support.GenericApplicationContext; import java.util.Objects; /** * This is only executed in UI mode (that is, without the --no-gui flag). */ public class JavaFxApplication extends Application { private ConfigurableApplicationContext springContext; private static Class springApplicationClass; static void start(Class springApplicationClass, String[] args) { JavaFxApplication.springApplicationClass = springApplicationClass; Application.launch(JavaFxApplication.class, args); } @Override public void init() { try { springContext = new SpringApplicationBuilder() .sources(springApplicationClass) .headless(false) // JavaFX defaults to true, which is not what we want .initializers(initializers()) .run(getParameters().getRaw().toArray(new String[0])); } catch (Exception e) { MUI.showError(e); System.exit(1); } } @Override public void start(Stage primaryStage) { Objects.requireNonNull(springContext); // This allows all JavaFX crashes to show up in the logger instead of stdout Thread.setDefaultUncaughtExceptionHandler(JavaFxApplication::handleException); springContext.publishEvent(new StageReadyEvent(primaryStage)); } /** * Registers HostServices as a bean. * * @return the ApplicationContextInitializer. */ private ApplicationContextInitializer initializers() { return ac -> ac.registerBean(HostServices.class, this::getHostServices); } @Override public void stop() { springContext.close(); } private static void handleException(Thread thread, Throwable throwable) { UiUtils.webAlertError(throwable); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/PrimaryStageInitializer.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui; import io.xeres.common.events.ConnectWebSocketsEvent; import io.xeres.common.properties.StartupProperties; import io.xeres.ui.client.ProfileClient; import io.xeres.ui.client.message.*; import io.xeres.ui.controller.chat.ChatViewController; import io.xeres.ui.event.StageReadyEvent; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import org.apache.commons.lang3.SystemUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClientRequestException; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Hooks; import static io.xeres.common.message.MessagePath.*; import static io.xeres.common.properties.StartupProperties.Property.ICONIFIED; import static io.xeres.common.properties.StartupProperties.Property.UI; @Component public class PrimaryStageInitializer { private static final Logger log = LoggerFactory.getLogger(PrimaryStageInitializer.class); private final WindowManager windowManager; private final ChatViewController chatViewController; private final ProfileClient profileClient; private final MessageClient messageClient; public PrimaryStageInitializer(WindowManager windowManager, ChatViewController chatViewController, ProfileClient profileClient, MessageClient messageClient) { this.windowManager = windowManager; this.chatViewController = chatViewController; this.profileClient = profileClient; this.messageClient = messageClient; } @EventListener public void onApplicationEvent(StageReadyEvent event) { Hooks.onErrorDropped(throwable -> log.debug("WebClient warning: {}", throwable.getMessage())); // Suppress Reactor's error messages // Do not exit the platform when all windows are closed. Platform.setImplicitExit(false); profileClient.getOwn() .doFirst(() -> Platform.runLater(() -> { if (SystemUtils.IS_OS_MAC) { // This is needed because of https://bugs.openjdk.org/browse/JDK-8248127 // "AppKit Thread" has a null class loader which prevents resources from being loaded so // we have to set it. The AppKit Thread seems to be related with AWT, so as soon as either // a splash screen or a systray is being used, it will be there. if (Thread.currentThread().getContextClassLoader() == null) { Thread.currentThread().setContextClassLoader(PrimaryStageInitializer.class.getClassLoader()); } } windowManager.calculateWindowDecorationSizes(event.getStage()); })) .doOnSuccess(profile -> windowManager.openMain(event.getStage(), profile, StartupProperties.getBoolean(ICONIFIED, false))) .doOnError(WebClientResponseException.class, e -> { if (e.getStatusCode() == HttpStatus.NOT_FOUND) { windowManager.openAccountCreation(event.getStage()); } }) .doOnError(WebClientRequestException.class, e -> UiUtils.webAlertError(e, Platform::exit)) .subscribe(); } @EventListener public void onNetworkReadyEvent(ConnectWebSocketsEvent unused) { if (!StartupProperties.getBoolean(UI, true)) { return; } if (messageClient.isConnected()) { return; } messageClient .subscribe(chatPrivateDestination(), new PrivateChatFrameHandler(windowManager)) .subscribe(chatRoomDestination(), new ChatRoomFrameHandler(chatViewController)) .subscribe(chatDistantDestination(), new DistantChatFrameHandler(windowManager)) .subscribe(chatBroadcastDestination(), new BroadcastChatFrameHandler()) .subscribe(voipPrivateDestination(), new VoipFrameHandler(windowManager)) .connect(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/UiStarter.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui; public final class UiStarter { private UiStarter() { throw new UnsupportedOperationException("Utility class"); } public static void start(Class springApplicationClass, String[] args) { JavaFxApplication.start(springApplicationClass, args); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/BoardClient.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.board.BoardGroupDTO; import io.xeres.common.dto.board.BoardMessageDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.board.UpdateBoardMessageReadRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.board.BoardGroup; import io.xeres.ui.model.board.BoardMapper; import io.xeres.ui.model.board.BoardMessage; import io.xeres.ui.support.util.ClientUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.context.event.EventListener; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.File; import static io.xeres.common.rest.PathConfig.BOARDS_PATH; @Component public class BoardClient implements GxsGroupClient, GxsMessageClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public BoardClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + BOARDS_PATH) .build(); } @Override public Flux getGroups() { return webClient.get() .uri("/groups") .retrieve() .bodyToFlux(BoardGroupDTO.class) .map(BoardMapper::fromDTO); } public Mono createBoardGroup(String name, String description, File image) { var builder = ClientUtils.createGroupBuilder(name, description, image); return webClient.post() .uri("/groups") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchangeToMono(ClientUtils::getCreatedId); } public Mono updateBoardGroup(long groupId, String name, String description, File image, boolean updateImage) { var builder = ClientUtils.createGroupBuilder(name, description, image); if (updateImage) { builder.part("updateImage", true); } return webClient.put() .uri("/groups/{groupId}", groupId) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .retrieve() .bodyToMono(Void.class); } public Mono getBoardGroupById(long groupId) { return webClient.get() .uri("/groups/{groupId}", groupId) .retrieve() .bodyToMono(BoardGroupDTO.class) .map(BoardMapper::fromDTO); } @Override public Mono getUnreadCount(long groupId) { return webClient.get() .uri("/groups/{groupId}/unread-count", groupId) .retrieve() .bodyToMono(Integer.class); } @Override public Mono subscribeToGroup(long groupId) { return webClient.put() .uri("/groups/{groupId}/subscription", groupId) .retrieve() .bodyToMono(Void.class); } @Override public Mono unsubscribeFromGroup(long groupId) { return webClient.delete() .uri("/groups/{groupId}/subscription", groupId) .retrieve() .bodyToMono(Void.class); } @Override public Mono setGroupMessagesReadState(long groupId, boolean read) { return webClient.put() .uri(uriBuilder -> uriBuilder .path("/groups/{groupId}/read") .queryParam("read", read) .build(groupId)) .retrieve() .bodyToMono(Void.class); } @Override public Mono> getMessages(long groupId, int page, int size) { return webClient.get() .uri(uriBuilder -> uriBuilder .path("/groups/{groupId}/messages") .queryParam("page", page) .queryParam("size", size) .queryParam("sort", "published,desc") .build(groupId)) .retrieve() .bodyToMono(new ParameterizedTypeReference>() { }) .map(BoardMapper::fromDTO); } public Mono getBoardMessage(long messageId) { return webClient.get() .uri("/messages/{messageId}", messageId) .retrieve() .bodyToMono(BoardMessageDTO.class) .map(BoardMapper::fromDTO); } public Mono createBoardMessage(long boardId, String title, String content, String link, File image) { var builder = new MultipartBodyBuilder(); if (boardId == 0L) { throw new IllegalArgumentException("BoardId is required"); } builder.part("boardId", boardId); if (StringUtils.isBlank(title)) { throw new IllegalArgumentException("Title is required"); } builder.part("title", title); if (StringUtils.isNotBlank(content)) { builder.part("content", content); } if (StringUtils.isNotBlank(link)) { builder.part("link", link); } if (image != null) { builder.part("image", new FileSystemResource(image)); } return webClient.post() .uri("/messages") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchangeToMono(ClientUtils::getCreatedId); } public Mono setBoardMessageReadState(long messageId, boolean read) { var request = new UpdateBoardMessageReadRequest(messageId, read); return webClient.patch() .uri("/messages") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ChannelClient.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.channel.ChannelGroupDTO; import io.xeres.common.dto.channel.ChannelMessageDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.channel.UpdateChannelMessageReadRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.channel.ChannelFile; import io.xeres.ui.model.channel.ChannelGroup; import io.xeres.ui.model.channel.ChannelMapper; import io.xeres.ui.model.channel.ChannelMessage; import io.xeres.ui.support.util.ClientUtils; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.context.event.EventListener; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.File; import java.util.List; import static io.xeres.common.rest.PathConfig.CHANNELS_PATH; import static io.xeres.ui.model.channel.ChannelMapper.toChannelFileDTOs; @Component public class ChannelClient implements GxsGroupClient, GxsMessageClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ChannelClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + CHANNELS_PATH) .build(); } @Override public Flux getGroups() { return webClient.get() .uri("/groups") .retrieve() .bodyToFlux(ChannelGroupDTO.class) .map(ChannelMapper::fromDTO); } public Mono createChannelGroup(String name, String description, File image) { var builder = ClientUtils.createGroupBuilder(name, description, image); return webClient.post() .uri("/groups") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchangeToMono(ClientUtils::getCreatedId); } public Mono updateChannelGroup(long groupId, String name, String description, File image, boolean updateImage) { var builder = ClientUtils.createGroupBuilder(name, description, image); if (updateImage) { builder.part("updateImage", true); } return webClient.put() .uri("/groups/{groupId}", groupId) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .retrieve() .bodyToMono(Void.class); } public Mono getChannelGroupById(long groupId) { return webClient.get() .uri("/groups/{groupId}", groupId) .retrieve() .bodyToMono(ChannelGroupDTO.class) .map(ChannelMapper::fromDTO); } @Override public Mono getUnreadCount(long groupId) { return webClient.get() .uri("/groups/{groupId}/unread-count", groupId) .retrieve() .bodyToMono(Integer.class); } @Override public Mono subscribeToGroup(long groupId) { return webClient.put() .uri("/groups/{groupId}/subscription", groupId) .retrieve() .bodyToMono(Void.class); } @Override public Mono unsubscribeFromGroup(long groupId) { return webClient.delete() .uri("/groups/{groupId}/subscription", groupId) .retrieve() .bodyToMono(Void.class); } @Override public Mono setGroupMessagesReadState(long groupId, boolean read) { return webClient.put() .uri(uriBuilder -> uriBuilder .path("/groups/{groupId}/read") .queryParam("read", read) .build(groupId)) .retrieve() .bodyToMono(Void.class); } @Override public Mono> getMessages(long groupId, int page, int size) { return webClient.get() .uri(uriBuilder -> uriBuilder .path("/groups/{groupId}/messages") .queryParam("page", page) .queryParam("size", size) .queryParam("sort", "published,desc") .build(groupId)) .retrieve() .bodyToMono(new ParameterizedTypeReference>() { }) .map(ChannelMapper::fromDTO); } public Mono getChannelMessage(long messageId) { return webClient.get() .uri("/messages/{messageId}", messageId) .retrieve() .bodyToMono(ChannelMessageDTO.class) .map(ChannelMapper::fromDTO); } public Mono createChannelMessage(long channelId, String title, String content, File image, List files, long originalId) { var builder = new MultipartBodyBuilder(); if (channelId == 0L) { throw new IllegalArgumentException("ChannelId is required"); } builder.part("channelId", channelId); if (StringUtils.isBlank(title)) { throw new IllegalArgumentException("Title is required"); } builder.part("title", title); if (StringUtils.isNotBlank(content)) { builder.part("content", content); } if (image != null) { builder.part("image", new FileSystemResource(image)); } if (CollectionUtils.isNotEmpty(files)) { builder.part("files", toChannelFileDTOs(files), MediaType.APPLICATION_JSON); } if (originalId != 0L) { builder.part("originalId", originalId); } return webClient.post() .uri("/messages") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchangeToMono(ClientUtils::getCreatedId); } public Mono setChannelMessageReadState(long messageId, boolean read) { var request = new UpdateChannelMessageReadRequest(messageId, read); return webClient.patch() .uri("/messages") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ChatClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.chat.ChatBacklogDTO; import io.xeres.common.dto.chat.ChatRoomBacklogDTO; import io.xeres.common.dto.chat.ChatRoomContextDTO; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.chat.ChatBacklog; import io.xeres.common.message.chat.ChatRoomBacklog; import io.xeres.common.message.chat.ChatRoomContext; import io.xeres.common.rest.chat.ChatRoomVisibility; import io.xeres.common.rest.chat.CreateChatRoomRequest; import io.xeres.common.rest.chat.DistantChatRequest; import io.xeres.common.rest.chat.InviteToChatRoomRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.chat.ChatMapper; import io.xeres.ui.model.location.Location; import io.xeres.ui.model.location.LocationMapper; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.util.Set; import java.util.stream.Collectors; import static io.xeres.common.rest.PathConfig.CHAT_PATH; @Component public class ChatClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ChatClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + CHAT_PATH) .build(); } public Mono createChatRoom(String name, String topic, ChatRoomVisibility visibility, boolean signedIdentities) { var request = new CreateChatRoomRequest(name, topic, visibility, signedIdentities); return webClient.post() .uri("/rooms") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } public Mono createDistantChat(long identityId) { var request = new DistantChatRequest(identityId); return webClient.post() .uri("/distant-chats") .bodyValue(request) .retrieve() .bodyToMono(LocationDTO.class) .map(LocationMapper::fromDTO); } public Mono closeDistantChat(long identityId) { return webClient.delete() .uri("/distant-chats/{id}", identityId) .retrieve() .bodyToMono(Void.class); } public Mono joinChatRoom(long id) { return webClient.put() .uri("/rooms/{id}/subscription", id) .retrieve() .bodyToMono(Void.class); } public Mono leaveChatRoom(long id) { return webClient.delete() .uri("/rooms/{id}/subscription", id) .retrieve() .bodyToMono(Void.class); } public Mono getChatRoomContext() { return webClient.get() .uri("/rooms") .retrieve() .bodyToMono(ChatRoomContextDTO.class) .map(ChatMapper::fromDTO); } public Mono inviteLocationsToChatRoom(long chatRoomId, Set locations) { var request = new InviteToChatRoomRequest(chatRoomId, locations.stream() .map(Location::getLocationIdentifier) .map(LocationIdentifier::toString) .collect(Collectors.toSet())); return webClient.post() .uri("/rooms/invite") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } public Flux getChatRoomBacklog(long id) { return webClient.get() .uri("/rooms/{roomId}/messages", id) .retrieve() .bodyToFlux(ChatRoomBacklogDTO.class) .map(ChatMapper::fromDTO); } public Mono deleteChatRoomBacklog(long id) { return webClient.delete() .uri("/rooms/{roomId}/messages", id) .retrieve() .bodyToMono(Void.class); } public Flux getChatBacklog(long id) { return webClient.get() .uri("/chats/{locationId}/messages", id) .retrieve() .bodyToFlux(ChatBacklogDTO.class) .map(ChatMapper::fromDTO); } public Mono deleteChatBacklog(long id) { return webClient.delete() .uri("/chats/{locationId}/messages", id) .retrieve() .bodyToMono(Void.class); } public Flux getDistantChatBacklog(long id) { return webClient.get() .uri("/distant-chats/{identityId}/messages", id) .retrieve() .bodyToFlux(ChatBacklogDTO.class) .map(ChatMapper::fromDTO); } public Mono deleteDistantChatBacklog(long id) { return webClient.delete() .uri("/distant-chats/{identityId}/messages", id) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ConfigClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; import io.xeres.common.location.Availability; import io.xeres.common.rest.config.*; import io.xeres.common.util.RemoteUtils; import org.springframework.context.event.EventListener; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.File; import java.util.Set; import static io.xeres.common.rest.PathConfig.CONFIG_PATH; import static io.xeres.ui.support.util.ClientUtils.fromFile; @Component public class ConfigClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ConfigClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + CONFIG_PATH) .build(); } public Mono createProfile(String name) { var profileRequest = new OwnProfileRequest(name); return webClient.post() .uri("/profile") .bodyValue(profileRequest) .retrieve() .bodyToMono(Void.class); } public Mono createLocation(String name) { var locationRequest = new OwnLocationRequest(name); return webClient.post() .uri("/location") .bodyValue(locationRequest) .retrieve() .bodyToMono(Void.class); } public Mono createIdentity(String name, boolean anonymous) { var identityRequest = new OwnIdentityRequest(name, anonymous); return webClient.post() .uri("/identity") .bodyValue(identityRequest) .retrieve() .bodyToMono(Void.class); } public Mono changeAvailability(Availability availability) { return webClient.put() .uri("/location/availability") .bodyValue(availability) .retrieve() .bodyToMono(Void.class); } public Mono getExternalIpAddress() { return webClient.get() .uri("/external-ip") .retrieve() .bodyToMono(IpAddressResponse.class); } public Mono getInternalIpAddress() { return webClient.get() .uri("/internal-ip") .retrieve() .bodyToMono(IpAddressResponse.class); } public Mono getHostname() { return webClient.get() .uri("/hostname") .retrieve() .bodyToMono(HostnameResponse.class); } public Mono getUsername() { return webClient.get() .uri("/username") .retrieve() .bodyToMono(UsernameResponse.class); } public Mono> getCapabilities() { return webClient.get() .uri("/capabilities") .retrieve() .bodyToMono(new ParameterizedTypeReference<>() { }); } public Flux getBackup() { return webClient.get() .uri("/export") .retrieve() .bodyToFlux(DataBuffer.class); } public Mono sendBackup(File file) { return webClient.post() .uri("/import") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(fromFile(file))) .retrieve() .bodyToMono(Void.class); } public Mono sendRsKeyring(File file, String locationName, String password) { return webClient.post() .uri(uriBuilder -> uriBuilder .path("/import-profile-from-rs") .queryParam("locationName", locationName) .queryParam("password", password) .build()) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(fromFile(file))) .retrieve() .bodyToMono(Void.class); } public Mono sendRsFriends(File file) { return webClient.post() .uri("/import-friends-from-rs") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(fromFile(file))) .retrieve() .bodyToMono(ImportRsFriendsResponse.class); } public Mono verifyUpdate(String filePath, byte[] signature) { var request = new VerifyUpdateRequest(filePath, signature); return webClient.post() .uri("/verify-update") .bodyValue(request) .retrieve() .bodyToMono(Boolean.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ConnectionClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.profile.ProfileDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.rest.connection.ConnectionRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.profile.Profile; import io.xeres.ui.model.profile.ProfileMapper; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import static io.xeres.common.rest.PathConfig.CONNECTIONS_PATH; @Component public class ConnectionClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ConnectionClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + CONNECTIONS_PATH) .build(); } public Flux getConnectedProfiles() { return webClient.get() .uri("/profiles") .retrieve() .bodyToFlux(ProfileDTO.class) .map(ProfileMapper::fromDeepDTO); } public Mono connect(LocationIdentifier locationIdentifier, int connectionIndex) { var connectionRequest = new ConnectionRequest(locationIdentifier.toString(), connectionIndex); return webClient.put() .uri("/connect") .bodyValue(connectionRequest) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ContactClient.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.contact.Contact; import io.xeres.common.util.RemoteUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import static io.xeres.common.rest.PathConfig.CONTACT_PATH; @Component public class ContactClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ContactClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + CONTACT_PATH) .build(); } public Flux getContacts() { return webClient.get() .uri("") .retrieve() .bodyToFlux(Contact.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/FileClient.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; import io.xeres.common.rest.file.FileDownloadRequest; import io.xeres.common.rest.file.FileProgress; import io.xeres.common.rest.file.FileSearchRequest; import io.xeres.common.rest.file.FileSearchResponse; import io.xeres.common.util.RemoteUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import static io.xeres.common.rest.PathConfig.FILES_PATH; @Component public class FileClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public FileClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + FILES_PATH) .build(); } public Mono search(String name) { var request = new FileSearchRequest(name); return webClient.post() .uri("/search") .bodyValue(request) .retrieve() .bodyToMono(FileSearchResponse.class); } public Mono download(String name, Sha1Sum hash, long size, LocationIdentifier locationIdentifier) { var request = new FileDownloadRequest(name, hash.toString(), size, locationIdentifier); return webClient.post() .uri("/download") .bodyValue(request) .retrieve() .bodyToMono(Long.class); } public Flux getDownloads() { return webClient.get() .uri("/downloads") .retrieve() .bodyToFlux(FileProgress.class); } public Flux getUploads() { return webClient.get() .uri("/uploads") .retrieve() .bodyToFlux(FileProgress.class); } public Mono removeDownload(long id) { return webClient.delete() .uri("/downloads/{id}", id) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ForumClient.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.forum.ForumGroupDTO; import io.xeres.common.dto.forum.ForumMessageDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.forum.CreateForumMessageRequest; import io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest; import io.xeres.common.rest.forum.UpdateForumMessageReadRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.forum.ForumGroup; import io.xeres.ui.model.forum.ForumMapper; import io.xeres.ui.model.forum.ForumMessage; import org.springframework.context.event.EventListener; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import static io.xeres.common.rest.PathConfig.FORUMS_PATH; @Component public class ForumClient implements GxsGroupClient, GxsMessageClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ForumClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + FORUMS_PATH) .build(); } @Override public Flux getGroups() { return webClient.get() .uri("/groups") .retrieve() .bodyToFlux(ForumGroupDTO.class) .map(ForumMapper::fromDTO); } public Mono createForumGroup(String name, String description) { var request = new CreateOrUpdateForumGroupRequest(name, description); return webClient.post() .uri("/groups") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } public Mono updateForumGroup(long groupId, String name, String description) { var request = new CreateOrUpdateForumGroupRequest(name, description); return webClient.put() .uri("/groups/{groupId}", groupId) .bodyValue(request) .retrieve() .bodyToMono(Void.class); } public Mono getForumGroupById(long groupId) { return webClient.get() .uri("/groups/{groupId}", groupId) .retrieve() .bodyToMono(ForumGroupDTO.class) .map(ForumMapper::fromDTO); } @Override public Mono getUnreadCount(long groupId) { return webClient.get() .uri("/groups/{groupId}/unread-count", groupId) .retrieve() .bodyToMono(Integer.class); } @Override public Mono subscribeToGroup(long groupId) { return webClient.put() .uri("/groups/{groupId}/subscription", groupId) .retrieve() .bodyToMono(Void.class); } @Override public Mono unsubscribeFromGroup(long groupId) { return webClient.delete() .uri("/groups/{groupId}/subscription", groupId) .retrieve() .bodyToMono(Void.class); } @Override public Mono setGroupMessagesReadState(long groupId, boolean read) { return webClient.put() .uri(uriBuilder -> uriBuilder .path("/groups/{groupId}/read") .queryParam("read", read) .build(groupId)) .retrieve() .bodyToMono(Void.class); } @Override public Mono> getMessages(long groupId, int page, int size) { return webClient.get() .uri(uriBuilder -> uriBuilder .path("/groups/{groupId}/messages") .queryParam("page", page) .queryParam("size", size) .queryParam("sort", "published,desc") .build(groupId)) .retrieve() .bodyToMono(new ParameterizedTypeReference>() { }) .map(ForumMapper::fromDTO); } public Mono getForumMessage(long messageId) { return webClient.get() .uri("/messages/{messageId}", messageId) .retrieve() .bodyToMono(ForumMessageDTO.class) .map(ForumMapper::fromDTO); } public Mono createForumMessage(long forumId, String title, String content, long parentId, long originalId) { var request = new CreateForumMessageRequest(forumId, title, content, parentId, originalId); return webClient.post() .uri("/messages") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } public Mono setForumMessageReadState(long messageId, boolean read) { var request = new UpdateForumMessageReadRequest(messageId, read); return webClient.patch() .uri("/messages") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/GeneralClient.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; import io.xeres.common.util.RemoteUtils; import org.springframework.context.event.EventListener; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; /** * A WebClient that has no specific API root and is not restricted to one domain in * particular. *

* You should use domain related web clients when possible. */ @Component public class GeneralClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public GeneralClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl()) .build(); } public Mono getImage(String path) { return webClient.get() .uri(path) .accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG, MediaType.parseMediaType("image/webp")) .retrieve() .bodyToMono(byte[].class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/GeoIpClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.geoip.CountryResponse; import io.xeres.common.util.RemoteUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import static io.xeres.common.rest.PathConfig.GEOIP_PATH; @Component public class GeoIpClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public GeoIpClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + GEOIP_PATH) .build(); } public Mono getIsoCountry(String ip) { return webClient.get() .uri("/{ip}", ip) .retrieve() .bodyToMono(CountryResponse.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/GxsGroupClient.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public interface GxsGroupClient { Flux getGroups(); Mono getUnreadCount(long groupId); Mono subscribeToGroup(long groupId); Mono unsubscribeFromGroup(long groupId); Mono setGroupMessagesReadState(long groupId, boolean read); } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/GxsMessageClient.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import reactor.core.publisher.Mono; public interface GxsMessageClient { Mono> getMessages(long groupId, int page, int size); } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/IdentityClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.identity.IdentityDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.id.GxsId; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.identity.Identity; import io.xeres.ui.model.identity.IdentityMapper; import org.springframework.context.event.EventListener; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.File; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; import static io.xeres.ui.support.util.ClientUtils.fromFile; @Component public class IdentityClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public IdentityClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + IDENTITIES_PATH) .build(); } public Flux getIdentities() { return webClient.get() .uri("") .retrieve() .bodyToFlux(IdentityDTO.class) .map(IdentityMapper::fromDTO); } public Mono findById(long id) { return webClient.get() .uri("/{id}", id) .retrieve() .bodyToMono(IdentityDTO.class) .map(IdentityMapper::fromDTO); } public Flux findByGxsId(GxsId gxsId) { return webClient.get() .uri(uriBuilder -> uriBuilder .path("") .queryParam("gxsId", gxsId.toString()) .build()) .retrieve() .bodyToFlux(IdentityDTO.class) .map(IdentityMapper::fromDTO); } public Mono uploadIdentityImage(long id, File file) { return webClient.post() .uri("/{id}/image", id) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(fromFile(file))) .retrieve() .bodyToMono(Void.class); } public Mono deleteIdentityImage(long id) { return webClient.delete() .uri("/{id}/image", id) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/LocationClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.location.RSIdResponse; import io.xeres.common.rsid.Type; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.location.Location; import io.xeres.ui.model.location.LocationMapper; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import static io.xeres.common.rest.PathConfig.LOCATIONS_PATH; @Component public class LocationClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public LocationClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + LOCATIONS_PATH) .build(); } public Mono findById(long id) { return webClient.get() .uri("/{id}", id) .retrieve() .bodyToMono(LocationDTO.class) .map(LocationMapper::fromDTO); } public Mono getRSId(long id, Type type) { return webClient.get() .uri(uriBuilder -> uriBuilder .path("/{id}/rs-id") .queryParam("type", type) .build(id)) .retrieve() .bodyToMono(RSIdResponse.class); } public Mono isServiceSupported(long id, int serviceId) { return webClient.get() .uri("/{id}/service/{serviceId}", id, serviceId) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/NotificationClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.notification.availability.AvailabilityChange; import io.xeres.common.rest.notification.board.BoardNotification; import io.xeres.common.rest.notification.channel.ChannelNotification; import io.xeres.common.rest.notification.contact.ContactNotification; import io.xeres.common.rest.notification.file.FileNotification; import io.xeres.common.rest.notification.file.FileSearchNotification; import io.xeres.common.rest.notification.file.FileTrendNotification; import io.xeres.common.rest.notification.forum.ForumNotification; import io.xeres.common.rest.notification.status.StatusNotification; import io.xeres.common.util.RemoteUtils; import org.springframework.context.event.EventListener; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.codec.ServerSentEvent; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import static io.xeres.common.rest.PathConfig.NOTIFICATIONS_PATH; @Component public class NotificationClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public NotificationClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + NOTIFICATIONS_PATH) .build(); } public Flux> getStatusNotifications() { return webClient.get() .uri("/status") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getForumNotifications() { return webClient.get() .uri("/forum") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getBoardNotifications() { return webClient.get() .uri("/board") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getChannelNotifications() { return webClient.get() .uri("/channel") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getFileNotifications() { return webClient.get() .uri("/file") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getFileSearchNotifications() { return webClient.get() .uri("/file-search") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getFileTrendNotifications() { return webClient.get() .uri("/file-trend") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getContactNotifications() { return webClient.get() .uri("/contact") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } public Flux> getAvailabilityNotifications() { return webClient.get() .uri("/availability") .retrieve() .bodyToFlux(new ParameterizedTypeReference<>() { }); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/PaginatedResponse.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import org.apache.commons.collections4.ListUtils; import java.util.List; /** * Paginated response. * * @param content the page content as a {@link List} * @param page the page values * @param the element's type */ public record PaginatedResponse( List content, PaginatedPage page ) { /** * The paginated response page values. * * @param totalElements the total amount of elements * @param totalPages the number of total pages * @param number the number of the current page. Is always non-negative. * @param size the size of the page */ public record PaginatedPage(int totalElements, int totalPages, int number, int size) { } /** * Checks if the page has any content at all. * * @return true if the page has no content at all */ public boolean empty() { return ListUtils.emptyIfNull(content).isEmpty(); } /** * Checks if the page is the first one. * * @return true if the page is the first one */ public boolean first() { return page.number == 0; } /** * Checks if the page is the last one. * * @return true if the page is the last one */ public boolean last() { return page.number == page.totalPages; } /** * Gets the number of elements in the page. * * @return the number of elements in the current page. */ public int numberOfElements() { return ListUtils.emptyIfNull(content).size(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ProfileClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.profile.ProfileDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.pgp.Trust; import io.xeres.common.rest.contact.Contact; import io.xeres.common.rest.profile.ProfileKeyAttributes; import io.xeres.common.rest.profile.RsIdRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.profile.Profile; import io.xeres.ui.model.profile.ProfileMapper; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID; import static io.xeres.common.rest.PathConfig.PROFILES_PATH; @Component public class ProfileClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ProfileClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + PROFILES_PATH) .build(); } public Mono create(String rsId, int connectionIndex, Trust trust) { var rsIdRequest = new RsIdRequest(rsId); return webClient.post() .uri(uriBuilder -> uriBuilder .path("") .queryParam("connectionIndex", connectionIndex) .queryParam("trust", trust.name()) .build()) .bodyValue(rsIdRequest) .retrieve() .bodyToMono(Void.class); } public Flux findAll() { return webClient.get() .uri("") .retrieve() .bodyToFlux(ProfileDTO.class) .map(ProfileMapper::fromDTO); } public Mono getOwn() { return findById(OWN_PROFILE_ID); } public Mono checkRsId(String rsId) { var rsIdRequest = new RsIdRequest(rsId); return webClient.post() .uri("/check") .bodyValue(rsIdRequest) .retrieve() .bodyToMono(ProfileDTO.class) .map(ProfileMapper::fromDeepDTO); } public Mono findById(long id) { return webClient.get() .uri("/{id}", id) .retrieve() .bodyToMono(ProfileDTO.class) .map(ProfileMapper::fromDeepDTO); } public Mono findProfileKeyAttributes(long id) { return webClient.get() .uri("/{id}/key-attributes", id) .retrieve() .bodyToMono(ProfileKeyAttributes.class); } public Flux findContactsForProfile(long id) { return webClient.get() .uri("/{id}/contacts", id) .retrieve() .bodyToFlux(Contact.class); } public Flux findByLocationIdentifier(LocationIdentifier locationIdentifier, boolean withLocations) { return webClient.get() .uri(uriBuilder -> uriBuilder .path("") .queryParam("locationIdentifier", locationIdentifier.toString()) .queryParam("withLocations", withLocations) .build()) .retrieve() .bodyToFlux(ProfileDTO.class) .map(ProfileMapper::fromDeepDTO); } public Flux findByPgpIdentifier(long pgpIdentifier, boolean withLocations) { return webClient.get() .uri(uriBuilder -> uriBuilder .path("") .queryParam("pgpIdentifier", Long.toUnsignedString(pgpIdentifier, 16)) .queryParam("withLocations", withLocations) .build()) .retrieve() .bodyToFlux(ProfileDTO.class) .map(ProfileMapper::fromDeepDTO); } public Mono setTrust(long id, Trust trust) { return webClient.put() .uri("/{id}/trust", id) .bodyValue(trust) .retrieve() .bodyToMono(Void.class); } public Mono delete(long id) { return webClient.delete() .uri("/{id}", id) .retrieve() .bodyToMono(Void.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/SettingsClient.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.settings.SettingsDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.settings.Settings; import io.xeres.ui.model.settings.SettingsMapper; import jakarta.json.Json; import jakarta.json.JsonValue; import org.springframework.context.event.EventListener; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import tools.jackson.databind.ObjectMapper; import static io.xeres.common.rest.PathConfig.SETTINGS_PATH; @Component public class SettingsClient { private final WebClient.Builder webClientBuilder; private final ObjectMapper objectMapper; private WebClient webClient; public SettingsClient(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { this.webClientBuilder = webClientBuilder; this.objectMapper = objectMapper; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + SETTINGS_PATH) .build(); } public Mono getSettings() { return webClient.get() .uri("") .retrieve() .bodyToMono(SettingsDTO.class) .map(SettingsMapper::fromDTO); } public Mono patchSettings(Settings originalSettings, Settings newSettings) { var target = objectMapper.convertValue(newSettings, JsonValue.class); var source = objectMapper.convertValue(originalSettings, JsonValue.class); var patch = Json.createDiff(source.asJsonObject(), target.asJsonObject()); return webClient.patch() .uri("") .contentType(MediaType.valueOf("application/json-patch+json")) .bodyValue(patch) .retrieve() .bodyToMono(SettingsDTO.class) .map(SettingsMapper::fromDTO); } public Mono putSettings(Settings newSettings) { return webClient.put() .uri("") .bodyValue(newSettings) .retrieve() .bodyToMono(SettingsDTO.class) .map(SettingsMapper::fromDTO); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/ShareClient.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.dto.share.ShareDTO; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.share.TemporaryShareRequest; import io.xeres.common.rest.share.TemporaryShareResponse; import io.xeres.common.rest.share.UpdateShareRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.share.Share; import io.xeres.ui.model.share.ShareMapper; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.util.List; import static io.xeres.common.rest.PathConfig.SHARES_PATH; @Component public class ShareClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public ShareClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + SHARES_PATH) .build(); } public Flux findAll() { return webClient.get() .uri("") .retrieve() .bodyToFlux(ShareDTO.class) .map(ShareMapper::fromDTO); } public Mono createAndUpdate(List shares) { var request = new UpdateShareRequest(ShareMapper.toDTOs(shares)); return webClient.post() .uri("") .bodyValue(request) .retrieve() .bodyToMono(Void.class); } public Mono createTemporaryShare(String filePath) { var request = new TemporaryShareRequest(filePath); return webClient.post() .uri("/temporary") .bodyValue(request) .retrieve() .bodyToMono(TemporaryShareResponse.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/StatisticsClient.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client; import io.xeres.common.events.StartupEvent; import io.xeres.common.rest.statistics.DataCounterStatisticsResponse; import io.xeres.common.rest.statistics.RttStatisticsResponse; import io.xeres.common.rest.statistics.TurtleStatisticsResponse; import io.xeres.common.util.RemoteUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import static io.xeres.common.rest.PathConfig.STATISTICS_PATH; @Component public class StatisticsClient { private final WebClient.Builder webClientBuilder; private WebClient webClient; public StatisticsClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl(RemoteUtils.getControlUrl() + STATISTICS_PATH) .build(); } public Mono getTurtleStatistics() { return webClient.get() .uri("/turtle") .retrieve() .bodyToMono(TurtleStatisticsResponse.class); } public Mono getRttStatistics() { return webClient.get() .uri("/rtt") .retrieve() .bodyToMono(RttStatisticsResponse.class); } public Mono getDataCounterStatistics() { return webClient.get() .uri("/data-counter") .retrieve() .bodyToMono(DataCounterStatisticsResponse.class); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/BroadcastChatFrameHandler.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import io.xeres.common.message.MessageType; import javafx.application.Platform; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import java.lang.reflect.Type; import static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE; /** * This handles the incoming broadcast messages from the server to the UI. * XXX: not used yet */ public class BroadcastChatFrameHandler implements StompFrameHandler { /** * Gets the payload type. It's not possible to use null or new Object(). It has to be a class * that is serializable by jackson. * * @param headers the headers * @return a type */ @Override public Type getPayloadType(StompHeaders headers) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); return switch (messageType) { case CHAT_BROADCAST_MESSAGE -> Void.class; default -> throw new IllegalStateException("Unexpected value: " + messageType); }; } @Override public void handleFrame(StompHeaders headers, Object payload) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); Platform.runLater(() -> { switch (messageType) { case CHAT_BROADCAST_MESSAGE -> { /* handled as a notification */ } default -> throw new IllegalStateException("Unexpected value: " + messageType); } } ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/ChatRoomFrameHandler.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.*; import io.xeres.ui.controller.chat.ChatViewController; import javafx.application.Platform; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import java.lang.reflect.Type; import java.util.Objects; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; import static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE; /** * This handles the incoming chat room messages from the server to the UI. */ public class ChatRoomFrameHandler implements StompFrameHandler { private final ChatViewController chatViewController; public ChatRoomFrameHandler(ChatViewController chatViewController) { this.chatViewController = chatViewController; } /** * Gets the payload type. It's not possible to use null or new Object(). It has to be a class * that is serializable by jackson. * * @param headers the headers * @return a type */ @Override public Type getPayloadType(StompHeaders headers) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); return switch (messageType) { case CHAT_ROOM_JOIN, CHAT_ROOM_LEAVE, CHAT_ROOM_MESSAGE, CHAT_ROOM_TYPING_NOTIFICATION -> ChatRoomMessage.class; case CHAT_ROOM_LIST -> ChatRoomLists.class; case CHAT_ROOM_USER_JOIN, CHAT_ROOM_USER_LEAVE, CHAT_ROOM_USER_KEEP_ALIVE -> ChatRoomUserEvent.class; case CHAT_ROOM_USER_TIMEOUT -> ChatRoomTimeoutEvent.class; case CHAT_ROOM_INVITE -> ChatRoomInviteEvent.class; default -> throw new IllegalStateException("Unexpected value: " + messageType); }; } @Override public void handleFrame(StompHeaders headers, Object payload) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); Platform.runLater(() -> { switch (messageType) { case CHAT_ROOM_MESSAGE, CHAT_ROOM_TYPING_NOTIFICATION -> chatViewController.showMessage(getChatRoomMessage(headers, payload)); case CHAT_ROOM_JOIN -> chatViewController.roomJoined(getRoomId(headers)); case CHAT_ROOM_LEAVE -> chatViewController.roomLeft(getRoomId(headers)); case CHAT_ROOM_LIST -> chatViewController.addRooms((ChatRoomLists) payload); case CHAT_ROOM_USER_JOIN -> chatViewController.userJoined(getRoomId(headers), (ChatRoomUserEvent) payload); case CHAT_ROOM_USER_LEAVE -> chatViewController.userLeft(getRoomId(headers), (ChatRoomUserEvent) payload); case CHAT_ROOM_USER_KEEP_ALIVE -> chatViewController.userKeepAlive(getRoomId(headers), (ChatRoomUserEvent) payload); case CHAT_ROOM_USER_TIMEOUT -> chatViewController.userTimeout(getRoomId(headers), (ChatRoomTimeoutEvent) payload); case CHAT_ROOM_INVITE -> chatViewController.openInvite(getRoomId(headers), (ChatRoomInviteEvent) payload); default -> throw new IllegalStateException("Unexpected value: " + messageType); } } ); } private static ChatRoomMessage getChatRoomMessage(StompHeaders headers, Object payload) { var chatRoomMessage = (ChatRoomMessage) payload; chatRoomMessage.setRoomId(Long.parseLong(Objects.requireNonNull(headers.getFirst(DESTINATION_ID)))); return chatRoomMessage; } private static long getRoomId(StompHeaders headers) { return Long.parseLong(Objects.requireNonNull(headers.getFirst(DESTINATION_ID))); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/DistantChatFrameHandler.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import io.xeres.common.id.GxsId; import io.xeres.common.location.Availability; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.ChatMessage; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import java.lang.reflect.Type; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; import static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE; /** * This handles the incoming distant chat messages from the server to the UI. */ public class DistantChatFrameHandler implements StompFrameHandler { private final WindowManager windowManager; public DistantChatFrameHandler(WindowManager windowManager) { this.windowManager = windowManager; } /** * Gets the payload type. It's not possible to use null or new Object(). It has to be a class * that is serializable by jackson. * * @param headers the headers * @return a type */ @Override public Type getPayloadType(StompHeaders headers) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); return switch (messageType) { case CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> ChatMessage.class; case CHAT_AVAILABILITY -> Availability.class; default -> throw new IllegalStateException("Unexpected value: " + messageType); }; } @Override public void handleFrame(StompHeaders headers, Object payload) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); Platform.runLater(() -> { switch (messageType) { case CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> windowManager.openMessaging(GxsId.fromString(headers.getFirst(DESTINATION_ID)), (ChatMessage) payload); case CHAT_AVAILABILITY -> windowManager.sendMessaging(headers.getFirst(DESTINATION_ID), (Availability) payload); default -> throw new IllegalStateException("Unexpected value: " + messageType); } } ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/MessageClient.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import io.xeres.common.id.GxsId; import io.xeres.common.id.Identifier; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.chat.ChatMessage; import io.xeres.common.message.voip.VoipMessage; import io.xeres.common.properties.StartupProperties; import io.xeres.common.util.RemoteUtils; import jakarta.websocket.ContainerProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.messaging.MessageDeliveryException; import org.springframework.messaging.converter.JacksonJsonMessageConverter; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.stereotype.Component; import org.springframework.web.socket.WebSocketHttpHeaders; import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; import tools.jackson.databind.json.JsonMapper; import javax.net.ssl.SSLContext; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; import static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE; import static io.xeres.common.message.MessagePath.*; import static io.xeres.common.message.MessageType.*; import static io.xeres.common.message.MessagingConfiguration.MAXIMUM_MESSAGE_SIZE; /** * This sends messages to the server. */ @Component public class MessageClient { private static final Logger log = LoggerFactory.getLogger(MessageClient.class); private SessionHandler sessionHandler; private StompSession stompSession; private String username; private String password; private final List pendingSubscriptions = new ArrayList<>(); private final List subscriptions = new ArrayList<>(); private final JsonMapper jsonMapper; public MessageClient(JsonMapper jsonMapper) { this.jsonMapper = jsonMapper; } public MessageClient connect() { var useHttps = StartupProperties.getBoolean(StartupProperties.Property.HTTPS, true); var url = (useHttps ? "wss://" : "ws://") + RemoteUtils.getHostnameAndPort() + "/ws"; var container = ContainerProvider.getWebSocketContainer(); container.setDefaultMaxTextMessageBufferSize(MAXIMUM_MESSAGE_SIZE); container.setDefaultMaxBinaryMessageBufferSize(MAXIMUM_MESSAGE_SIZE); var client = new StandardWebSocketClient(container); if (useHttps) { try { var sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, InsecureTrustManagerFactory.INSTANCE.getTrustManagers(), null); client.setSslContext(sslContext); } catch (KeyManagementException | NoSuchAlgorithmException e) { throw new RuntimeException(e); } } var stompClient = new WebSocketStompClient(client); stompClient.setMessageConverter(new JacksonJsonMessageConverter(jsonMapper)); stompClient.setInboundMessageSizeLimit(MAXIMUM_MESSAGE_SIZE); var httpHeaders = new WebSocketHttpHeaders(); if (password != null) { httpHeaders.setBasicAuth(username, password); } sessionHandler = new SessionHandler(stompClient, url, httpHeaders, session -> { stompSession = session; performPendingSubscriptions(stompSession); }); log.debug("Connecting to {}", url); sessionHandler.connect(); return this; } public void setAuthentication(String username, String password) { this.username = username; this.password = password; } public MessageClient subscribe(String path, StompFrameHandler frameHandler) { pendingSubscriptions.add(new PendingSubscription(path, frameHandler)); if (stompSession != null) { performPendingSubscriptions(stompSession); } return this; } public boolean isConnected() { return stompSession != null && stompSession.isConnected(); } public void sendToDestination(Identifier identifier, ChatMessage message) { Objects.requireNonNull(stompSession); switch (identifier) { case LocationIdentifier locationIdentifier -> sendToLocation(locationIdentifier, message); case GxsId gxsId -> sendToGxsId(gxsId, message); default -> throw new IllegalStateException("Unexpected value: " + identifier); } } public void sendToDestination(Identifier identifier, VoipMessage message) { Objects.requireNonNull(stompSession); switch (identifier) { case LocationIdentifier locationIdentifier -> sendToLocation(locationIdentifier, message); default -> throw new IllegalStateException("Unexpected value: " + identifier); } } private void sendToLocation(LocationIdentifier locationIdentifier, ChatMessage message) { var headers = new StompHeaders(); headers.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_PRIVATE_DESTINATION); headers.set(MESSAGE_TYPE, message.isEmpty() ? CHAT_TYPING_NOTIFICATION.name() : CHAT_PRIVATE_MESSAGE.name()); headers.set(DESTINATION_ID, locationIdentifier.toString()); stompSession.send(headers, message); } private void sendToLocation(LocationIdentifier locationIdentifier, VoipMessage message) { var headers = new StompHeaders(); headers.setDestination(APP_PREFIX + VOIP_ROOT + VOIP_PRIVATE_DESTINATION); headers.set(DESTINATION_ID, locationIdentifier.toString()); stompSession.send(headers, message); } private void sendToGxsId(GxsId gxsId, ChatMessage message) { var headers = new StompHeaders(); headers.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_DISTANT_DESTINATION); headers.set(MESSAGE_TYPE, message.isEmpty() ? CHAT_TYPING_NOTIFICATION.name() : CHAT_PRIVATE_MESSAGE.name()); headers.set(DESTINATION_ID, gxsId.toString()); stompSession.send(headers, message); } public void requestAvatar(Identifier identifier) { Objects.requireNonNull(stompSession); switch (identifier) { case LocationIdentifier locationIdentifier -> requestAvatarFromLocation(locationIdentifier); default -> throw new IllegalStateException("Unexpected value: " + identifier); } } private void requestAvatarFromLocation(LocationIdentifier locationIdentifier) { var headers = new StompHeaders(); headers.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_PRIVATE_DESTINATION); headers.set(MESSAGE_TYPE, CHAT_AVATAR.name()); headers.set(DESTINATION_ID, locationIdentifier.toString()); stompSession.send(headers, new ChatMessage()); } public void sendToChatRoom(long chatRoomId, ChatMessage message) { Objects.requireNonNull(stompSession); var headers = new StompHeaders(); headers.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_ROOM_DESTINATION); headers.set(MESSAGE_TYPE, message.isEmpty() ? CHAT_ROOM_TYPING_NOTIFICATION.name() : CHAT_ROOM_MESSAGE.name()); headers.set(DESTINATION_ID, String.valueOf(chatRoomId)); stompSession.send(headers, message); } public void sendBroadcast(ChatMessage message) { Objects.requireNonNull(stompSession); var headers = new StompHeaders(); headers.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_BROADCAST_DESTINATION); headers.set(MESSAGE_TYPE, CHAT_BROADCAST_MESSAGE.name()); stompSession.send(headers, message); } private void performPendingSubscriptions(StompSession session) { log.debug("Performing subscriptions..."); while (!pendingSubscriptions.isEmpty()) { var pendingSubscription = pendingSubscriptions.removeFirst(); var subscription = session.subscribe(pendingSubscription.getPath(), pendingSubscription.getStompFrameHandler()); subscriptions.add(subscription); } } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) // we don't use @PreDestroy because the tomcat context is closed before that { if (sessionHandler != null && sessionHandler.getFuture() != null) { try { subscriptions.forEach(StompSession.Subscription::unsubscribe); // if the connection is already closed (likely when running on the same host), we catch the MessageDeliveryException below as well as IllegalStateException sessionHandler.getFuture().get().disconnect(); } catch (MessageDeliveryException | IllegalStateException | ExecutionException _) { // Nothing we can do } catch (InterruptedException _) { Thread.currentThread().interrupt(); } } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/PendingSubscription.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import org.springframework.messaging.simp.stomp.StompFrameHandler; public class PendingSubscription { private final String path; private final StompFrameHandler stompFrameHandler; PendingSubscription(String path, StompFrameHandler stompFrameHandler) { this.path = path; this.stompFrameHandler = stompFrameHandler; } public String getPath() { return path; } public StompFrameHandler getStompFrameHandler() { return stompFrameHandler; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/PrivateChatFrameHandler.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.location.Availability; import io.xeres.common.message.MessageType; import io.xeres.common.message.chat.ChatAvatar; import io.xeres.common.message.chat.ChatMessage; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import java.lang.reflect.Type; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; import static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE; /** * This handles the incoming private messages from the server to the UI. */ public class PrivateChatFrameHandler implements StompFrameHandler { private final WindowManager windowManager; public PrivateChatFrameHandler(WindowManager windowManager) { this.windowManager = windowManager; } /** * Gets the payload type. It's not possible to use null or new Object(). It has to be a class * that is serializable by jackson. * * @param headers the headers * @return a type */ @Override public Type getPayloadType(StompHeaders headers) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); return switch (messageType) { case CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> ChatMessage.class; case CHAT_AVATAR -> ChatAvatar.class; case CHAT_AVAILABILITY -> Availability.class; default -> throw new IllegalStateException("Unexpected value: " + messageType); }; } @Override public void handleFrame(StompHeaders headers, Object payload) { var messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE)); Platform.runLater(() -> { switch (messageType) { case CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> windowManager.openMessaging(LocationIdentifier.fromString(headers.getFirst(DESTINATION_ID)), (ChatMessage) payload); case CHAT_AVATAR -> windowManager.sendMessaging(headers.getFirst(DESTINATION_ID), (ChatAvatar) payload); case CHAT_AVAILABILITY -> windowManager.sendMessaging(headers.getFirst(DESTINATION_ID), (Availability) payload); default -> throw new IllegalStateException("Unexpected value: " + messageType); } } ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/SessionHandler.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.simp.stomp.*; import org.springframework.web.socket.WebSocketHttpHeaders; import org.springframework.web.socket.messaging.WebSocketStompClient; import java.util.concurrent.CompletableFuture; public class SessionHandler extends StompSessionHandlerAdapter { private static final Logger log = LoggerFactory.getLogger(SessionHandler.class); public interface OnConnected { void afterConnected(StompSession session); } private final WebSocketStompClient stompClient; private final String url; private final WebSocketHttpHeaders httpHeaders; private final OnConnected onConnected; private CompletableFuture future; SessionHandler(WebSocketStompClient stompClient, String url, WebSocketHttpHeaders httpHeaders, OnConnected onConnected) { this.stompClient = stompClient; this.url = url; this.httpHeaders = httpHeaders; this.onConnected = onConnected; } public void connect() { future = stompClient.connectAsync(url, httpHeaders, new StompHeaders(), this); } public CompletableFuture getFuture() { return future; } @Override public void afterConnected(StompSession session, StompHeaders connectedHeaders) { log.debug("Connected successfully to session {}, headers: {}", session, connectedHeaders); onConnected.afterConnected(session); } @Override public void handleException(StompSession session, StompCommand command, StompHeaders headers, byte[] payload, Throwable exception) { log.error("StompSessionHandler Exception for session {}, command {}, headers {} and payload {}", session, command, headers, payload, exception); } @Override public void handleTransportError(StompSession session, Throwable exception) { if (exception instanceof ConnectionLostException) { log.debug("Connection closed: {}", exception.getMessage()); Platform.runLater(() -> UiUtils.showAlertConfirm(I18nUtils.getBundle().getString("websocket.disconnected"), this::connect)); } else { log.warn("StompSessionHandler Transport Exception for session {}", session, exception); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/message/VoipFrameHandler.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.message; import io.xeres.common.message.voip.VoipMessage; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; import java.lang.reflect.Type; import static io.xeres.common.message.MessageHeaders.DESTINATION_ID; public class VoipFrameHandler implements StompFrameHandler { private final WindowManager windowManager; public VoipFrameHandler(WindowManager windowManager) { this.windowManager = windowManager; } @Override public Type getPayloadType(StompHeaders headers) { return VoipMessage.class; } @Override public void handleFrame(StompHeaders headers, Object payload) { Platform.runLater(() -> windowManager.doVoip(headers.getFirst(DESTINATION_ID), (VoipMessage) payload)); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/preview/OEmbedResponse.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.preview; import com.fasterxml.jackson.annotation.JsonProperty; record OEmbedResponse( String type, String version, String title, @JsonProperty("author_name") String authorName, @JsonProperty("author_url") String authorUrl, @JsonProperty("provider_name") String providerName, @JsonProperty("provider_url") String providerUrl, @JsonProperty("thumbnail_url") String thumbnailUrl, @JsonProperty("thumbnail_width") Integer thumbnailWidth, @JsonProperty("thumbnail_height") Integer thumbnailHeight ) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/preview/PreviewClient.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.preview; import io.xeres.common.events.StartupEvent; import io.xeres.ui.support.oembed.OEmbedService; import io.xeres.ui.support.util.ClientUtils; import io.xeres.ui.support.util.UriUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; import org.jsoup.Jsoup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.HtmlUtils; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.netty.http.client.HttpClient; import java.nio.charset.StandardCharsets; import java.util.stream.Collectors; @Component public class PreviewClient { private static final Logger log = LoggerFactory.getLogger(PreviewClient.class); private static final int HEAD_RANGE = 32768; private final WebClient.Builder webClientBuilder; private WebClient webClient; private final OEmbedService oembedService; public PreviewClient(WebClient.Builder webClientBuilder, OEmbedService oembedService) { this.webClientBuilder = webClientBuilder; this.oembedService = oembedService; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .defaultHeaders(HttpHeaders::clear) // Do not let remote sites know our credentials .defaultHeader(HttpHeaders.USER_AGENT, ClientUtils.GENERAL_USER_AGENT) .clientConnector(new ReactorClientHttpConnector(HttpClient.create().followRedirect(true))) // Follow redirects .build(); } /** * Gets information about a URL. * * @param url the URL * @return the information */ public Mono getPreview(String url) { if (!UriUtils.isSafeEnough(url) || !url.startsWith("https://")) // Only preview https links { return Mono.just(PreviewResponse.EMPTY); } var oEmbedUrl = oEmbedUrl(url); if (StringUtils.isEmpty(oEmbedUrl)) { return getOpenGraph(url); } else { return getOEmbed(oEmbedUrl, url); } } public Mono getImage(String url) { return webClient.get() .uri(url) .accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG) .retrieve() .bodyToMono(byte[].class); } private String oEmbedUrl(String url) { return oembedService.getOembedForUrl(url); } /** * Gets information about a URL using the OpenGraph protocol * * @param url the URL * @return the information */ private Mono getOpenGraph(String url) { log.debug("Using OpenGraph for {}", url); return webClient.get() .uri(url) .accept(MediaType.TEXT_HTML) .header(HttpHeaders.RANGE, String.format("bytes=%d-%d", 0, HEAD_RANGE)) .header(HttpHeaders.ACCEPT_ENCODING, "identity") // No compression .header(HttpHeaders.USER_AGENT, masqueradeUserAgent(url)) .exchangeToMono(response -> { if (response.statusCode() != HttpStatus.PARTIAL_CONTENT) { log.debug("Server returned full content to our range request, truncating response..."); } // Most servers don't support range requests for dynamic content so we need to truncate. return response.bodyToFlux(DataBuffer.class) .collect(() -> new SizeLimitingCollector(HEAD_RANGE), SizeLimitingCollector::add) .map(collector -> new String(collector.getResult(), StandardCharsets.UTF_8)); }) .publishOn(Schedulers.boundedElastic()) // Because we might block to fetch the possible oembed link .map(s -> toPreviewResponse(s, url)); } /** * Gets information about a URL using the oEmbed protocol * * @param oembedUrl the URL * @return the information */ private Mono getOEmbed(String oembedUrl, String url) { log.debug("Using oEmbed for {}", url); return webClient.get() .uri(_ -> UriComponentsBuilder.fromUriString(oembedUrl) .queryParam("format", "json") .queryParam("url", url) .build().toUri()) .accept(MediaType.APPLICATION_JSON) // We don't want the XML variant... .retrieve() .bodyToMono(OEmbedResponse.class) .map(PreviewClient::toPreviewResponse); } private PreviewResponse toPreviewResponse(String content, String url) { // No need to check for and , Jsoup can parse partial tags fine var document = Jsoup.parse(content, url); var metaElements = document.select("meta"); var ogs = metaElements.stream() .filter(element -> element.attr("property").startsWith("og:")) .collect(Collectors.toMap(element -> element.attr("property"), element -> element.attr("content"))); var previewResponse = new PreviewResponse( ogs.getOrDefault("og:title", ""), ogs.getOrDefault("og:description", ""), ogs.getOrDefault("og:site_name", ""), ogs.getOrDefault("og:image", ""), NumberUtils.toInt(ogs.getOrDefault("og:image:width", "0")), NumberUtils.toInt(ogs.getOrDefault("og:image:height", "0"))); if (!previewResponse.hasThumbnail()) { // No thumbnail? Try to find an oEmbed link in the head var linkElements = document.select("link"); var oembedLink = linkElements.stream() .filter(element -> element.attr("type").equals("application/json+oembed") && !element.attr("href").isBlank()) .map(element -> element.attr("href")) .findFirst().orElse(null); if (oembedLink != null && UriUtils.isSafeEnough(oembedLink)) { return getOEmbed(oembedLink, url).block(); } } return previewResponse; } private static PreviewResponse toPreviewResponse(OEmbedResponse response) { // Twitter has not title so let's use the author instead var titleOrAuthor = StringUtils.isBlank(response.title()) ? response.authorName() : response.title(); return new PreviewResponse( HtmlUtils.htmlUnescape(StringUtils.defaultString(titleOrAuthor)), "", HtmlUtils.htmlUnescape(StringUtils.defaultString(response.providerName())), HtmlUtils.htmlUnescape(StringUtils.defaultString(response.thumbnailUrl())), response.thumbnailWidth() != null ? response.thumbnailWidth() : 0, response.thumbnailHeight() != null ? response.thumbnailHeight() : 0 ); } /** * Some sites return different data depending on the user agent. * * @param url the url to check * @return the user agent to return */ private String masqueradeUserAgent(String url) { // instagram doesn't show opengraph header to non-logged in users in a normal browser if (url.startsWith("https://www.instagram.com") || url.startsWith("https://instagram.org")) { return "googlebot-mobile"; } return ClientUtils.GENERAL_USER_AGENT; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/preview/PreviewResponse.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.preview; import org.apache.commons.lang3.StringUtils; public record PreviewResponse( String title, String description, String site, String thumbnailUrl, int thumbnailWidth, int thumbnailHeight ) { public static final PreviewResponse EMPTY = new PreviewResponse(null, null, null, null, 0, 0); public PreviewResponse { title = StringUtils.abbreviate(title, 128); description = StringUtils.abbreviate(description, 256); site = StringUtils.abbreviate(site, 32); thumbnailUrl = StringUtils.truncate(thumbnailUrl, 2048); } public boolean isEmpty() { return equals(EMPTY); } public boolean hasInfo() { return StringUtils.isNotBlank(title) || StringUtils.isNotBlank(description) || StringUtils.isNotBlank(site) || hasThumbnail(); } public boolean hasThumbnail() { return StringUtils.isNotBlank(thumbnailUrl); } public boolean hasThumbnailDimensions() { return hasThumbnail() && thumbnailWidth > 0 && thumbnailHeight > 0; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/preview/SizeLimitingCollector.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.preview; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import java.util.ArrayList; import java.util.List; /** * A DataBuffer collector that can avoid fetching the whole HTML file. */ public class SizeLimitingCollector { private final List chunks = new ArrayList<>(); private long totalBytes; private final long maxBytes; private boolean limitReached; public SizeLimitingCollector(long maxBytes) { this.maxBytes = maxBytes; } public void add(DataBuffer buffer) { if (limitReached) { DataBufferUtils.release(buffer); return; } int readableBytes = buffer.readableByteCount(); if (totalBytes + readableBytes <= maxBytes) { // We can take the whole buffer var bytes = new byte[readableBytes]; buffer.read(bytes); chunks.add(bytes); totalBytes += readableBytes; DataBufferUtils.release(buffer); } else { // Partial buffer int bytesToTake = (int) (maxBytes - totalBytes); if (bytesToTake > 0) { var bytes = new byte[bytesToTake]; buffer.read(bytes); chunks.add(bytes); totalBytes += bytesToTake; } DataBufferUtils.release(buffer); limitReached = true; } } public byte[] getResult() { // Combine all chunks into one byte array var result = new byte[(int) totalBytes]; var offset = 0; for (byte[] chunk : chunks) { System.arraycopy(chunk, 0, result, offset, chunk.length); offset += chunk.length; } return result; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/update/ReleaseAsset.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.update; import com.fasterxml.jackson.annotation.JsonProperty; public record ReleaseAsset( String name, @JsonProperty("browser_download_url") String url ) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/update/ReleaseResponse.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.update; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; public record ReleaseResponse( @JsonProperty("tag_name") String tagName, List assets ) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/update/UpdateClient.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.update; import io.xeres.common.events.StartupEvent; import io.xeres.ui.support.util.ClientUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.netty.http.client.HttpClient; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.function.Consumer; @Component public class UpdateClient { private static final Logger log = LoggerFactory.getLogger(UpdateClient.class); private final WebClient.Builder webClientBuilder; private WebClient webClient; public UpdateClient(WebClient.Builder webClientBuilder) { this.webClientBuilder = webClientBuilder; } @EventListener public void init(@SuppressWarnings("unused") StartupEvent event) { webClient = webClientBuilder.clone() .baseUrl("https://api.github.com/repos/zapek/Xeres") .defaultHeaders(HttpHeaders::clear) // Do not let GitHub know our remote user/password .defaultHeader(HttpHeaders.USER_AGENT, ClientUtils.GENERAL_USER_AGENT) .clientConnector(new ReactorClientHttpConnector(HttpClient.create().followRedirect(true))) // This is needed if we want to follow redirects, which GitHub uses .build(); } public Mono getLatestVersion() { return webClient.get() .uri("/releases/latest") .retrieve() .bodyToMono(ReleaseResponse.class); } public Mono downloadFile(String url, Path destination) { var dataBufferFlux = webClient.get() .uri(url) .accept(MediaType.APPLICATION_OCTET_STREAM) .retrieve() .bodyToFlux(DataBuffer.class); log.debug("Downloading file {} to {}", url, destination); return DataBufferUtils.write(dataBufferFlux, destination, StandardOpenOption.WRITE); } public Mono downloadFile(String url) { return webClient.get() .uri(url) .accept(MediaType.APPLICATION_OCTET_STREAM) .retrieve() .bodyToMono(byte[].class); } public Flux downloadFileWithProgress(String url, Path destination, Consumer progress) { var updateProgress = new UpdateProgress(destination, progress); var dataBufferFlux = webClient.get() .uri(url) .accept(MediaType.APPLICATION_OCTET_STREAM) .exchangeToFlux(response -> { long contentLength = response.headers().contentLength().orElse(-1); updateProgress.setContentLength(contentLength); return response.bodyToFlux(DataBuffer.class); }); return DataBufferUtils.write(dataBufferFlux, updateProgress.getOutputStream()) .doOnNext(DataBufferUtils::release); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/client/update/UpdateProgress.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.client.update; import java.io.*; import java.nio.file.Path; import java.util.function.Consumer; /** * OutputStream that reports progress. */ public class UpdateProgress { public static final int UPDATE_DELAY = 33; // 30 Hz private long contentLength = -1; private long downloaded; private final OutputStream outputStream; public UpdateProgress(Path destination, Consumer callback) { FileOutputStream out; try { out = new FileOutputStream(destination.toFile()); } catch (FileNotFoundException e) { throw new RuntimeException("File not found: " + destination.toAbsolutePath(), e); } outputStream = new FilterOutputStream(out) { private long lastTime; @Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); downloaded += len; updateStatus(); } @Override public void write(int b) throws IOException { out.write(b); downloaded++; updateStatus(); } @Override public void write(byte[] b) throws IOException { out.write(b); downloaded += b.length; updateStatus(); } private void updateStatus() throws IOException { var now = System.currentTimeMillis(); if (now - lastTime > UPDATE_DELAY || downloaded == contentLength) { if (downloaded == contentLength) { close(); } callback.accept(UpdateProgress.this); lastTime = now; } } }; } public void setContentLength(long contentLength) { this.contentLength = contentLength; } public OutputStream getOutputStream() { return outputStream; } public double getProgress() { if (contentLength == -1) { return 0; } return downloaded / (double) contentLength; } public long getDownloaded() { return downloaded; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/configuration/I18nConfiguration.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.configuration; import io.xeres.common.i18n.I18nUtils; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.ResourceBundle; @Configuration public class I18nConfiguration { @Bean public ResourceBundle bundle() { return I18nUtils.getBundle(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/configuration/WebClientConfiguration.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.configuration; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import io.xeres.common.properties.StartupProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; import tools.jackson.databind.json.JsonMapper; import javax.net.ssl.SSLException; /** * This configuration overrides the default one of Spring Boot by making sure we only use * a global webclient. Spring Boot has one that is customized then cloned so that it can only * be modified globally once and from a configuration. */ @Configuration public class WebClientConfiguration { public static final int MAX_IN_MEMORY = 300 * 1024; private final JsonMapper jsonMapper; public WebClientConfiguration(JsonMapper jsonMapper) { this.jsonMapper = jsonMapper; } @Bean public WebClient.Builder webClientBuilder() throws SSLException { var webClientBuilder = createWebClientBuilder(); // Allow bigger message sizes (default is 256 KB). Not used yet but potentially // a private message can be around 300 KB. webClientBuilder.codecs(clientCodecConfigurer -> { var defaultCodecs = clientCodecConfigurer.defaultCodecs(); defaultCodecs.maxInMemorySize(MAX_IN_MEMORY); defaultCodecs.jacksonJsonDecoder(new JacksonJsonDecoder(jsonMapper)); defaultCodecs.jacksonJsonEncoder(new JacksonJsonEncoder(jsonMapper)); }); return webClientBuilder; } private WebClient.Builder createWebClientBuilder() throws SSLException { var useHttps = StartupProperties.getBoolean(StartupProperties.Property.HTTPS, true); if (useHttps) { var sslContext = SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE) .build(); var httpClient = HttpClient.create().secure(t -> t.sslContext(sslContext)); return WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)); } else { return WebClient.builder(); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/Controller.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller; import java.io.IOException; /** * Use this interface when building a controller. If you need a Window, use WindowController instead. */ public interface Controller { void initialize() throws IOException; // IOException is often thrown in initialize() because of FXML loading, just remove it from the implementation if you don't use it } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/MainWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller; import atlantafx.base.controls.Notification; import atlantafx.base.theme.Styles; import atlantafx.base.util.Animations; import io.xeres.common.mui.MUI; import io.xeres.common.rest.notification.status.DhtInfo; import io.xeres.common.rest.notification.status.NatStatus; import io.xeres.common.rsid.Type; import io.xeres.common.util.ByteUnitUtils; import io.xeres.common.util.OsUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.ConfigClient; import io.xeres.ui.client.LocationClient; import io.xeres.ui.client.NotificationClient; import io.xeres.ui.controller.chat.ChatViewController; import io.xeres.ui.controller.file.FileMainController; import io.xeres.ui.custom.DelayedAction; import io.xeres.ui.custom.ReadOnlyTextField; import io.xeres.ui.custom.led.LedControl; import io.xeres.ui.custom.led.LedStatus; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.event.UnreadEvent; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.tray.TrayService; import io.xeres.ui.support.updater.UpdateService; import io.xeres.ui.support.uri.*; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.TooltipUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import jakarta.annotation.Nullable; import javafx.animation.*; import javafx.application.HostServices; import javafx.application.Platform; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.*; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import javafx.stage.FileChooser.ExtensionFilter; import javafx.stage.Stage; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignI; import org.springframework.context.annotation.Lazy; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.stereotype.Component; import reactor.core.Disposable; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.text.MessageFormat; import java.time.Duration; import java.util.ResourceBundle; import static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID; import static io.xeres.ui.support.util.UiUtils.getWindow; @Component @FxmlView(value = "/view/main.fxml") public class MainWindowController implements WindowController { private static final String XERES_DOCS_URL = "https://xeres.io/docs"; private static final String XERES_BUGS_URL = "https://github.com/zapek/Xeres/issues/new/choose"; private static final KeyCombination SHELL_SHORTCUT = new KeyCodeCombination( KeyCode.F12 ); private static final KeyCombination HELP_SHORTCUT = new KeyCodeCombination( KeyCode.F1, KeyCombination.SHORTCUT_DOWN // This is the online help, F1 alone is mapped to the built-in documentation ); private EventHandler keyEventHandler; @FXML private StackPane stackPane; @FXML private TabPane tabPane; @FXML private Tab homeTab; @FXML private Tab chatTab; @FXML private Tab contactTab; @FXML private Tab forumTab; @FXML private Tab channelTab; @FXML private Tab boardTab; @FXML private Tab fileTab; @FXML private ImageView logo; @FXML private Label titleLabel; @FXML private Label shareId; @FXML private Label slogan; @FXML private MenuItem addPeer; @FXML private MenuItem launchWebInterface; @FXML private MenuItem launchSwagger; @FXML private MenuItem exitApplication; @FXML private MenuItem showDocumentation; @FXML private MenuItem reportBug; @FXML private MenuItem showAboutWindow; @FXML private MenuItem showBroadcastWindow; @FXML private MenuItem exportBackup; @FXML private MenuItem importFriends; @FXML private MenuItem statistics; @FXML private MenuItem showSettingsWindow; @FXML private MenuItem showSharesWindow; @FXML private Menu debug; @FXML private SeparatorMenuItem debugSeparator; @FXML private MenuItem runGc; @FXML private MenuItem h2Console; @FXML private MenuItem openShell; @FXML private MenuItem showErrorException; @FXML private MenuItem showError; @FXML private MenuItem showThemeExample; @FXML private MenuItem versionCheck; @FXML private ReadOnlyTextField shortId; @FXML private Button copyShortIdButton; @FXML private Button showQrCodeButton; @FXML private Button addFriendButton; @FXML private Button webHelpButton; @FXML private Label numberOfConnections; @FXML private LedControl natStatus; @FXML private LedControl dhtStatus; @FXML private HBox hashingStatus; @FXML private Label hashingName; @FXML private FileMainController fileMainController; private final ChatViewController chatViewController; private final LocationClient locationClient; private final TrayService trayService; private final WindowManager windowManager; private final Environment environment; private final ConfigClient configClient; private final NotificationClient notificationClient; private final HostServices hostServices; private final UpdateService updateService; private final ResourceBundle bundle; private int currentUsers; private int totalUsers; private Disposable statusNotificationDisposable; private Disposable fileNotificationDisposable; private DelayedAction hashingDelayedDisplayAction; public MainWindowController(ChatViewController chatViewController, LocationClient locationClient, TrayService trayService, WindowManager windowManager, Environment environment, ConfigClient configClient, NotificationClient notificationClient, @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @Nullable HostServices hostServices, @Lazy UpdateService updateService, ResourceBundle bundle) { this.chatViewController = chatViewController; this.locationClient = locationClient; this.trayService = trayService; this.windowManager = windowManager; this.environment = environment; this.configClient = configClient; this.notificationClient = notificationClient; this.hostServices = hostServices; this.updateService = updateService; this.bundle = bundle; } @Override public void initialize() { addPeer.setOnAction(_ -> windowManager.openAddPeer()); addFriendButton.setOnAction(_ -> windowManager.openAddPeer()); copyShortIdButton.setOnAction(_ -> copyOwnId()); showQrCodeButton.setOnAction(_ -> showQrCode()); launchWebInterface.setOnAction(_ -> openUrl(RemoteUtils.getControlUrl())); launchSwagger.setOnAction(_ -> openUrl(RemoteUtils.getControlUrl() + "/swagger-ui/index.html")); showDocumentation.setOnAction(_ -> windowManager.openDocumentation(true)); webHelpButton.setOnAction(_ -> openUrl(XERES_DOCS_URL)); reportBug.setOnAction(_ -> openUrl(XERES_BUGS_URL)); showAboutWindow.setOnAction(_ -> windowManager.openAbout()); showBroadcastWindow.setOnAction(_ -> windowManager.openBroadcast()); showSettingsWindow.setOnAction(_ -> windowManager.openSettings()); showSharesWindow.setOnAction(_ -> windowManager.openShare()); exportBackup.setOnAction(event -> { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("main.export-profile")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); fileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString("file-requester.xml"), "*.xml")); fileChooser.setInitialFileName("xeres_backup.xml"); var selectedFile = fileChooser.showSaveDialog(getWindow(event)); if (selectedFile != null) { DataBufferUtils.write(configClient.getBackup(), selectedFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING).subscribe(); } }); importFriends.setOnAction(event -> { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("main.import-friends")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); fileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString("file-requester.xml"), "*.xml")); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); if (selectedFile != null && selectedFile.canRead()) { configClient.sendRsFriends(selectedFile) .doOnSuccess(response -> Platform.runLater(() -> { assert response != null; if (response.errors() > 0) { UiUtils.showAlert(Alert.AlertType.WARNING, MessageFormat.format(bundle.getString("main.friends-import-errors"), response.success(), response.errors())); } else { UiUtils.showAlert(Alert.AlertType.INFORMATION, MessageFormat.format(bundle.getString("main.friends-import-successful"), response.success())); } })) .doOnError(UiUtils::webAlertError) .subscribe(); } }); statistics.setOnAction(_ -> windowManager.openStatistics()); if (environment.acceptsProfiles(Profiles.of("dev"))) { debugSeparator.setVisible(true); debug.setVisible(true); runGc.setOnAction(_ -> System.gc()); h2Console.setOnAction(_ -> openUrl(RemoteUtils.getControlUrl() + "/h2-console")); openShell.setOnAction(_ -> MUI.openShell()); showThemeExample.setOnAction(_ -> windowManager.openThemeExample()); showErrorException.setOnAction(_ -> UiUtils.webAlertError(new IllegalArgumentException("Dummy error"))); showError.setOnAction(_ -> UiUtils.showAlert(Alert.AlertType.ERROR, "This is some error blabla")); } versionCheck.setOnAction(_ -> updateService.checkForUpdate()); exitApplication.setOnAction(_ -> trayService.exitApplication()); setupNotifications(); trayService.addSystemTray(windowManager.getFullTitle()); locationClient.getRSId(OWN_LOCATION_ID, Type.SHORT_INVITE) .doOnSuccess(rsIdResponse -> Platform.runLater(() -> { assert rsIdResponse != null; shortId.setText(rsIdResponse.rsId()); })) .subscribe(); setupAnimations(); updateService.startBackgroundChecksIfEnabled(); keyEventHandler = event -> { if (SHELL_SHORTCUT.match(event)) { MUI.openShell(); event.consume(); } else if (HELP_SHORTCUT.match(event)) { webHelpButton.fire(); event.consume(); } }; tabPane.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> { if (chatTab.equals(newValue)) { addOrRemoveTabHighlight(chatTab, false); } }); } @Override public void onShowing() { fileMainController.resume(); } @Override public void onShown() { windowManager.setRootWindow(getWindow(titleLabel)); chatViewController.jumpToBottom(); getWindow(titleLabel).addEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler); if (!trayService.hasSystemTray()) { UiUtils.getWindow(titleLabel).setOnCloseRequest(event -> { UiUtils.showAlertConfirm(bundle.getString("main.exit.confirm"), () -> UiUtils.getWindow(titleLabel).hide()); event.consume(); }); } } @Override public void onHiding() { fileMainController.suspend(); getWindow(titleLabel).removeEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler); } @Override public void onHidden() { if (!trayService.hasSystemTray()) { trayService.exitApplication(); } } private void copyOwnId() { var rsIdResponse = locationClient.getRSId(OWN_LOCATION_ID, Type.ANY); rsIdResponse.subscribe(reply -> Platform.runLater(() -> ClipboardUtils.copyTextToClipboard(reply.rsId()))); } private void showQrCode() { var rsIdResponse = locationClient.getRSId(OWN_LOCATION_ID, Type.ANY); rsIdResponse.subscribe(reply -> Platform.runLater(() -> windowManager.openQrCode(reply))); } public void showUpdate(String message, String tagName, Runnable downloadAction) { var msg = new Notification(message, new FontIcon(MaterialDesignI.INFORMATION)); msg.getStyleClass().addAll(Styles.ACCENT, Styles.ELEVATED_2); msg.setPrefHeight(Region.USE_PREF_SIZE); msg.setMaxHeight(Region.USE_PREF_SIZE); var downloadButton = new Button(bundle.getString("download")); downloadButton.setDefaultButton(true); downloadButton.setOnAction(_ -> downloadAction.run()); var skipButton = new Button(bundle.getString("skip")); skipButton.setOnAction(_ -> updateService.skipUpdate(tagName)); msg.setPrimaryActions(downloadButton, skipButton); StackPane.setAlignment(msg, Pos.TOP_RIGHT); StackPane.setMargin(msg, new Insets(0, 10, 10, 0)); msg.setOnClose(_ -> { var out = Animations.slideOutUp(msg, javafx.util.Duration.millis(250)); out.setOnFinished(_ -> stackPane.getChildren().remove(msg)); out.playFromStart(); }); var in = Animations.slideInDown(msg, javafx.util.Duration.millis(250)); stackPane.getChildren().add(msg); in.playFromStart(); // If the window is iconified, un-iconify it ((Stage) UiUtils.getWindow(stackPane)).show(); } private void setupNotifications() { // Apparently the LED is not happy if we don't turn it on first here. natStatus.setState(true); dhtStatus.setState(true); statusNotificationDisposable = notificationClient.getStatusNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { if (sse.data() != null) { setUserCount(sse.data().currentUsers(), sse.data().totalUsers()); setNatStatus(sse.data().natStatus()); setDhtInfo(sse.data().dhtInfo()); } })) .subscribe(); fileNotificationDisposable = notificationClient.getFileNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { if (sse.data() != null) { switch (sse.data().action()) { case START_SCANNING -> { var defaultText = MessageFormat.format(bundle.getString("main.scanning"), sse.data().shareName()); hashingStatus.setVisible(true); hashingDelayedDisplayAction = new DelayedAction(() -> hashingName.setText(defaultText), () -> hashingName.setText(null), Duration.ofMillis(2000)); hashingDelayedDisplayAction.run(); } case START_HASHING -> { if (hashingDelayedDisplayAction == null) // Can happen when scanning temporary files { hashingDelayedDisplayAction = new DelayedAction(null, () -> hashingName.setText(null), Duration.ofMillis(2000)); } hashingDelayedDisplayAction.abort(); hashingName.setText(MessageFormat.format(bundle.getString("main.hashing"), Path.of(sse.data().scannedFile()).getFileName())); TooltipUtils.install(hashingStatus, MessageFormat.format(bundle.getString("main.scanning.tip"), sse.data().shareName(), sse.data().scannedFile())); } case STOP_HASHING -> { TooltipUtils.uninstall(hashingStatus); hashingDelayedDisplayAction.run(); } case STOP_SCANNING -> { if (hashingDelayedDisplayAction == null) // Can happen when connecting remotely { return; } hashingDelayedDisplayAction.abort(); hashingStatus.setVisible(false); hashingDelayedDisplayAction = null; } case NONE -> { // Nothing to do } } } })) .subscribe(); } private void setUserCount(Integer newCurrentUsers, Integer newTotalUsers) { if (newCurrentUsers != null) { currentUsers = newCurrentUsers; } if (newTotalUsers != null) { totalUsers = newTotalUsers; } numberOfConnections.setText(currentUsers + "/" + totalUsers); } private void setNatStatus(NatStatus newNatStatus) { if (newNatStatus != null) { switch (newNatStatus) { case UNKNOWN -> { TooltipUtils.install(natStatus, bundle.getString("main.status.nat.unknown")); natStatus.setStatus(LedStatus.WARNING); } case FIREWALLED -> { TooltipUtils.install(natStatus, bundle.getString("main.status.nat.firewalled")); natStatus.setStatus(LedStatus.ERROR); } case UPNP -> { TooltipUtils.install(natStatus, bundle.getString("main.status.nat.upnp")); natStatus.setStatus(LedStatus.OK); } } } } private void setDhtInfo(DhtInfo newDhtInfo) { if (newDhtInfo != null) { switch (newDhtInfo.dhtStatus()) { case OFF -> { dhtStatus.setState(false); TooltipUtils.install(dhtStatus, bundle.getString("main.status.dht.disabled")); } case INITIALIZING -> { dhtStatus.setState(true); dhtStatus.setStatus(LedStatus.WARNING); TooltipUtils.install(dhtStatus, bundle.getString("main.status.dht.initializing")); } case RUNNING -> { dhtStatus.setState(true); dhtStatus.setStatus(LedStatus.OK); if (newDhtInfo.numPeers() == 0) { TooltipUtils.install(dhtStatus, bundle.getString("main.status.dht.running")); } else { TooltipUtils.install(dhtStatus, MessageFormat.format(bundle.getString("main.status.dht.stats"), newDhtInfo.numPeers(), newDhtInfo.receivedPackets(), ByteUnitUtils.fromBytes(newDhtInfo.receivedBytes()), newDhtInfo.sentPackets(), ByteUnitUtils.fromBytes(newDhtInfo.sentBytes()), newDhtInfo.keyCount(), newDhtInfo.itemCount())); } } } } } @EventListener public void handleOpenUriEvents(OpenUriEvent event) { switch (event.uri()) { case ChatRoomUri _ -> tabPane.getSelectionModel().select(chatTab); case ForumUri _ -> tabPane.getSelectionModel().select(forumTab); case BoardUri _ -> tabPane.getSelectionModel().select(boardTab); case ChannelUri _ -> tabPane.getSelectionModel().select(channelTab); case SearchUri _ -> tabPane.getSelectionModel().select(fileTab); case IdentityUri _, ProfileUri _ -> tabPane.getSelectionModel().select(contactTab); default -> { // Nothing to do } } } @EventListener public void handleUnreadEvents(UnreadEvent event) { switch (event.element()) { case CHAT_ROOM -> { if (!tabPane.getSelectionModel().getSelectedItem().equals(chatTab)) { addOrRemoveTabHighlight(chatTab, event.unread()); } } case FORUM -> addOrRemoveTabHighlight(forumTab, event.unread()); case FILE -> addOrRemoveTabHighlight(fileTab, event.unread()); case BOARD -> addOrRemoveTabHighlight(boardTab, event.unread()); case CHANNEL -> addOrRemoveTabHighlight(channelTab, event.unread()); } } private void addOrRemoveTabHighlight(Tab tab, boolean add) { var styleClass = tab.getStyleClass(); if (add) { if (!styleClass.contains("tab-bold")) { styleClass.add("tab-bold"); } } else { styleClass.remove("tab-bold"); } } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (statusNotificationDisposable != null && !statusNotificationDisposable.isDisposed()) { statusNotificationDisposable.dispose(); } if (fileNotificationDisposable != null && !fileNotificationDisposable.isDisposed()) { fileNotificationDisposable.dispose(); } } private void setupAnimations() { var rotateTransition = new RotateTransition(javafx.util.Duration.millis(2000), logo); rotateTransition.setByAngle(360); rotateTransition.setCycleCount(Animation.INDEFINITE); rotateTransition.setInterpolator(Interpolator.LINEAR); var scaleTransition = new ScaleTransition(javafx.util.Duration.millis(200), titleLabel); scaleTransition.setByX(0.2); scaleTransition.setByY(0.2); scaleTransition.setAutoReverse(true); scaleTransition.setCycleCount(Animation.INDEFINITE); scaleTransition.setInterpolator(Interpolator.EASE_BOTH); var fadeTransition = new FadeTransition(javafx.util.Duration.millis(100), slogan); fadeTransition.setByValue(-1.0); fadeTransition.setAutoReverse(true); fadeTransition.setCycleCount(Animation.INDEFINITE); fadeTransition.setInterpolator(Interpolator.EASE_BOTH); var translateTransitionLeft = new TranslateTransition(javafx.util.Duration.millis(300)); translateTransitionLeft.setFromX(0.0); translateTransitionLeft.setToX(-80.0); translateTransitionLeft.setAutoReverse(true); translateTransitionLeft.setCycleCount(2); translateTransitionLeft.setInterpolator(Interpolator.LINEAR); var translateTransitionRight = new TranslateTransition(javafx.util.Duration.millis(300)); translateTransitionRight.setFromX(0.0); translateTransitionRight.setToX(+80.0); translateTransitionRight.setAutoReverse(true); translateTransitionRight.setCycleCount(2); translateTransitionRight.setInterpolator(Interpolator.LINEAR); var sequentialTransition = new SequentialTransition(translateTransitionLeft, translateTransitionRight); sequentialTransition.setNode(shareId); sequentialTransition.setCycleCount(Animation.INDEFINITE); UiUtils.setOnPrimaryMouseDoubleClicked(logo, _ -> { if (rotateTransition.getStatus() == Animation.Status.RUNNING) { rotateTransition.jumpTo(javafx.util.Duration.millis(0)); rotateTransition.stop(); scaleTransition.jumpTo(javafx.util.Duration.millis(0)); scaleTransition.stop(); fadeTransition.jumpTo(javafx.util.Duration.millis(0)); fadeTransition.stop(); sequentialTransition.jumpTo(javafx.util.Duration.millis(0)); sequentialTransition.stop(); } else { rotateTransition.play(); scaleTransition.play(); fadeTransition.play(); sequentialTransition.play(); } }); } private void openUrl(String url) { if (hostServices != null) { hostServices.showDocument(url); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/TabActivation.java ================================================ package io.xeres.ui.controller; public interface TabActivation { void activate(); void deactivate(); } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/WindowController.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller; /** * Use this interface when building a Window. */ public interface WindowController extends Controller { default void onShowing() { // default } default void onShown() { // default } default void onHiding() { // default } default void onHidden() { // default } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/about/AboutWindowController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.about; import io.xeres.ui.controller.WindowController; import io.xeres.ui.support.util.TooltipUtils; import io.xeres.ui.support.util.UiUtils; import jakarta.annotation.Nullable; import javafx.application.HostServices; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TabPane; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.text.Text; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.boot.info.BuildProperties; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Objects; import java.util.ResourceBundle; import java.util.stream.Collectors; import static org.apache.commons.lang3.ArrayUtils.isEmpty; @Component @FxmlView(value = "/view/about/about.fxml") public class AboutWindowController implements WindowController { @FXML private Button closeWindow; @FXML private TabPane infoPane; @FXML private Text license; @FXML private Label version; @FXML private Label profile; @FXML private ImageView logo; private final BuildProperties buildProperties; private final Environment environment; private final HostServices hostServices; private final ResourceBundle bundle; public AboutWindowController(BuildProperties buildProperties, Environment environment, @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @Nullable HostServices hostServices, ResourceBundle bundle) { this.buildProperties = buildProperties; this.environment = environment; this.hostServices = hostServices; this.bundle = bundle; } @Override public void initialize() throws IOException { version.setText(buildProperties.getVersion()); if (isEmpty(environment.getActiveProfiles())) { profile.setText(bundle.getString("about.release")); } else { profile.setText(bundle.getString("about.profiles") + " " + String.join(", ", environment.getActiveProfiles())); } license.setText(UiUtils.getResourceFileAsString(AboutWindowController.class.getResourceAsStream("/LICENSE"))); closeWindow.setOnAction(UiUtils::closeWindow); UiUtils.linkify(infoPane, hostServices); UiUtils.setOnPrimaryMouseDoubleClicked(logo, _ -> { logo.setImage(new Image(Objects.requireNonNull(AboutWindowController.class.getResourceAsStream("/image/egg.png")))); TooltipUtils.install(logo, "Qrqvpngrq gb Lhyvn\u001F Nqevra\u001F Nyvan naq Kravn".chars().mapToObj(v -> (char) v).map(c -> (char) ((c < 'a') ? ((c - 'A' + 13) % 26) + 'A' : ((c - 'a' + 13) % 26) + 'a')).map(String::valueOf).collect(Collectors.joining()).replace("-", " ")); }); Platform.runLater(() -> closeWindow.requestFocus()); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/account/AccountCreationWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.account; import io.xeres.common.util.OsUtils; import io.xeres.ui.client.ConfigClient; import io.xeres.ui.client.ProfileClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyEvent; import javafx.stage.FileChooser; import javafx.stage.FileChooser.ExtensionFilter; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.ResourceBundle; import static io.xeres.ui.support.util.UiUtils.getWindow; import static javafx.scene.control.Alert.AlertType.ERROR; import static org.apache.commons.lang3.StringUtils.isNotBlank; @Component @FxmlView(value = "/view/account/account_creation.fxml") public class AccountCreationWindowController implements WindowController { private static final KeyCombination HELP_SHORTCUT = new KeyCodeCombination( KeyCode.F1 ); private EventHandler keyEventHandler; @FXML private Button okButton; @FXML private Button helpButton; @FXML private TextField profileName; @FXML private TextField locationName; @FXML private ProgressIndicator progress; @FXML private Label status; @FXML private TitledPane titledPane; @FXML private Button importBackup; private final ConfigClient configClient; private final ProfileClient profileClient; private final WindowManager windowManager; private final ResourceBundle bundle; public AccountCreationWindowController(ConfigClient configClient, ProfileClient profileClient, WindowManager windowManager, ResourceBundle bundle) { this.configClient = configClient; this.profileClient = profileClient; this.windowManager = windowManager; this.bundle = bundle; } @Override public void initialize() { profileName.textProperty().addListener(_ -> okButton.setDisable(profileName.getText().isBlank())); locationName.textProperty().addListener(_ -> okButton.setDisable(locationName.getText().isBlank())); configClient.getUsername() .doOnSuccess(usernameResult -> Platform.runLater(() -> { assert usernameResult != null; profileName.setText(usernameResult.username()); })) .subscribe(); configClient.getHostname() .doOnSuccess(hostnameResult -> Platform.runLater(() -> { assert hostnameResult != null; locationName.setText(sanitizeHostname(hostnameResult.hostname())); })) .subscribe(); okButton.setOnAction(_ -> { var profileNameText = profileName.getText(); var locationNameText = locationName.getText(); if (isNotBlank(profileNameText) && isNotBlank(locationNameText)) { generateProfileAndLocation(profileNameText, locationNameText); } }); importBackup.setOnAction(event -> { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("account.generation.profile-load")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); fileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString("file-requester.profiles"), "*.xml", "*.gpg", "*.asc")); var selectedFile = fileChooser.showOpenDialog(UiUtils.getWindow(event)); if (selectedFile != null && selectedFile.canRead()) { if (selectedFile.getPath().endsWith(".xml")) { status.setText(bundle.getString("account.generation.import.progress")); setInProgress(true); configClient.sendBackup(selectedFile) .doOnSuccess(_ -> Platform.runLater(() -> Platform.runLater(this::openDashboard))) .doOnError(throwable -> { UiUtils.webAlertError(throwable); setInProgress(false); status.setText(null); }) .subscribe(); } else if (selectedFile.getPath().endsWith(".gpg") || selectedFile.getPath().endsWith(".asc")) { status.setText(bundle.getString("account.generation.import.progress")); setInProgress(true); var dialog = new TextInputDialog(); dialog.setTitle(bundle.getString("account.generation.import.confirm.title")); dialog.setHeaderText(null); dialog.setContentText(bundle.getString("account.generation.import.confirm.prompt")); dialog.initOwner(UiUtils.getWindow(event)); dialog.showAndWait().ifPresent(response -> configClient.sendRsKeyring(selectedFile, locationName.getText(), response) .doOnSuccess(_ -> Platform.runLater(() -> Platform.runLater(this::openDashboard))) .doOnError(throwable -> { UiUtils.webAlertError(throwable); setInProgress(false); status.setText(null); }) .subscribe()); } else { UiUtils.showAlert(ERROR, bundle.getString("account.generation.import.unknown")); } } }); keyEventHandler = event -> { if (HELP_SHORTCUT.match(event)) { windowManager.openDocumentation(false); event.consume(); } }; helpButton.setOnAction(_ -> windowManager.openDocumentation(false)); } @Override public void onShown() { getWindow(okButton).addEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler); getWindow(okButton).setOnCloseRequest(_ -> Platform.exit()); } @Override public void onHiding() { getWindow(okButton).removeEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler); } /** * Try to make the hostname better by removing the domain part, if present. * For example, bar.foo.baz -> bar * * @param hostname a hostname * @return a hostname without the domain part */ private static String sanitizeHostname(String hostname) { return hostname.split("\\.")[0]; } private void setInProgress(boolean inProgress) { okButton.setDisable(inProgress); profileName.setDisable(inProgress); locationName.setDisable(inProgress); importBackup.setDisable(inProgress); progress.setVisible(inProgress); titledPane.setExpanded(!inProgress); } public void generateProfileAndLocation(String profileName, String locationName) { setInProgress(true); status.setText(bundle.getString("account.generation.profile-keys")); configClient.createProfile(profileName).doOnSuccess(_ -> Platform.runLater(() -> generateLocation(profileName, locationName))) .doOnError(e -> Platform.runLater(() -> { UiUtils.webAlertError(e); setInProgress(false); status.setText(null); })) .subscribe(); } private void generateLocation(String profileName, String locationName) { setInProgress(true); status.setText(bundle.getString("account.generation.location-keys-and-certificate")); configClient.createLocation(locationName).doOnSuccess(_ -> Platform.runLater(() -> generateIdentity(profileName))) .doOnError(e -> Platform.runLater(() -> { UiUtils.webAlertError(e); setInProgress(false); status.setText(null); })) .subscribe(); } private void generateIdentity(String identityName) { setInProgress(true); var result = configClient.createIdentity(identityName, false); status.setText(bundle.getString("account.generation.identity")); result.doOnSuccess(_ -> Platform.runLater(this::openDashboard)) .doOnError(e -> Platform.runLater(() -> { UiUtils.webAlertError(e); setInProgress(false); status.setText(null); })) .subscribe(); } private void openDashboard() { profileClient.getOwn().doOnSuccess(profile -> Platform.runLater(() -> { windowManager.openMain(null, profile, false); getWindow(profileName).hide(); })) .subscribe(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/board/BoardGroupCell.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.board; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.custom.asyncimage.PlaceholderImageView; import io.xeres.ui.model.board.BoardGroup; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.TreeTableCell; import javafx.scene.image.ImageView; import java.text.MessageFormat; import java.util.ResourceBundle; import static io.xeres.common.rest.PathConfig.BOARDS_PATH; public class BoardGroupCell extends TreeTableCell { private static final int IMAGE_WIDTH = 32; private static final int IMAGE_HEIGHT = 32; private final GeneralClient generalClient; private final ImageCache imageCache; private static final ResourceBundle bundle = I18nUtils.getBundle(); public BoardGroupCell(GeneralClient generalClient, ImageCache imageCache) { super(); this.generalClient = generalClient; this.imageCache = imageCache; TooltipUtils.install(this, () -> MessageFormat.format(bundle.getString("gxs-group.tree.info"), getItem().getName(), getItem().getGxsId(), getItem().getVisibleMessageCount(), DateUtils.formatDateTime(getItem().getLastActivity(), bundle.getString("unknown-lc"))), () -> new ImageView(((PlaceholderImageView) getGraphic()).getImage())); } @Override protected void updateItem(BoardGroup item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : item.getName()); setGraphic(empty ? null : updateImage((PlaceholderImageView) getGraphic(), item)); } private PlaceholderImageView updateImage(PlaceholderImageView placeholderImageView, BoardGroup item) { if (placeholderImageView == null) { placeholderImageView = new PlaceholderImageView( url -> generalClient.getImage(url).block(), "mdi2v-view-dashboard-outline", imageCache); } if (item.isReal()) { placeholderImageView.setFitWidth(IMAGE_WIDTH); placeholderImageView.setFitHeight(IMAGE_HEIGHT); placeholderImageView.setUrl(getImageUrl(item)); } else { placeholderImageView.setFitWidth(0); placeholderImageView.setFitHeight(0); placeholderImageView.setUrl(null); placeholderImageView.hideDefault(); // SetUrl(null) shows a default, but we don't want one as we're tree group nodes } return placeholderImageView; } private String getImageUrl(BoardGroup item) { if (item.isReal() && item.hasImage()) { return RemoteUtils.getControlUrl() + BOARDS_PATH + "/groups/" + item.getId() + "/image"; } return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/board/BoardGroupWindowController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.board; import io.xeres.common.util.OsUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.BoardClient; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.ImageSelectorView; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.ProgressBar; import javafx.scene.control.TextField; import javafx.stage.FileChooser; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.lang3.Strings; import org.springframework.stereotype.Component; import java.util.ResourceBundle; import static io.xeres.common.rest.PathConfig.BOARDS_PATH; import static io.xeres.ui.support.util.UiUtils.getWindow; @Component @FxmlView(value = "/view/board/board_group_view.fxml") public class BoardGroupWindowController implements WindowController { @FXML private Button createOrUpdateButton; @FXML private Button cancelButton; @FXML private TextField boardName; @FXML private TextField boardDescription; @FXML private ImageSelectorView boardLogo; @FXML private ProgressBar progressBar; private final BoardClient boardClient; private final GeneralClient generalClient; private final ResourceBundle bundle; private long boardId; private String initialUrl; private String initialName; private String initialDescription; public BoardGroupWindowController(BoardClient boardClient, GeneralClient generalClient, ResourceBundle bundle) { this.boardClient = boardClient; this.generalClient = generalClient; this.bundle = bundle; } @Override public void initialize() { boardName.textProperty().addListener(_ -> checkCreatableOrUpdatable()); boardDescription.textProperty().addListener(_ -> checkCreatableOrUpdatable()); boardLogo.imageProperty().addListener(_ -> checkCreatableOrUpdatable()); boardLogo.setOnSelectAction(this::selectGroupImage); boardLogo.setOnDeleteAction(this::clearGroupImage); boardLogo.setImageLoader(url -> generalClient.getImage(url).block()); cancelButton.setOnAction(UiUtils::closeWindow); } @Override public void onShown() { var userData = UiUtils.getUserData(boardName); if (userData != null) { boardId = (long) userData; } if (boardId != 0L) { boardClient.getBoardGroupById(boardId) .doOnSuccess(boardGroup -> Platform.runLater(() -> { assert boardGroup != null; boardName.setText(boardGroup.getName()); boardDescription.setText(boardGroup.getDescription()); if (boardGroup.hasImage()) { boardLogo.setImageUrl(RemoteUtils.getControlUrl() + BOARDS_PATH + "/groups/" + boardGroup.getId() + "/image"); initialUrl = boardLogo.getUrl(); } initialName = boardName.getText(); initialDescription = boardDescription.getText(); createOrUpdateButton.setDisable(true); })) .subscribe(); createOrUpdateButton.setText(bundle.getString("update")); createOrUpdateButton.setOnAction(_ -> { setWaiting(true); boardClient.updateBoardGroup(boardId, boardName.getText(), boardDescription.getText(), boardLogo.getFile(), !Strings.CS.equals(initialUrl, boardLogo.getUrl())) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(boardName))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); }); } else { createOrUpdateButton.setOnAction(_ -> { setWaiting(true); boardClient.createBoardGroup(boardName.getText(), boardDescription.getText(), boardLogo.getFile()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(boardName))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); }); } } private void setWaiting(boolean waiting) { boardName.setDisable(waiting); boardDescription.setDisable(waiting); boardLogo.setDisable(waiting); createOrUpdateButton.setDisable(waiting); cancelButton.setDisable(waiting); UiUtils.setPresent(progressBar, waiting); } private void checkCreatableOrUpdatable() { createOrUpdateButton.setDisable((boardId == 0L && boardName.getText().isBlank()) || (boardId == 0L && boardDescription.getText().isBlank()) || ( Strings.CS.equals(initialName, boardName.getText()) && Strings.CS.equals(initialDescription, boardDescription.getText()) && Strings.CS.equals(initialUrl, boardLogo.getUrl()) ) ); } private void selectGroupImage(ActionEvent event) { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("board.select-logo")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); ChooserUtils.setSupportedLoadImageFormats(fileChooser); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); boardLogo.setFile(selectedFile); } private void clearGroupImage(ActionEvent event) { boardLogo.setImage(null); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/board/BoardMessageCell.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.board; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.BoardClient; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.custom.asyncimage.AsyncImageView; import io.xeres.ui.model.board.BoardMessage; import io.xeres.ui.support.contentline.Content; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.markdown.MarkdownService.Rendering; import io.xeres.ui.support.uri.UriFactory; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.ImageViewUtils; import io.xeres.ui.support.util.TextFlowDragSelection; import io.xeres.ui.support.util.UiUtils; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.ToggleButton; import javafx.scene.layout.VBox; import javafx.scene.text.TextFlow; import org.fxmisc.flowless.Cell; import java.io.IOException; import java.util.EnumSet; import static io.xeres.common.rest.PathConfig.BOARDS_PATH; class BoardMessageCell implements Cell { @FXML private VBox groupView; @FXML private TextFlow titleFlow; @FXML private TextFlow contentFlow; @FXML private Label authorLabel; @FXML private Label postInstantLabel; @FXML private ToggleButton unreadButton; @FXML private AsyncImageView imageView; private final MarkdownService markdownService; public BoardMessageCell(BoardMessage boardMessage, GeneralClient generalClient, BoardClient boardClient, MarkdownService markdownService) { this.markdownService = markdownService; var loader = new FXMLLoader(BoardMessageCell.class.getResource("/view/board/message_cell.fxml"), I18nUtils.getBundle()); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } imageView.setLoader(url -> generalClient.getImage(url).block()); ImageViewUtils.addImageContextMenuActions(imageView); TextFlowDragSelection.enableSelection(contentFlow, null); unreadButton.setOnAction(_ -> { var item = (BoardMessage) unreadButton.getUserData(); boardClient.setBoardMessageReadState(item.getId(), !unreadButton.isSelected()) .subscribe(); }); updateItem(boardMessage); } @Override public Node getNode() { return groupView; } @Override public boolean isReusable() { return true; } @Override public void updateItem(BoardMessage item) { titleFlow.getChildren().clear(); if (item.hasLink()) { var content = UriFactory.createContent(item.getLink(), item.getName(), markdownService.getUriService()); titleFlow.getChildren().addAll(content.getNode()); } else { titleFlow.getChildren().add(new Label(item.getName())); } contentFlow.getChildren().clear(); if (item.hasContent()) { contentFlow.getChildren().addAll(markdownService.parse(item.getContent(), EnumSet.noneOf(Rendering.class)).stream() .map(Content::getNode).toList()); UiUtils.setPresent(contentFlow); } else { UiUtils.setAbsent(contentFlow); } authorLabel.setText(item.getAuthorName()); postInstantLabel.setText(DateUtils.DATE_TIME_FORMAT.format(item.getPublished())); unreadButton.setSelected(!item.isRead()); unreadButton.setUserData(item); UiUtils.setPresent(imageView, item.hasImage()); if (item.hasImage() && item.getImageWidth() > 0 && item.getImageHeight() > 0) { // Improve layout by knowing the dimension in advance. imageView.setFitWidth(item.getImageWidth()); imageView.setFitHeight(item.getImageHeight()); } else { imageView.setFitWidth(0); imageView.setFitHeight(0); } imageView.setUrl(getImageUrl(item)); } private String getImageUrl(BoardMessage item) { if (item.hasImage()) { return RemoteUtils.getControlUrl() + BOARDS_PATH + "/messages/" + item.getId() + "/image"; } return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/board/BoardMessageWindowController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.board; import atlantafx.base.controls.Tab; import atlantafx.base.controls.TabLine; import io.xeres.common.util.OsUtils; import io.xeres.ui.client.BoardClient; import io.xeres.ui.client.LocationClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.EditorView; import io.xeres.ui.custom.ImageSelectorView; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.ProgressBar; import javafx.scene.control.TextField; import javafx.scene.layout.GridPane; import javafx.stage.FileChooser; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; import java.util.ResourceBundle; import static io.xeres.ui.support.util.UiUtils.getWindow; @Component @FxmlView(value = "/view/board/board_message_view.fxml") public class BoardMessageWindowController implements WindowController { @FXML private GridPane gridPane; @FXML private TextField boardName; @FXML private TextField title; @FXML private TabLine tabLine; @FXML private ProgressBar progressBar; @FXML private Tab textTab; @FXML private Tab imageTab; @FXML private Tab linkTab; @FXML private EditorView editorView; @FXML private Button send; private final List addedNodes = new ArrayList<>(); private ImageSelectorView imageSelectorView; private Label linkLabel; private TextField linkTextField; private long boardId; private final BoardClient boardClient; private final LocationClient locationClient; private final MarkdownService markdownService; private final ResourceBundle bundle; public BoardMessageWindowController(BoardClient boardClient, LocationClient locationClient, MarkdownService markdownService, ResourceBundle bundle) { this.boardClient = boardClient; this.locationClient = locationClient; this.markdownService = markdownService; this.bundle = bundle; } @Override public void initialize() { imageSelectorView = new ImageSelectorView(240.0, 180.0, "mdi2i-image", true); imageSelectorView.setOnSelectAction(this::selectMessageImage); imageSelectorView.setOnDeleteAction(this::clearMessageImage); linkLabel = new Label("URL"); linkTextField = new TextField(); tabLine.setTabClosingPolicy(Tab.ClosingPolicy.NO_TABS); tabLine.setTabDragPolicy(Tab.DragPolicy.FIXED); tabLine.setTabResizePolicy(Tab.ResizePolicy.ADAPTIVE); tabLine.getSelectionModel().selectedItemProperty().subscribe(tab -> { clearPanelContent(); if (tab == imageTab) { gridPane.add(addPanelContent(imageSelectorView), 0, 3, 2, 1); } else if (tab == linkTab) { gridPane.add(addPanelContent(linkLabel), 0, 3); gridPane.add(addPanelContent(linkTextField), 1, 3); } }); Platform.runLater(() -> title.requestFocus()); editorView.setInputContextMenu(locationClient); editorView.setMarkdownService(markdownService); title.setOnKeyTyped(_ -> checkSendable()); send.setOnAction(_ -> postMessage()); } private Node addPanelContent(Node node) { addedNodes.add(node); return node; } private void clearPanelContent() { addedNodes.forEach(node -> gridPane.getChildren().remove(node)); addedNodes.clear(); } private void checkSendable() { send.setDisable(StringUtils.isBlank(title.getText())); } @Override public void onShown() { var userData = UiUtils.getUserData(title); if (userData == null) { throw new IllegalArgumentException("Missing board id"); } boardId = (long) userData; boardClient.getBoardGroupById(boardId) .doOnSuccess(boardGroup -> Platform.runLater(() -> { assert boardGroup != null; boardName.setText(boardGroup.getName()); })) .subscribe(); // Prevent the message from being discarded by mistake UiUtils.getWindow(send).setOnCloseRequest(event -> { if (!title.getText().isBlank() || editorView.isModified() || !imageSelectorView.isEmpty() || !linkTextField.getText().isBlank()) { UiUtils.showAlertConfirm(bundle.getString("board.editor.cancel"), () -> UiUtils.getWindow(send).hide()); event.consume(); } }); } private void setWaiting(boolean waiting) { tabLine.setDisable(waiting); title.setDisable(waiting); editorView.setDisable(waiting); send.setDisable(waiting); UiUtils.setPresent(progressBar, waiting); } private void postMessage() { setWaiting(true); boardClient.createBoardMessage(boardId, title.getText(), editorView.getText(), linkTextField.getText(), imageSelectorView.getFile()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(send))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); } private void selectMessageImage(ActionEvent event) { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("board.select-image")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); ChooserUtils.setSupportedLoadImageFormats(fileChooser); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); imageSelectorView.setFile(selectedFile); } private void clearMessageImage(ActionEvent event) { imageSelectorView.setImage(null); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/board/BoardViewController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.board; import io.xeres.common.gxs.GxsGroupConstants; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.rest.notification.board.AddOrUpdateBoardGroups; import io.xeres.common.rest.notification.board.AddOrUpdateBoardMessages; import io.xeres.common.rest.notification.board.SetBoardGroupMessagesReadState; import io.xeres.common.rest.notification.board.SetBoardMessageReadState; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.BoardClient; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.client.NotificationClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.common.GxsGroupTreeTableAction; import io.xeres.ui.controller.common.GxsGroupTreeTableView; import io.xeres.ui.custom.InfoView; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.event.UnreadEvent; import io.xeres.ui.model.board.BoardGroup; import io.xeres.ui.model.board.BoardMapper; import io.xeres.ui.model.board.BoardMessage; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contentline.Content; import io.xeres.ui.support.loader.OnDemandLoader; import io.xeres.ui.support.loader.OnDemandLoaderAction; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.unread.UnreadService; import io.xeres.ui.support.uri.BoardUri; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.SplitPane; import javafx.scene.input.ScrollEvent; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import net.rgielen.fxweaver.core.FxmlView; import org.fxmisc.flowless.VirtualFlow; import org.fxmisc.flowless.VirtualizedScrollPane; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.Disposable; import java.util.*; import static io.xeres.common.rest.PathConfig.BOARDS_PATH; import static io.xeres.ui.support.preference.PreferenceUtils.BOARDS; import static javafx.scene.control.Alert.AlertType.WARNING; @Component @FxmlView(value = "/view/board/board_view.fxml") public class BoardViewController implements Controller, GxsGroupTreeTableAction, OnDemandLoaderAction { private final WindowManager windowManager; @FXML private GxsGroupTreeTableView boardTree; @FXML private SplitPane splitPaneVertical; @FXML private Button createBoard; @FXML private Button newPost; @FXML private StackPane contentGroup; private InfoView infoView; private final ObservableList messages = FXCollections.observableArrayList(); private OnDemandLoader onDemandLoader; private VirtualFlow virtualFlow; private final ResourceBundle bundle; private final BoardClient boardClient; private final NotificationClient notificationClient; private final GeneralClient generalClient; private final ImageCache imageCacheService; private final UnreadService unreadService; private final MarkdownService markdownService; private final ImageCache imageCache; private Disposable notificationDisposable; private UrlToOpen urlToOpen; public BoardViewController(BoardClient boardClient, ResourceBundle bundle, NotificationClient notificationClient, GeneralClient generalClient, ImageCache imageCacheService, UnreadService unreadService, MarkdownService markdownService, WindowManager windowManager, ImageCache imageCache) { this.boardClient = boardClient; this.bundle = bundle; this.notificationClient = notificationClient; this.generalClient = generalClient; this.imageCacheService = imageCacheService; this.unreadService = unreadService; this.markdownService = markdownService; this.windowManager = windowManager; this.imageCache = imageCache; } @Override public void initialize() { boardTree.initialize(BOARDS, boardClient, BoardGroup::new, () -> new BoardGroupCell(generalClient, imageCacheService), this); boardTree.unreadProperty().addListener((_, _, newValue) -> unreadService.sendUnreadEvent(UnreadEvent.Element.BOARD, newValue)); // VirtualizedScrollPane doesn't work from FXML so we add it manually virtualFlow = VirtualFlow.createVertical(messages, boardMessage -> new BoardMessageCell(boardMessage, generalClient, boardClient, markdownService)); VirtualizedScrollPane> messagesView = new VirtualizedScrollPane<>(virtualFlow); VBox.setVgrow(messagesView, Priority.ALWAYS); contentGroup.getChildren().add(messagesView); // Create InfoView to display group info infoView = new InfoView(); infoView.setLoader(url -> generalClient.getImage(url).block()); contentGroup.getChildren().add(infoView); infoView.setVisible(false); onDemandLoader = new OnDemandLoader<>(messagesView, messages, boardClient, this); // The default handler is a bit slow, let's speed up // mouse scrolling. messagesView.addEventFilter(ScrollEvent.ANY, se -> { messagesView.scrollXBy(-se.getDeltaX()); messagesView.scrollYBy(-se.getDeltaY() * 4); se.consume(); }); createBoard.setOnAction(_ -> windowManager.openBoardCreation(0L)); newPost.setOnAction(_ -> newBoardPost()); setupBoardNotifications(); } @EventListener public void handleOpenUriEvent(OpenUriEvent event) { if (event.uri() instanceof BoardUri boardUri) { if (!boardTree.openUrl(boardUri.gxsId(), boardUri.msgId())) { UiUtils.showAlert(WARNING, bundle.getString("board.view.group.not-found")); } } } @Override public void onSubscribeToGroup(BoardGroup group) { } @Override public void onUnsubscribeFromGroup(BoardGroup group) { } @Override public void onCopyGroupLink(BoardGroup group) { var boardUri = new BoardUri(group.getName(), group.getGxsId(), null); ClipboardUtils.copyTextToClipboard(boardUri.toUriString()); } @Override public void onOpenUrl(GxsId gxsId, MsgId msgId) { if (gxsId.equals(boardTree.getSelectedGroupGxsId())) { selectMessage(msgId); } else { urlToOpen = new UrlToOpen(gxsId, msgId); } } private void selectMessage(MsgId msgId) { for (var i = 0; i < messages.size(); i++) { var message = messages.get(i); if (message.getMsgId().equals(msgId)) { virtualFlow.show(i); break; } } } @Override public void onSelectSubscribedGroup(BoardGroup group) { onDemandLoader.changeSelection(group); newPost.setDisable(false); showGroupInfo(null); } @Override public void onSelectUnsubscribedGroup(BoardGroup group) { onDemandLoader.changeSelection(group); newPost.setDisable(true); showGroupInfo(group); } @Override public void onUnselectGroup() { onDemandLoader.changeSelection(null); newPost.setDisable(true); showGroupInfo(null); } @Override public void onEditGroup(BoardGroup group) { windowManager.openBoardCreation(group.getId()); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (notificationDisposable != null && !notificationDisposable.isDisposed()) { notificationDisposable.dispose(); } } private void setupBoardNotifications() { notificationDisposable = notificationClient.getBoardNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { switch (sse.data()) { case AddOrUpdateBoardGroups action -> { action.boardGroups().forEach(boardGroupItem -> imageCache.evictImage(RemoteUtils.getControlUrl() + BOARDS_PATH + "/groups/" + boardGroupItem.id() + "/image")); boardTree.addGroups(action.boardGroups().stream() .map(BoardMapper::fromDTO) .toList()); } case AddOrUpdateBoardMessages action -> addBoardMessages(action.boardMessages().stream() .map(BoardMapper::fromDTO) .toList()); case SetBoardMessageReadState action -> setMessageReadState(action.groupId(), action.messageId(), action.read()); case SetBoardGroupMessagesReadState action -> setGroupMessagesReadState(action.groupId(), action.read()); case null -> throw new IllegalArgumentException("Board notifications have not been set"); } })) .subscribe(); } private void setMessageReadState(long groupId, long messageId, boolean read) { onDemandLoader.setMessageReadState(groupId, messageId, read); boardTree.setUnreadCount(groupId, read); } private void setGroupMessagesReadState(long groupId, boolean read) { onDemandLoader.setGroupMessagesReadState(groupId, read); boardTree.refreshUnreadCount(groupId); } private void newBoardPost() { windowManager.openBoardMessage(boardTree.getSelectedGroupId()); } private void addBoardMessages(List boardMessages) { Set boardsToUpdate = new HashSet<>(); for (BoardMessage boardMessage : boardMessages) { onDemandLoader.insertMessage(boardMessage); boardsToUpdate.add(boardMessage.getGxsId()); } boardTree.refreshUnreadCount(boardsToUpdate); } @Override public void onMessagesLoaded(BoardGroup group) { if (urlToOpen != null) { if (group.getGxsId().equals(urlToOpen.gxsId())) { selectMessage(urlToOpen.msgId()); urlToOpen = null; } } } private List createContent(String input) { return markdownService.parse(input, EnumSet.noneOf(MarkdownService.Rendering.class)).stream() .map(Content::getNode).toList(); } private void showGroupInfo(BoardGroup group) { if (group != null && group.isReal()) { var header = createContent(""" ## %s %s: %s\\ %s: %s """.formatted( group.getName(), bundle.getString("posts-at-remote-nodes"), group.getVisibleMessageCount(), bundle.getString("last-activity"), DateUtils.formatDateTime(group.getLastActivity(), bundle.getString("unknown-lc")))); var body = createContent(group.getDescription()); if (group.hasImage()) { infoView.setInfo(header, body, RemoteUtils.getControlUrl() + BOARDS_PATH + "/groups/" + group.getId() + "/image", GxsGroupConstants.IMAGE_SIDE_SIZE, GxsGroupConstants.IMAGE_SIDE_SIZE); } else { infoView.setInfo(header, body); } infoView.setVisible(true); } else { infoView.setInfo(null, null); infoView.setVisible(false); } } record UrlToOpen(GxsId gxsId, MsgId msgId) { } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/channel/ChannelFileSizeCell.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.channel; import io.xeres.common.util.ByteUnitUtils; import io.xeres.ui.model.channel.ChannelFile; import javafx.scene.control.TableCell; class ChannelFileSizeCell extends TableCell { @Override protected void updateItem(Long value, boolean empty) { super.updateItem(value, empty); setText(empty ? null : ByteUnitUtils.fromBytes(value)); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/channel/ChannelGroupCell.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.channel; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.custom.asyncimage.PlaceholderImageView; import io.xeres.ui.model.channel.ChannelGroup; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.TreeTableCell; import javafx.scene.image.ImageView; import java.text.MessageFormat; import java.util.ResourceBundle; import static io.xeres.common.rest.PathConfig.CHANNELS_PATH; public class ChannelGroupCell extends TreeTableCell { private static final int IMAGE_WIDTH = 32; private static final int IMAGE_HEIGHT = 32; private final GeneralClient generalClient; private final ImageCache imageCache; private static final ResourceBundle bundle = I18nUtils.getBundle(); public ChannelGroupCell(GeneralClient generalClient, ImageCache imageCache) { super(); this.generalClient = generalClient; this.imageCache = imageCache; TooltipUtils.install(this, () -> MessageFormat.format(bundle.getString("gxs-group.tree.info"), getItem().getName(), getItem().getGxsId(), getItem().getVisibleMessageCount(), DateUtils.formatDateTime(getItem().getLastActivity(), bundle.getString("unknown-lc"))), () -> new ImageView(((PlaceholderImageView) super.getGraphic()).getImage())); } @Override protected void updateItem(ChannelGroup item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : item.getName()); setGraphic(empty ? null : updateImage((PlaceholderImageView) getGraphic(), item)); } private PlaceholderImageView updateImage(PlaceholderImageView placeholderImageView, ChannelGroup item) { if (placeholderImageView == null) { placeholderImageView = new PlaceholderImageView( url -> generalClient.getImage(url).block(), "mdi2p-play-box", imageCache); } if (item.isReal()) { placeholderImageView.setFitWidth(IMAGE_WIDTH); placeholderImageView.setFitHeight(IMAGE_HEIGHT); placeholderImageView.setUrl(getImageUrl(item)); } else { placeholderImageView.setFitWidth(0); placeholderImageView.setFitHeight(0); placeholderImageView.setUrl(null); placeholderImageView.hideDefault(); // SetUrl(null) shows a default, but we don't want one as we're tree group nodes } return placeholderImageView; } private String getImageUrl(ChannelGroup item) { if (item.isReal() && item.hasImage()) { return RemoteUtils.getControlUrl() + CHANNELS_PATH + "/groups/" + item.getId() + "/image"; } return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/channel/ChannelGroupWindowController.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.channel; import io.xeres.common.util.OsUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.ChannelClient; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.ImageSelectorView; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.ProgressBar; import javafx.scene.control.TextField; import javafx.stage.FileChooser; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.lang3.Strings; import org.springframework.stereotype.Component; import java.util.ResourceBundle; import static io.xeres.common.rest.PathConfig.CHANNELS_PATH; import static io.xeres.ui.support.util.UiUtils.getWindow; @Component @FxmlView(value = "/view/channel/channel_group_view.fxml") public class ChannelGroupWindowController implements WindowController { @FXML private Button createOrUpdateButton; @FXML private Button cancelButton; @FXML private TextField channelName; @FXML private TextField channelDescription; @FXML private ImageSelectorView channelLogo; @FXML private ProgressBar progressBar; private final ChannelClient channelClient; private final GeneralClient generalClient; private final ResourceBundle bundle; private long channelId; private String initialUrl; private String initialName; private String initialDescription; public ChannelGroupWindowController(ChannelClient channelClient, GeneralClient generalClient, ResourceBundle bundle) { this.channelClient = channelClient; this.generalClient = generalClient; this.bundle = bundle; } @Override public void initialize() { channelName.textProperty().addListener(_ -> checkCreatableOrUpdatable()); channelDescription.textProperty().addListener(_ -> checkCreatableOrUpdatable()); channelLogo.imageProperty().addListener(_ -> checkCreatableOrUpdatable()); channelLogo.setOnSelectAction(this::selectGroupImage); channelLogo.setOnDeleteAction(this::clearGroupImage); channelLogo.setImageLoader(url -> generalClient.getImage(url).block()); cancelButton.setOnAction(UiUtils::closeWindow); } @Override public void onShown() { var userData = UiUtils.getUserData(channelName); if (userData != null) { channelId = (long) userData; } if (channelId != 0L) { channelClient.getChannelGroupById(channelId) .doOnSuccess(channelGroup -> Platform.runLater(() -> { assert channelGroup != null; channelName.setText(channelGroup.getName()); channelDescription.setText(channelGroup.getDescription()); if (channelGroup.hasImage()) { channelLogo.setImageUrl(RemoteUtils.getControlUrl() + CHANNELS_PATH + "/groups/" + channelGroup.getId() + "/image"); initialUrl = channelLogo.getUrl(); } initialName = channelName.getText(); initialDescription = channelDescription.getText(); createOrUpdateButton.setDisable(true); })) .subscribe(); createOrUpdateButton.setText(bundle.getString("update")); createOrUpdateButton.setOnAction(_ -> { setWaiting(true); channelClient.updateChannelGroup(channelId, channelName.getText(), channelDescription.getText(), channelLogo.getFile(), !Strings.CS.equals(initialUrl, channelLogo.getUrl())) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(channelName))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); }); } else { createOrUpdateButton.setOnAction(_ -> { setWaiting(true); channelClient.createChannelGroup(channelName.getText(), channelDescription.getText(), channelLogo.getFile()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(channelName))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); }); } } private void setWaiting(boolean waiting) { channelName.setDisable(waiting); channelDescription.setDisable(waiting); channelLogo.setDisable(waiting); createOrUpdateButton.setDisable(waiting); cancelButton.setDisable(waiting); UiUtils.setPresent(progressBar, waiting); } private void checkCreatableOrUpdatable() { createOrUpdateButton.setDisable((channelId == 0L && channelName.getText().isBlank()) || (channelId == 0L && channelDescription.getText().isBlank()) || ( Strings.CS.equals(initialName, channelName.getText()) && Strings.CS.equals(initialDescription, channelDescription.getText()) && Strings.CS.equals(initialUrl, channelLogo.getUrl()) ) ); } private void selectGroupImage(ActionEvent event) { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("channel.select-logo")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); ChooserUtils.setSupportedLoadImageFormats(fileChooser); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); channelLogo.setFile(selectedFile); } private void clearGroupImage(ActionEvent event) { channelLogo.setImage(null); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/channel/ChannelMessageCell.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.channel; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.custom.asyncimage.PlaceholderImageView; import io.xeres.ui.model.channel.ChannelMessage; import io.xeres.ui.support.util.DateUtils; import javafx.css.PseudoClass; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.layout.HBox; import org.fxmisc.flowless.Cell; import java.io.IOException; import static io.xeres.common.rest.PathConfig.CHANNELS_PATH; class ChannelMessageCell implements Cell { private static final PseudoClass selectedPseudoClass = PseudoClass.getPseudoClass("selected"); private static final PseudoClass unreadPseudoClass = PseudoClass.getPseudoClass("unread"); @FXML private HBox groupView; @FXML private Label titleLabel; @FXML private Label postInstantLabel; @FXML private PlaceholderImageView imageView; public ChannelMessageCell(ChannelMessage channelMessage, GeneralClient generalClient) { var loader = new FXMLLoader(ChannelMessageCell.class.getResource("/view/channel/message_cell.fxml")); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } imageView.setLoader(url -> generalClient.getImage(url).block()); updateItem(channelMessage); } @Override public Node getNode() { return groupView; } @Override public boolean isReusable() { return true; } @Override public void updateItem(ChannelMessage item) { titleLabel.setText(item.getName()); postInstantLabel.setText(DateUtils.DATE_TIME_FORMAT.format(item.getPublished())); imageView.setUrl(getImageUrl(item)); groupView.pseudoClassStateChanged(selectedPseudoClass, item.isSelected()); groupView.pseudoClassStateChanged(unreadPseudoClass, !item.isRead()); } private String getImageUrl(ChannelMessage item) { if (item.hasImage()) { return RemoteUtils.getControlUrl() + CHANNELS_PATH + "/messages/" + item.getId() + "/image"; } return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/channel/ChannelMessageRow.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.channel; import io.micrometer.common.util.StringUtils; import io.xeres.ui.model.channel.ChannelFile; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.TableRow; class ChannelMessageRow extends TableRow { @Override protected void updateItem(ChannelFile item, boolean empty) { super.updateItem(item, empty); if (empty) { TooltipUtils.uninstall(this); } else { if (StringUtils.isNotBlank(item.getPath())) { TooltipUtils.install(this, item.getPath()); } } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/channel/ChannelMessageWindowController.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.channel; import io.xeres.common.util.OsUtils; import io.xeres.ui.client.ChannelClient; import io.xeres.ui.client.LocationClient; import io.xeres.ui.client.ShareClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.EditorView; import io.xeres.ui.custom.ImageSelectorView; import io.xeres.ui.model.channel.ChannelFile; import io.xeres.ui.model.channel.ChannelFile.State; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.uri.FileUri; import io.xeres.ui.support.uri.UriFactory; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.input.TransferMode; import javafx.stage.FileChooser; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import reactor.core.publisher.SignalType; import java.io.File; import java.util.*; import static io.xeres.ui.support.util.UiUtils.getWindow; @Component @FxmlView(value = "/view/channel/channel_message_view.fxml") public class ChannelMessageWindowController implements WindowController { @FXML private TextField channelName; @FXML private TextField title; @FXML private ImageSelectorView postLogo; @FXML private TabPane tabPane; @FXML private ProgressBar progressBar; @FXML private EditorView editorView; @FXML private TableView channelFileTableView; @FXML private TableColumn tableName; @FXML private TableColumn tableSize; @FXML private TableColumn tableState; @FXML private TableColumn tableHash; @FXML private Button send; @FXML private Button addFile; @FXML private Button removeFile; @FXML private Button pasteLink; private long channelId; private final ChannelClient channelClient; private final LocationClient locationClient; private final MarkdownService markdownService; private final ShareClient shareClient; private final ResourceBundle bundle; private final Queue filesToAdd = new ArrayDeque<>(); private final ObservableList files = FXCollections.observableArrayList(); public ChannelMessageWindowController(ChannelClient channelClient, LocationClient locationClient, MarkdownService markdownService, ShareClient shareClient, ResourceBundle bundle) { this.channelClient = channelClient; this.locationClient = locationClient; this.markdownService = markdownService; this.shareClient = shareClient; this.bundle = bundle; } @Override public void initialize() { postLogo.setOnSelectAction(this::selectMessageImage); postLogo.setOnDeleteAction(this::clearMessageImage); Platform.runLater(() -> title.requestFocus()); editorView.setInputContextMenu(locationClient); editorView.setMarkdownService(markdownService); title.setOnKeyTyped(_ -> checkSendable()); addFile.setOnAction(event -> { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("file-requester.add-files")); var selectedFiles = fileChooser.showOpenMultipleDialog(getWindow(event)); if (selectedFiles != null) { addFiles(selectedFiles); } }); removeFile.setOnAction(_ -> channelFileTableView.getItems().removeAll(channelFileTableView.getSelectionModel().getSelectedItems())); removeFile.disableProperty().bind(Bindings.isEmpty(channelFileTableView.getSelectionModel().getSelectedItems())); pasteLink.setOnAction(_ -> { var s = ClipboardUtils.getStringFromClipboard(); if (StringUtils.isNotBlank(s)) { String[] lines = s.split("\\R"); Arrays.stream(lines).forEach(line -> { var uri = UriFactory.createUri(line); if (uri instanceof FileUri fileUri) { addUri(fileUri); } }); } else { UiUtils.showAlert(Alert.AlertType.INFORMATION, bundle.getString("channel.clipboard.error")); } }); send.setOnAction(_ -> postMessage()); tableName.setCellValueFactory(new PropertyValueFactory<>("name")); tableSize.setCellFactory(_ -> new ChannelFileSizeCell()); tableSize.setCellValueFactory(new PropertyValueFactory<>("size")); tableState.setCellValueFactory(new PropertyValueFactory<>("state")); tableHash.setCellValueFactory(new PropertyValueFactory<>("hash")); channelFileTableView.setRowFactory(_ -> new ChannelMessageRow()); channelFileTableView.setOnDragOver(event -> { if (event.getDragboard().hasFiles()) { event.acceptTransferModes(TransferMode.COPY_OR_MOVE); } event.consume(); }); channelFileTableView.setOnDragDropped(event -> { var droppedFiles = event.getDragboard().getFiles(); addFiles(droppedFiles); event.setDropCompleted(true); event.consume(); }); channelFileTableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); channelFileTableView.setItems(files); } private void addFiles(List files) { filesToAdd.addAll(CollectionUtils.emptyIfNull(files)); addNextFile(); } private void addUri(FileUri fileUri) { var channelFile = new ChannelFile(fileUri.name(), null, State.DONE, fileUri.size(), fileUri.hash().toString()); if (files.contains(channelFile)) { return; // Already present } files.add(channelFile); } private void addNextFile() { var file = filesToAdd.poll(); if (file != null) { var channelFile = new ChannelFile(file.getName(), file.getPath(), State.HASHING, file.length(), null); if (files.contains(channelFile)) { return; // Already present } files.add(channelFile); shareClient.createTemporaryShare(file.getAbsolutePath()) .doOnSuccess(result -> Platform.runLater(() -> { assert result != null; channelFile.setHash(result.hash()); channelFile.setState(State.DONE); })) .doFinally(signalType -> { if (signalType != SignalType.CANCEL) { Platform.runLater(this::addNextFile); } }) .subscribe(); } } @Override public void onShown() { var userData = UiUtils.getUserData(title); if (userData == null) { throw new IllegalArgumentException("Missing channel id"); } channelId = (long) userData; channelClient.getChannelGroupById(channelId) .doOnSuccess(channelGroup -> Platform.runLater(() -> { assert channelGroup != null; channelName.setText(channelGroup.getName()); })) .subscribe(); // Prevent the message from being discarded by mistake UiUtils.getWindow(send).setOnCloseRequest(event -> { if (!title.getText().isBlank() || editorView.isModified() || !postLogo.isEmpty()) // XXX: add file list condition { UiUtils.showAlertConfirm(bundle.getString("channel.editor.cancel"), () -> UiUtils.getWindow(send).hide()); event.consume(); } }); } private void checkSendable() { send.setDisable(StringUtils.isBlank(title.getText())); // XXX: more? } private void setWaiting(boolean waiting) { tabPane.setDisable(waiting); send.setDisable(waiting); UiUtils.setPresent(progressBar, waiting); } private void postMessage() { setWaiting(true); channelClient.createChannelMessage(channelId, title.getText(), editorView.getText(), postLogo.getFile(), files, 0L) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(send))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); } private void selectMessageImage(ActionEvent event) { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("channel.select-image")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); ChooserUtils.setSupportedLoadImageFormats(fileChooser); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); postLogo.setFile(selectedFile); } private void clearMessageImage(ActionEvent event) { postLogo.setImage(null); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/channel/ChannelViewController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.channel; import io.xeres.common.gxs.GxsGroupConstants; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.id.Sha1Sum; import io.xeres.common.rest.notification.channel.AddOrUpdateChannelGroups; import io.xeres.common.rest.notification.channel.AddOrUpdateChannelMessages; import io.xeres.common.rest.notification.channel.SetChannelGroupMessagesReadState; import io.xeres.common.rest.notification.channel.SetChannelMessageReadState; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.ChannelClient; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.client.NotificationClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.common.GxsGroupTreeTableAction; import io.xeres.ui.controller.common.GxsGroupTreeTableView; import io.xeres.ui.custom.InfoView; import io.xeres.ui.custom.ProgressPane; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.event.UnreadEvent; import io.xeres.ui.model.channel.ChannelFile; import io.xeres.ui.model.channel.ChannelGroup; import io.xeres.ui.model.channel.ChannelMapper; import io.xeres.ui.model.channel.ChannelMessage; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contentline.Content; import io.xeres.ui.support.loader.OnDemandLoader; import io.xeres.ui.support.loader.OnDemandLoaderAction; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.unread.UnreadService; import io.xeres.ui.support.uri.ChannelUri; import io.xeres.ui.support.uri.FileUriFactory; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.SplitPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.lang3.StringUtils; import org.fxmisc.flowless.VirtualFlow; import org.fxmisc.flowless.VirtualizedScrollPane; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.Disposable; import java.util.*; import java.util.stream.Collectors; import static io.xeres.common.rest.PathConfig.CHANNELS_PATH; import static io.xeres.ui.support.preference.PreferenceUtils.CHANNELS; import static io.xeres.ui.support.util.DateUtils.DATE_TIME_PRECISE_FORMAT; import static javafx.scene.control.Alert.AlertType.WARNING; @Component @FxmlView(value = "/view/channel/channel_view.fxml") public class ChannelViewController implements Controller, GxsGroupTreeTableAction, OnDemandLoaderAction { @FXML private GxsGroupTreeTableView channelTree; @FXML private SplitPane splitPaneVertical; @FXML private SplitPane splitPaneHorizontal; @FXML private Button createChannel; @FXML private Button newPost; @FXML private ProgressPane channelMessagesProgress; @FXML private InfoView infoView; private final ObservableList messages = FXCollections.observableArrayList(); private OnDemandLoader onDemandLoader; private final ResourceBundle bundle; private final ChannelClient channelClient; private final NotificationClient notificationClient; private final GeneralClient generalClient; private final ImageCache imageCache; private final UnreadService unreadService; private final WindowManager windowManager; private final MarkdownService markdownService; private Disposable notificationDisposable; private ChannelMessage selectedChannelMessage; private UrlToOpen urlToOpen; public ChannelViewController(ResourceBundle bundle, ChannelClient channelClient, NotificationClient notificationClient, GeneralClient generalClient, ImageCache imageCache, UnreadService unreadService, WindowManager windowManager, MarkdownService markdownService) { this.channelClient = channelClient; this.bundle = bundle; this.notificationClient = notificationClient; this.generalClient = generalClient; this.imageCache = imageCache; this.unreadService = unreadService; this.windowManager = windowManager; this.markdownService = markdownService; } @Override public void initialize() { channelTree.initialize(CHANNELS, channelClient, ChannelGroup::new, () -> new ChannelGroupCell(generalClient, imageCache), this); channelTree.unreadProperty().addListener((_, _, newValue) -> unreadService.sendUnreadEvent(UnreadEvent.Element.CHANNEL, newValue)); // VirtualizedScrollPane doesn't work from FXML so we add it manually VirtualizedScrollPane> messagesView = new VirtualizedScrollPane<>(VirtualFlow.createVertical(messages, channelMessage -> new ChannelMessageCell(channelMessage, generalClient))); VBox.setVgrow(messagesView, Priority.ALWAYS); channelMessagesProgress.getChildren().add(messagesView); onDemandLoader = new OnDemandLoader<>(messagesView, messages, channelClient, this); createChannel.setOnAction(_ -> windowManager.openChannelCreation(0L)); newPost.setOnAction(_ -> newChannelPost()); infoView.setLoader(url -> generalClient.getImage(url).block()); messagesView.setOnMouseClicked(event -> { var hit = messagesView.getContent().hit(event.getX(), event.getY()); if (hit.isCellHit()) { changeSelectedChannelMessage(hit.getCellIndex()); } }); setupChannelNotifications(); } @EventListener public void handleOpenUriEvent(OpenUriEvent event) { if (event.uri() instanceof ChannelUri channelUri) { if (!channelTree.openUrl(channelUri.gxsId(), channelUri.msgId())) { UiUtils.showAlert(WARNING, bundle.getString("channel.view.group.not-found")); } } } private void changeSelectedChannelMessage(int index) { if (index >= 0) { var channelMessage = messages.get(index); if (Objects.equals(selectedChannelMessage, channelMessage)) { return; } clearSelected(); selectedChannelMessage = channelMessage; channelMessage.setSelected(true); messages.set(index, channelMessage); channelClient.getChannelMessage(channelMessage.getId()) .doOnSuccess(message -> Platform.runLater(() -> { assert message != null; setCommonMessageAttributes(message); // XXX: multiple versions? if (!message.isRead()) { channelClient.setChannelMessageReadState(message.getId(), true) .subscribe(); } })) .doOnError(UiUtils::webAlertError) .subscribe(); } } private void setCommonMessageAttributes(ChannelMessage message) { var header = createContent("## " + message.getName() + "\n\n#### " + DATE_TIME_PRECISE_FORMAT.format(message.getPublished())); var body = createContent(StringUtils.defaultString(message.getContent()) + "\n\n" + getFiles(message.getFiles())); if (message.hasImage()) { infoView.setInfo(header, body, RemoteUtils.getControlUrl() + CHANNELS_PATH + "/messages/" + message.getId() + "/image", message.getImageWidth(), message.getImageHeight()); } else { infoView.setInfo(header, body); } } private String getFiles(List files) { var result = files.isEmpty() ? "" : "\n\n### %s\n\n- ".formatted(bundle.getString("channel.files")); result += files.stream() .map(file -> FileUriFactory.generateMarkdown(file.getName(), file.getSize(), Sha1Sum.fromString(file.getHash()))) .collect(Collectors.joining("\n- ")); return result; } private void clearSelected() { if (selectedChannelMessage != null) { selectedChannelMessage.setSelected(false); messages.set(messages.indexOf(selectedChannelMessage), selectedChannelMessage); } } @Override public void onSubscribeToGroup(ChannelGroup group) { } @Override public void onUnsubscribeFromGroup(ChannelGroup group) { } @Override public void onCopyGroupLink(ChannelGroup group) { var channelUri = new ChannelUri(group.getName(), group.getGxsId(), null); ClipboardUtils.copyTextToClipboard(channelUri.toUriString()); } @Override public void onOpenUrl(GxsId gxsId, MsgId msgId) { if (gxsId.equals(channelTree.getSelectedGroupGxsId())) { selectMessage(msgId); } else { urlToOpen = new UrlToOpen(gxsId, msgId); } } @Override public void onMessagesLoaded(ChannelGroup group) { channelMessagesState(false); if (urlToOpen != null) { if (group.getGxsId().equals(urlToOpen.gxsId())) { selectMessage(urlToOpen.msgId()); urlToOpen = null; } } } private void selectMessage(MsgId msgId) { for (var i = 0; i < messages.size(); i++) { var message = messages.get(i); if (message.getMsgId().equals(msgId)) { changeSelectedChannelMessage(i); break; } } } @Override public void onSelectSubscribedGroup(ChannelGroup group) { selectedChannelMessage = null; channelMessagesState(true); onDemandLoader.changeSelection(group); newPost.setDisable(group.isExternal()); showGroupInfo(group); } @Override public void onSelectUnsubscribedGroup(ChannelGroup group) { selectedChannelMessage = null; onDemandLoader.changeSelection(group); newPost.setDisable(true); showGroupInfo(group); } @Override public void onUnselectGroup() { selectedChannelMessage = null; onDemandLoader.changeSelection(null); newPost.setDisable(true); showGroupInfo(null); } @Override public void onEditGroup(ChannelGroup group) { windowManager.openChannelCreation(group.getId()); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (notificationDisposable != null && !notificationDisposable.isDisposed()) { notificationDisposable.dispose(); } } private void setupChannelNotifications() { notificationDisposable = notificationClient.getChannelNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { switch (sse.data()) { case AddOrUpdateChannelGroups action -> { action.channelGroups().forEach(channelGroupItem -> imageCache.evictImage(RemoteUtils.getControlUrl() + CHANNELS_PATH + "/groups/" + channelGroupItem.id() + "/image")); channelTree.addGroups(action.channelGroups().stream() .map(ChannelMapper::fromDTO) .toList()); } case AddOrUpdateChannelMessages action -> addChannelMessages(action.channelMessages().stream() .map(ChannelMapper::fromDTO) .toList()); case SetChannelMessageReadState action -> setMessageReadState(action.groupId(), action.messageId(), action.read()); case SetChannelGroupMessagesReadState action -> setGroupMessagesReadState(action.groupId(), action.read()); case null -> throw new IllegalArgumentException("Channel notifications have not been set"); } })) .subscribe(); } private void setMessageReadState(long groupId, long messageId, boolean read) { onDemandLoader.setMessageReadState(groupId, messageId, read); channelTree.setUnreadCount(groupId, read); } private void setGroupMessagesReadState(long groupId, boolean read) { onDemandLoader.setGroupMessagesReadState(groupId, read); channelTree.refreshUnreadCount(groupId); } private void newChannelPost() { windowManager.openChannelMessage(channelTree.getSelectedGroupId()); } private void addChannelMessages(List channelMessages) { Set channelsToUpdate = new HashSet<>(); for (ChannelMessage channelMessage : channelMessages) { onDemandLoader.insertMessage(channelMessage); channelsToUpdate.add(channelMessage.getGxsId()); } channelTree.refreshUnreadCount(channelsToUpdate); } private List createContent(String input) { return markdownService.parse(input, EnumSet.noneOf(MarkdownService.Rendering.class)).stream() .map(Content::getNode).toList(); } private void channelMessagesState(boolean loading) { Platform.runLater(() -> channelMessagesProgress.showProgress(loading)); } private void showGroupInfo(ChannelGroup group) { if (group != null && group.isReal()) { var header = createContent(""" ## %s %s: %s\\ %s: %s """.formatted( group.getName(), bundle.getString("posts-at-remote-nodes"), group.getVisibleMessageCount(), bundle.getString("last-activity"), DateUtils.formatDateTime(group.getLastActivity(), bundle.getString("unknown-lc")))); var body = createContent(group.getDescription()); if (group.hasImage()) { infoView.setInfo(header, body, RemoteUtils.getControlUrl() + CHANNELS_PATH + "/groups/" + group.getId() + "/image", GxsGroupConstants.IMAGE_SIDE_SIZE, GxsGroupConstants.IMAGE_SIDE_SIZE); } else { infoView.setInfo(header, body); } } else { infoView.setInfo(null, null); } channelMessagesState(false); } record UrlToOpen(GxsId gxsId, MsgId msgId) { } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.ui.support.chat.ChatLine; import io.xeres.ui.support.chat.ColorGenerator; import io.xeres.ui.support.contentline.Content; import javafx.css.PseudoClass; import javafx.scene.control.Label; import javafx.scene.shape.Path; import javafx.scene.text.TextFlow; import org.fxmisc.flowless.Cell; import java.util.List; import static io.xeres.ui.support.util.DateUtils.TIME_FORMAT; class ChatListCell implements Cell { private static final PseudoClass passivePseudoClass = PseudoClass.getPseudoClass("passive"); private static final PseudoClass quotedPseudoClass = PseudoClass.getPseudoClass("quoted"); private static final List allColors = ColorGenerator.getAllColors(); private final TextFlow content; private final Label time; private final Label action; private boolean isRich; public ChatListCell(ChatLine line) { content = new TextFlow(); content.getStyleClass().add("list-cell"); time = new Label(); time.getStyleClass().add("time"); action = new Label(); action.getStyleClass().add("action"); content.getChildren().addAll(time, action); updateItem(line); } @Override public TextFlow getNode() { return content; } @Override public boolean isReusable() { return !isRich && !(content.getChildren().getLast() instanceof Path); // Do not reuse rich content AND selected content } @Override public void reset() { if (isReusable()) { if (content.getChildren().size() > 2) { content.getChildren().remove(2); // keep time and action only } } } @Override public void updateItem(ChatLine line) { isRich = line.isRich(); time.setText(TIME_FORMAT.format(line.getInstant())); action.setText(line.getAction()); action.getStyleClass().removeAll(allColors); var nicknameColor = line.getNicknameColor(); if (nicknameColor != null) { action.getStyleClass().add(nicknameColor); } var nodes = line.getChatContents().stream() .map(Content::getNode) .toList(); content.pseudoClassStateChanged(passivePseudoClass, !line.isActiveAction()); content.pseudoClassStateChanged(quotedPseudoClass, line.isQuote()); content.getChildren().addAll(nodes); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.micrometer.common.util.StringUtils; import io.xeres.ui.support.chat.ChatLine; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.util.TextFlowUtils; import io.xeres.ui.support.util.TextFlowUtils.Options; import io.xeres.ui.support.util.TextSelectRange; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.input.MouseEvent; import javafx.scene.text.HitInfo; import javafx.scene.text.TextFlow; import org.fxmisc.flowless.VirtualFlow; import org.fxmisc.flowless.VirtualFlowHit; import java.util.LinkedList; import java.util.List; import java.util.stream.Collectors; class ChatListDragSelection { private final Node focusNode; private enum SelectionMode { TEXT, ACTION_AND_TEXT, TIME_ACTION_AND_TEXT } private enum Direction { SAME, DOWN, UP } private HitInfo firstHitInfo; private int startCellIndex; private int lastCellIndex; private SelectionMode selectionMode; private TextSelectRange textSelectRange; // This is used for one line of text private Direction direction = Direction.SAME; private final List textFlows = new LinkedList<>(); public ChatListDragSelection(Node focusNode) { this.focusNode = focusNode; } public void press(MouseEvent e) { if (e.getEventType() != MouseEvent.MOUSE_PRESSED) { throw new IllegalArgumentException("Event must be a MOUSE_PRESSED event"); } clearSelection(); var virtualFlow = getVirtualFlow(e); virtualFlow.setCursor(Cursor.TEXT); var hitResult = virtualFlow.hit(e.getX(), e.getY()); if (hitResult.isCellHit()) { var textFlow = hitResult.getCell().getNode(); startCellIndex = hitResult.getCellIndex(); textFlows.add(textFlow); var hitInfo = textFlow.getHitInfo(hitResult.getCellOffset()); firstHitInfo = hitInfo; switch (hitInfo.getCharIndex()) { case 0 -> selectionMode = SelectionMode.TIME_ACTION_AND_TEXT; case 1 -> selectionMode = SelectionMode.ACTION_AND_TEXT; default -> selectionMode = SelectionMode.TEXT; } } } public void drag(MouseEvent e) { if (e.getEventType() != MouseEvent.MOUSE_DRAGGED) { throw new IllegalArgumentException("Event must be a MOUSE_DRAGGED event"); } var virtualFlow = getVirtualFlow(e); var hitResult = virtualFlow.hit(e.getX(), e.getY()); if (hitResult.isCellHit()) { var cellIndex = hitResult.getCellIndex(); if (cellIndex < virtualFlow.getFirstVisibleIndex() || cellIndex > virtualFlow.getLastVisibleIndex()) { return; } // XXX: this is not currently working (remove the above to have this be reachable) if (cellIndex <= virtualFlow.getFirstVisibleIndex()) { virtualFlow.showAsFirst(cellIndex); } else if (cellIndex >= virtualFlow.getLastVisibleIndex()) { virtualFlow.showAsLast(cellIndex); } if (!handleMultilineSelect(virtualFlow, hitResult)) { handleSingleLineSelect(hitResult); } } } public void release(MouseEvent e) { if (e.getEventType() != MouseEvent.MOUSE_RELEASED) { throw new IllegalArgumentException("Event must be a MOUSE_RELEASED event"); } var virtualFlow = getVirtualFlow(e); virtualFlow.setCursor(Cursor.DEFAULT); if (textSelectRange == null || !textSelectRange.isSelected()) { clearSelection(); textSelectRange = null; } if (focusNode != null) { focusNode.requestFocus(); } } public void copy() { var text = getSelectionAsText(); if (StringUtils.isNotBlank(text)) { ClipboardUtils.copyTextToClipboard(text); } } public boolean isSelected() { return textFlows.size() > 1 || (textSelectRange != null && textSelectRange.isSelected()); } private boolean handleMultilineSelect(VirtualFlow virtualFlow, VirtualFlowHit hitResult) { var cellIndex = hitResult.getCellIndex(); var textFlow = hitResult.getCell().getNode(); if (cellIndex != startCellIndex) { if (direction == Direction.SAME) { // We're switching to multiline mode. if (!textFlows.isEmpty()) { var pathElements = textFlows.getFirst().getRangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlows.getFirst()), false); TextFlowUtils.showSelection(textFlows.getFirst(), pathElements); direction = cellIndex > startCellIndex ? Direction.DOWN : Direction.UP; markSelection(virtualFlow, startCellIndex, cellIndex); } } else { markSelection(virtualFlow, lastCellIndex, cellIndex); } lastCellIndex = cellIndex; return true; } else { if (direction != Direction.SAME) { // We're coming back to single line mode. clearSelection(); direction = Direction.SAME; textFlows.add(textFlow); } return false; } } private void markSelection(VirtualFlow virtualFlow, int fromCell, int toCell) { switch (direction) { case UP -> { if (fromCell > toCell) // Going up (mark more) { for (int i = fromCell; i >= toCell; i--) { addVisibleSelection(virtualFlow.getCell(i).getNode()); } } else if (fromCell < toCell) // Going down (unwind) { for (int i = fromCell; i < toCell; i++) { removeVisibleSelection(virtualFlow.getCell(i).getNode()); } } } case DOWN -> { if (fromCell < toCell) // Going down (mark more) { for (int i = fromCell; i <= toCell; i++) { addVisibleSelection(virtualFlow.getCell(i).getNode()); } } else if (fromCell > toCell) // Going up (unwind) { for (int i = fromCell; i > toCell; i--) { removeVisibleSelection(virtualFlow.getCell(i).getNode()); } } } case null, default -> throw new IllegalArgumentException("Wrong direction: " + direction); } } private int getOffsetFromSelectionMode() { return switch (selectionMode) { case TIME_ACTION_AND_TEXT -> 0; case ACTION_AND_TEXT -> 1; case TEXT -> 2; }; } private void handleSingleLineSelect(VirtualFlowHit hitResult) { var textFlow = hitResult.getCell().getNode(); textSelectRange = new TextSelectRange(firstHitInfo, textFlow.getHitInfo(hitResult.getCellOffset())); if (textSelectRange.isSelected()) { var pathElements = textFlow.getRangeShape(textSelectRange.getStart(), textSelectRange.getEnd() + 1, false); TextFlowUtils.showSelection(textFlow, pathElements); } else { TextFlowUtils.hideSelection(textFlow); } } private void addVisibleSelection(TextFlow textFlow) { TextFlowUtils.showSelection(textFlow, textFlow.getRangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlow), false)); if (textFlows.getLast() != textFlow) { textFlows.add(textFlow); } } private void removeVisibleSelection(TextFlow textFlow) { TextFlowUtils.hideSelection(textFlow); textFlows.remove(textFlow); } private void clearSelection() { while (!textFlows.isEmpty()) { var textFlow = textFlows.getLast(); removeVisibleSelection(textFlow); } textSelectRange = null; } private String getSelectionAsText() { if (textFlows.isEmpty()) { return ""; } if (textFlows.size() == 1) { // Single line selection var textFlow = textFlows.getFirst(); assert textFlow.getChildren().size() >= 3; return TextFlowUtils.getTextFlowAsText(textFlow, textSelectRange.getStart(), textSelectRange.getEnd() + 1, Options.SPACED_PREFIXES); } else { if (direction == Direction.UP) { return textFlows.reversed().stream() .map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode(), Options.SPACED_PREFIXES)) .collect(Collectors.joining("\n")); } else { return textFlows.stream() .map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode(), Options.SPACED_PREFIXES)) .collect(Collectors.joining("\n")); } } } private VirtualFlow getVirtualFlow(MouseEvent e) { //noinspection unchecked return (VirtualFlow) e.getSource(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.id.GxsId; import io.xeres.common.message.chat.ChatMessage; import io.xeres.common.message.chat.ChatRoomMessage; import io.xeres.common.message.chat.ChatRoomTimeoutEvent; import io.xeres.common.message.chat.ChatRoomUserEvent; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.client.preview.PreviewClient; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.support.chat.ChatAction; import io.xeres.ui.support.chat.ChatLine; import io.xeres.ui.support.chat.ChatParser; import io.xeres.ui.support.chat.NicknameCompleter; import io.xeres.ui.support.contentline.ContentImage; import io.xeres.ui.support.contentline.ContentUri; import io.xeres.ui.support.contentline.ContentUriPreview; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.markdown.MarkdownService.Rendering; import io.xeres.ui.support.markdown.UriAction; import io.xeres.ui.support.uri.ExternalUri; import io.xeres.ui.support.uri.IdentityUri; import io.xeres.ui.support.util.ImageViewUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Node; import javafx.scene.control.ListView; import javafx.scene.control.MenuItem; import javafx.scene.image.Image; import javafx.scene.input.MouseEvent; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.fxmisc.flowless.VirtualFlow; import org.fxmisc.flowless.VirtualizedScrollPane; import org.jsoup.Jsoup; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignA; import org.kordamp.ikonli.materialdesign2.MaterialDesignM; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; import static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID; import static io.xeres.ui.support.chat.ChatAction.Type.*; import static org.apache.commons.lang3.StringUtils.isEmpty; import static org.apache.commons.lang3.StringUtils.isNotEmpty; public class ChatListView implements NicknameCompleter.UsernameFinder { private static final int SCROLL_BACK_MAX_LINES = 1000; private static final int SCROLL_BACK_CLEANUP_THRESHOLD = 100; private static final Duration PREVIEW_WINDOW = Duration.ofSeconds(30); private static final String INFO_MENU_ID = "info"; private static final String CHAT_MENU_ID = "chat"; private final ObservableList messages = FXCollections.observableArrayList(); private final Map userMap = new HashMap<>(); private final ObservableList users = FXCollections.observableArrayList(); private String nickname; private final long id; private final AnchorPane anchorPane; private final VirtualizedScrollPane> chatView; private final ListView userListView; private final MarkdownService markdownService; private final UriAction uriAction; private final GeneralClient generalClient; private final ImageCache imageCache; private final ResourceBundle bundle; private final WindowManager windowManager; private PreviewClient previewClient; private final ChatListDragSelection dragSelection; private final ChatListViewContextMenu contextMenu; public enum AddUserOrigin { JOIN, KEEP_ALIVE } public ChatListView(String nickname, long id, MarkdownService markdownService, UriAction uriAction, GeneralClient generalClient, ImageCache imageCache, WindowManager windowManager, Node focusNode) { this.nickname = nickname; this.id = id; this.markdownService = markdownService; this.uriAction = uriAction; this.generalClient = generalClient; this.imageCache = imageCache; this.windowManager = windowManager; bundle = I18nUtils.getBundle(); anchorPane = new AnchorPane(); dragSelection = new ChatListDragSelection(focusNode); chatView = createChatView(dragSelection); addToAnchorPane(chatView, anchorPane); userListView = createUserListView(); contextMenu = new ChatListViewContextMenu(); // Make sure we stick to the bottom even when we resize the chatview (user typing multiple lines, other user offline, ...) anchorPane.heightProperty().addListener((_, _, _) -> jumpToBottom(false)); } /** * Enables previews. Only use it for trustable channels (not public chats, etc...). * * @param previewClient the preview client */ public void setPreviewClient(PreviewClient previewClient) { this.previewClient = previewClient; } public void installClearHistoryContextMenu(Runnable action) { contextMenu.installClearHistoryMenu(_ -> UiUtils.showAlertConfirm(bundle.getString("chat.room.clear-history"), () -> { action.run(); messages.clear(); })); } private VirtualizedScrollPane> createChatView(ChatListDragSelection selection) { final var view = VirtualFlow.createVertical(messages, ChatListCell::new, VirtualFlow.Gravity.REAR); view.setFocusTraversable(false); view.getStyleClass().add("chat-list"); view.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { contextMenu.hide(); if (!e.isSecondaryButtonDown()) { selection.press(e); contextMenu.removeSelectionMenu(); } }); view.addEventFilter(MouseEvent.MOUSE_DRAGGED, selection::drag); view.addEventFilter(MouseEvent.MOUSE_RELEASED, selection::release); view.setOnContextMenuRequested(event -> { if (selection.isSelected()) { contextMenu.installSelectionMenu(_ -> selection.copy()); } contextMenu.show(view, event.getScreenX(), event.getScreenY()); event.consume(); }); return new VirtualizedScrollPane<>(view); } private ListView createUserListView() { final ListView view; view = new ListView<>(); view.getStyleClass().add("chat-user-list"); VBox.setVgrow(view, Priority.ALWAYS); view.setCellFactory(_ -> new ChatUserCell(generalClient, imageCache)); view.setItems(users); createUsersListViewContextMenu(view); return view; } public boolean copy() { if (dragSelection.isSelected()) { dragSelection.copy(); return true; } return false; } public void addOwnMessage(ChatMessage chatMessage) { addOwnMessage(Instant.now(), chatMessage.getContent()); } public void addOwnMessage(ChatRoomMessage chatRoomMessage) { addOwnMessage(Instant.now(), chatRoomMessage.getContent()); } public void addOwnMessage(Instant when, String message) { var chatAction = new ChatAction(SAY_OWN, nickname, null); addMessage(when, chatAction, message); jumpToBottom(true); // Always move to the bottom for our own message } public void addUserMessage(String from, String message) { addUserMessage(Instant.now(), from, message); } public void addUserMessage(Instant when, String from, String message) { var chatAction = new ChatAction(SAY, from, null); addMessage(when, chatAction, message); } public void addUserMessage(String from, GxsId gxsId, String message) { addUserMessage(Instant.now(), from, gxsId, message); } public void addUserMessage(Instant when, String from, GxsId gxsId, String message) { var chatAction = new ChatAction(SAY, from, gxsId); addMessage(when, chatAction, message); } private void addMessage(Instant time, ChatAction chatAction, String message) { message = removeEmtpyImageTag(message); var img = Jsoup.parse(message).selectFirst("img"); if (img != null) { var data = img.absUrl("src"); if (isNotEmpty(data) && data.startsWith("data:")) // the core only allows 'data' already but better safe than sorry { var image = new Image(data); if (!image.isError() && !ImageViewUtils.isExaggeratedAspectRatio(image)) { addMessageLine(time, chatAction, image); } } } else { if (ChatParser.isActionMe(message)) { message = ChatParser.parseActionMe(message, chatAction.getNickname()); chatAction.setType(ACTION); } var contents = markdownService.parse(message, EnumSet.of(Rendering.CHAT), uriAction); var chatLine = new ChatLine(time, chatAction, contents); addMessageLine(chatLine); if (chatAction.getType() == SAY) { // Important: do *NOT* perform preview for things we send ourselves. Otherwise, both us and // the recipient will perform a near simultaneous access, and friends could be easily correlated // by the target website. scanForPreview(time, chatLine); } } } private void scanForPreview(Instant messageArrival, ChatLine chatLine) { if (previewClient == null || Duration.between(messageArrival, Instant.now()).compareTo(PREVIEW_WINDOW) > 0) // Don't preview "old" URLs { return; } chatLine.getChatContents().stream() .filter(ContentUri.class::isInstance) // Only handle the first URI .findFirst() .map(content -> ((ContentUri) content).getUri()) .ifPresent(url -> previewClient.getPreview(url) .doOnSuccess(preview -> Platform.runLater(() -> { assert preview != null; if (!preview.hasInfo()) { return; } var index = messages.indexOf(chatLine); if (index >= 0) { var contents = new ArrayList<>(chatLine.getChatContents()); for (var i = 0; i < contents.size(); i++) { if (contents.get(i) instanceof ContentUri contentUri) // And replace back the first URI { contents.set(i, new ContentUriPreview( new ExternalUri(url), preview.title(), preview.description(), preview.site(), preview.thumbnailUrl(), preview.thumbnailWidth(), preview.thumbnailHeight(), thumbUrl -> previewClient.getImage(thumbUrl).block(), contentUri.getAction(), () -> jumpToBottom(false))); break; } } var newChatLine = chatLine.withContent(contents); messages.set(index, newChatLine); jumpToBottom(false); } })) .subscribe()); } /** * Removes the empty img tag that is added by Retroshare when sending a file URL. * * @param message the message * @return the cleaned up message */ private static String removeEmtpyImageTag(String message) { if (message.startsWith("") && message.length() > 5) { message = message.substring(5); } return message; } public void addUser(ChatRoomUserEvent event, AddUserOrigin addUserOrigin) { if (!userMap.containsKey(event.getGxsId())) { var chatRoomUser = new ChatRoomUser(event.getGxsId(), event.getNickname(), event.getIdentityId()); users.add(chatRoomUser); userMap.put(event.getGxsId(), chatRoomUser); users.sort((o1, o2) -> o1.nickname().compareToIgnoreCase(o2.nickname())); if (addUserOrigin == AddUserOrigin.JOIN && !nickname.equals(event.getNickname())) { addMessageLine(new ChatAction(JOIN, event.getNickname(), event.getGxsId())); } } } public void removeUser(ChatRoomUserEvent event) { var chatRoomUser = userMap.remove(event.getGxsId()); if (chatRoomUser != null) { users.remove(chatRoomUser); addMessageLine(new ChatAction(LEAVE, event.getNickname(), event.getGxsId())); } } public void timeoutUser(ChatRoomTimeoutEvent event) { var chatRoomUser = userMap.remove(event.getGxsId()); if (chatRoomUser != null) { users.remove(chatRoomUser); if (!event.isSplit() && userSaidSomethingRecently(event.getGxsId())) { // Only display this if the user said something 5-10 minutes ago, so that we know that the conversation is "dead". Displaying it all the time is too verbose addMessageLine(new ChatAction(TIMEOUT, chatRoomUser.nickname(), event.getGxsId())); } } } private boolean userSaidSomethingRecently(GxsId gxsId) { var now = Instant.now(); for (var i = messages.size() - 1; i >= 0; i--) { var message = messages.get(i); if (message.getInstant().isBefore(now.minus(10, ChronoUnit.MINUTES))) { break; } if (message.hasSaid(gxsId)) { return true; } } return false; } @Override public String getUsername(String prefix, int index) { var prefixLower = prefix.toLowerCase(Locale.ROOT); var matchingUsers = users.stream() .filter(chatRoomUser -> !chatRoomUser.nickname().equals(nickname) && (isEmpty(prefix) || chatRoomUser.nickname().toLowerCase(Locale.ROOT).startsWith(prefixLower))) .toList(); if (matchingUsers.isEmpty()) { return null; } return matchingUsers.get(index % matchingUsers.size()).nickname(); } public void setNickname(String nickname) { this.nickname = nickname; } public Node getChatView() { return anchorPane; } private static void addToAnchorPane(Node chatView, AnchorPane anchorPane) { // We use an anchor to force the VirtualFlow to be bigger // than its default size of 100 x 100. It doesn't behave // well in a VBox only. anchorPane.getChildren().add(chatView); anchorPane.getStyleClass().add("chat-list-pane"); AnchorPane.setTopAnchor(chatView, 0.0); AnchorPane.setLeftAnchor(chatView, 0.0); AnchorPane.setRightAnchor(chatView, 0.0); AnchorPane.setBottomAnchor(chatView, 0.0); VBox.setVgrow(anchorPane, Priority.ALWAYS); } ListView getUserListView() { return userListView; } public long getId() { return id; } private void addMessageLine(ChatLine line) { messages.add(line); jumpToBottom(false); trimScrollBackIfNeeded(); } private void addMessageLine(Instant when, ChatAction action, Image image) { var chatLine = new ChatLine(when, action, List.of(new ContentImage(image, chatView))); addMessageLine(chatLine); } private void addMessageLine(ChatAction action) { var chatLine = new ChatLine(Instant.now(), action, List.of()); addMessageLine(chatLine); } /** * Jumps to the bottom of the chat listview. * * @param force always jumps, otherwise it will only jump if it was already at the bottom at the last layout */ public void jumpToBottom(boolean force) { if (force || messages.size() - chatView.getContent().getLastVisibleIndex() <= 2) { chatView.getContent().showAsFirst(messages.size()); } } private void trimScrollBackIfNeeded() { if (messages.size() >= SCROLL_BACK_MAX_LINES) { messages.remove(0, SCROLL_BACK_CLEANUP_THRESHOLD); } } private void createUsersListViewContextMenu(Node view) { var infoItem = new MenuItem(bundle.getString("chat.room.user-menu")); infoItem.setId(INFO_MENU_ID); infoItem.setGraphic(new FontIcon(MaterialDesignA.ACCOUNT_BOX)); infoItem.setOnAction(event -> { var user = (ChatRoomUser) event.getSource(); uriAction.openUri(new IdentityUri(user.nickname(), user.gxsId(), null)); }); var chatItem = new MenuItem(bundle.getString("contact-view.action.chat")); chatItem.setId(CHAT_MENU_ID); chatItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT)); chatItem.setOnAction(event -> { var user = (ChatRoomUser) event.getSource(); windowManager.openMessaging(user.gxsId()); }); var xContextMenu = new XContextMenu(chatItem, infoItem); xContextMenu.setOnShowing((cm, chatRoomUser) -> { if (chatRoomUser == null) { return false; } cm.getItems().stream() .filter(menuItem -> CHAT_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(chatRoomUser.identityId() == OWN_IDENTITY_ID)); return chatRoomUser.gxsId() != null; }); xContextMenu.addToNode(view); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatListViewContextMenu.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.i18n.I18nUtils; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.scene.Node; import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; import java.util.Optional; import java.util.ResourceBundle; class ChatListViewContextMenu { private static final String CLEAR_HISTORY_MENU_ID = "clearHistory"; private static final String COPY_SELECTION_MENU_ID = "copySelection"; private final ResourceBundle bundle = I18nUtils.getBundle(); private final ContextMenu contextMenu; public ChatListViewContextMenu() { contextMenu = new ContextMenu(); } public void show(Node anchor, double screenX, double screenY) { contextMenu.show(anchor, screenX, screenY); } public void hide() { contextMenu.hide(); } public void installSelectionMenu(EventHandler eventHandler) { if (findMenuEntry(COPY_SELECTION_MENU_ID).isPresent()) { return; } var copySelectionItem = new MenuItem(bundle.getString("chat.room.copy-selection")); copySelectionItem.setId(COPY_SELECTION_MENU_ID); copySelectionItem.setOnAction(eventHandler); contextMenu.getItems().addFirst(copySelectionItem); } public void removeSelectionMenu() { removeMenuEntry(COPY_SELECTION_MENU_ID); } public void installClearHistoryMenu(EventHandler eventHandler) { if (findMenuEntry(CLEAR_HISTORY_MENU_ID).isPresent()) { return; } var clearItem = new MenuItem(bundle.getString("chat.room.clear-chat-history")); clearItem.setId(CLEAR_HISTORY_MENU_ID); clearItem.setOnAction(eventHandler); contextMenu.getItems().addAll(clearItem); } public void removeClearHistoryMenu() { removeMenuEntry(CLEAR_HISTORY_MENU_ID); } private void removeMenuEntry(String id) { findMenuEntry(id).ifPresent(menuItem -> contextMenu.getItems().remove(menuItem)); } private Optional findMenuEntry(String id) { return contextMenu.getItems().stream() .filter(menuItem -> menuItem.getId().equals(id)) .findFirst(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomCell.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.id.Id; import io.xeres.common.message.chat.RoomType; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.TreeCell; import org.apache.commons.lang3.StringUtils; import java.text.MessageFormat; import java.util.ResourceBundle; public class ChatRoomCell extends TreeCell { private static final ResourceBundle bundle = I18nUtils.getBundle(); public ChatRoomCell() { super(); TooltipUtils.install(this, () -> { var roomInfo = getItem().getRoomInfo(); if (roomInfo.getId() == 0) { return null; } return MessageFormat.format(bundle.getString("chat.room.info"), (StringUtils.isNotBlank(roomInfo.getTopic()) ? roomInfo.getTopic() : bundle.getString("chat.room.none")), roomInfo.getCount(), String.join(", ", roomInfo.getRoomType() == RoomType.PRIVATE ? bundle.getString("chat.room.private") : bundle.getString("chat.room.public"), roomInfo.isSigned() ? bundle.getString("chat.room.signed-only") : bundle.getString("chat.room.anonymous-allowed")), Id.toString(getItem().getRoomInfo().getId())); } , null); } @Override protected void updateItem(RoomHolder item, boolean empty) { super.updateItem(item, empty); if (empty) { setText(null); setStyle(""); } else { setText(item.getRoomInfo().getName()); if (item.getRoomInfo().hasNewMessages()) { if (item.getRoomInfo().getRoomType() == RoomType.PRIVATE) { setStyle("-fx-text-fill: teal; -fx-font-weight: bold;"); } else { setStyle("-fx-font-weight: bold;"); } } else { if (item.getRoomInfo().getRoomType() == RoomType.PRIVATE) { setStyle("-fx-text-fill: teal;"); } else { setStyle(""); } } } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomCreationWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.rest.chat.ChatRoomVisibility; import io.xeres.ui.client.ChatClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.ChoiceBox; import javafx.scene.control.TextField; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.ResourceBundle; @Component @FxmlView(value = "/view/chat/chatroom_create.fxml") public class ChatRoomCreationWindowController implements WindowController { @FXML private Button createButton; @FXML private Button cancelButton; @FXML private TextField roomName; @FXML private TextField topic; @FXML private ChoiceBox visibility; @FXML private CheckBox security; private final ChatClient chatClient; private final ResourceBundle bundle; public ChatRoomCreationWindowController(ChatClient chatClient, ResourceBundle bundle) { this.chatClient = chatClient; this.bundle = bundle; } @Override public void initialize() { roomName.textProperty().addListener(_ -> checkCreatable()); topic.textProperty().addListener(_ -> checkCreatable()); visibility.setItems(FXCollections.observableArrayList(bundle.getString("enum.room-type.public"), bundle.getString("enum.room-type.private"))); visibility.getSelectionModel().select(0); createButton.setOnAction(_ -> chatClient.createChatRoom(roomName.getText(), topic.getText(), ChatRoomVisibility.fromSelection(visibility.getSelectionModel().getSelectedIndex()), security.isSelected()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(roomName))) .doOnError(UiUtils::webAlertError) .subscribe()); cancelButton.setOnAction(UiUtils::closeWindow); } private void checkCreatable() { createButton.setDisable(roomName.getText().isBlank() || topic.getText().isBlank()); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomInfoController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.id.Id; import io.xeres.common.message.chat.ChatRoomInfo; import io.xeres.common.message.chat.RoomType; import io.xeres.ui.controller.Controller; import javafx.fxml.FXML; import javafx.scene.control.Label; import javafx.scene.layout.GridPane; import org.apache.commons.lang3.StringUtils; import java.util.ResourceBundle; public class ChatRoomInfoController implements Controller { @FXML private GridPane roomGroup; @FXML private Label roomName; @FXML private Label roomId; @FXML private Label roomTopic; @FXML private Label roomSecurity; @FXML private Label roomCount; private final ResourceBundle bundle = I18nUtils.getBundle(); @Override public void initialize() { // Clear the display first setRoomInfo(null); } public void setRoomInfo(ChatRoomInfo chatRoomInfo) { if (chatRoomInfo != null && chatRoomInfo.isReal()) { roomGroup.setVisible(true); roomName.setText(chatRoomInfo.getName()); roomId.setText(Id.toString(chatRoomInfo.getId())); roomTopic.setText(StringUtils.isNotBlank(chatRoomInfo.getTopic()) ? chatRoomInfo.getTopic() : bundle.getString("chat.room.none")); roomSecurity.setText(String.join(", ", chatRoomInfo.getRoomType() == RoomType.PRIVATE ? bundle.getString("chat.room.private") : bundle.getString("chat.room.public"), chatRoomInfo.isSigned() ? bundle.getString("chat.room.signed-only") : bundle.getString("chat.room.anonymous-allowed"))); roomCount.setText(String.valueOf(chatRoomInfo.getCount())); } else { roomGroup.setVisible(false); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomInvitationWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.ui.client.ChatClient; import io.xeres.ui.client.ConnectionClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.model.location.Location; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.CheckBoxTreeItem; import javafx.scene.control.SelectionMode; import javafx.scene.control.TreeView; import javafx.scene.control.cell.CheckBoxTreeCell; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; @Component @FxmlView(value = "/view/chat/chatroom_invite.fxml") public class ChatRoomInvitationWindowController implements WindowController { @FXML private TreeView peersTree; @FXML private Button inviteButton; @FXML private Button cancelButton; private final ConnectionClient connectionClient; private final ChatClient chatClient; private final Set> invitedItems = new HashSet<>(); private long chatRoomId; public ChatRoomInvitationWindowController(ConnectionClient connectionClient, ChatClient chatClient) { this.connectionClient = connectionClient; this.chatClient = chatClient; } @Override public void initialize() { var root = new CheckBoxTreeItem<>(new PeerHolder()); root.setExpanded(true); root.addEventHandler( CheckBoxTreeItem.checkBoxSelectionChangedEvent(), (CheckBoxTreeItem.TreeModificationEvent e) -> { var item = e.getTreeItem(); if (item.isLeaf()) { checkInvite(item); } }); peersTree.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); // XXX: needed? peersTree.setRoot(root); peersTree.setShowRoot(false); peersTree.setCellFactory(CheckBoxTreeCell.forTreeView()); connectionClient.getConnectedProfiles().collectList() .doOnSuccess(profiles -> Platform.runLater(() -> { assert profiles != null; profiles.forEach(profile -> { if (profile.getLocations().size() == 1) { root.getChildren().add(new CheckBoxTreeItem<>(new PeerHolder(profile, profile.getLocations().getFirst()))); } else { var parent = new CheckBoxTreeItem<>(new PeerHolder(profile)); parent.setExpanded(true); root.getChildren().add(parent); profile.getLocations().stream() .filter(Location::isConnected) .forEach(location -> parent.getChildren().add(new CheckBoxTreeItem<>(new PeerHolder(profile, location)))); } }); })) .doAfterTerminate(() -> root.setSelected(false)) .doOnError(UiUtils::webAlertError) .subscribe(); inviteButton.setOnAction(this::invitePeers); cancelButton.setOnAction(UiUtils::closeWindow); Platform.runLater(this::handleArgument); } private void handleArgument() { var userData = UiUtils.getUserData(inviteButton); if (userData != null) { chatRoomId = (long) userData; } } private void checkInvite(CheckBoxTreeItem item) { if (item.isSelected()) { invitedItems.add(item); } else { invitedItems.remove(item); } inviteButton.setDisable(invitedItems.isEmpty()); } private void invitePeers(ActionEvent event) { var selectedLocations = invitedItems.stream() .map(peerHolderTreeItem -> peerHolderTreeItem.getValue().getLocation()) .collect(Collectors.toSet()); invitedItems.clear(); peersTree.setRoot(null); chatClient.inviteLocationsToChatRoom(chatRoomId, selectedLocations) .subscribe(); UiUtils.closeWindow(event); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomUser.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.id.GxsId; record ChatRoomUser(GxsId gxsId, String nickname, long identityId) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatUserCell.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.custom.asyncimage.AsyncImageView; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.ListCell; import javafx.scene.image.ImageView; import java.text.MessageFormat; import java.util.ResourceBundle; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; class ChatUserCell extends ListCell { private static final int AVATAR_WIDTH = 32; private static final int AVATAR_HEIGHT = 32; private final GeneralClient generalClient; private final ImageCache imageCache; private static final ResourceBundle bundle = I18nUtils.getBundle(); public ChatUserCell(GeneralClient generalClient, ImageCache imageCache) { super(); this.generalClient = generalClient; this.imageCache = imageCache; TooltipUtils.install(this, () -> MessageFormat.format(bundle.getString("chat.room.user-info"), super.getItem().nickname(), super.getItem().gxsId()), () -> new ImageView(((ImageView) super.getGraphic()).getImage())); } @Override protected void updateItem(ChatRoomUser item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : item.nickname()); setGraphic(empty ? null : updateAvatar((AsyncImageView) getGraphic(), item)); } private AsyncImageView updateAvatar(AsyncImageView asyncImageView, ChatRoomUser item) { if (asyncImageView == null) { asyncImageView = new AsyncImageView( url -> generalClient.getImage(url).block(), imageCache); asyncImageView.setFitWidth(AVATAR_WIDTH); asyncImageView.setFitHeight(AVATAR_HEIGHT); } asyncImageView.setUrl(getImageUrl(item)); return asyncImageView; } private String getImageUrl(ChatRoomUser item) { if (item.identityId() != 0L) { return RemoteUtils.getControlUrl() + IDENTITIES_PATH + "/" + item.identityId() + "/image"; } else { return RemoteUtils.getControlUrl() + IDENTITIES_PATH + "/image?gxsId=" + item.gxsId(); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.id.Sha1Sum; import io.xeres.common.message.chat.*; import io.xeres.common.rest.contact.Contact; import io.xeres.common.rest.notification.contact.AddOrUpdateContacts; import io.xeres.common.rest.notification.contact.RemoveContacts; import io.xeres.common.util.RemoteUtils; import io.xeres.common.util.image.ImageUtils; import io.xeres.ui.client.*; import io.xeres.ui.client.message.MessageClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.chat.ChatListView.AddUserOrigin; import io.xeres.ui.custom.InputAreaGroup; import io.xeres.ui.custom.TypingNotificationView; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.custom.event.FileSelectedEvent; import io.xeres.ui.custom.event.ImageSelectedEvent; import io.xeres.ui.custom.event.StickerSelectedEvent; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.event.UnreadEvent; import io.xeres.ui.support.chat.ChatCommand; import io.xeres.ui.support.chat.NicknameCompleter; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.preference.PreferenceUtils; import io.xeres.ui.support.sound.SoundPlayerService; import io.xeres.ui.support.sound.SoundPlayerService.SoundType; import io.xeres.ui.support.tray.TrayService; import io.xeres.ui.support.unread.UnreadService; import io.xeres.ui.support.uri.ChatRoomUri; import io.xeres.ui.support.uri.FileUriFactory; import io.xeres.ui.support.uri.UriService; import io.xeres.ui.support.util.ImageViewUtils; import io.xeres.ui.support.util.TextInputControlUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.application.Platform; import javafx.collections.ObservableList; import javafx.embed.swing.SwingFXUtils; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.Disposable; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.text.MessageFormat; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; import static io.xeres.common.message.chat.ChatConstants.TYPING_NOTIFICATION_DELAY; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; import static io.xeres.ui.support.preference.PreferenceUtils.CHAT_ROOMS; import static javafx.scene.control.Alert.AlertType.WARNING; import static org.apache.commons.lang3.ObjectUtils.isEmpty; import static org.apache.commons.lang3.StringUtils.isNotBlank; @Component @FxmlView(value = "/view/chat/chat_view.fxml") public class ChatViewController implements Controller { private static final Logger log = LoggerFactory.getLogger(ChatViewController.class); private static final int PREVIEW_IMAGE_WIDTH_MAX = 320; private static final int PREVIEW_IMAGE_HEIGHT_MAX = 240; private static final int STICKER_WIDTH_MAX = 192; private static final int STICKER_HEIGHT_MAX = 192; private static final int MESSAGE_MAXIMUM_SIZE = 31000; // XXX: put that on chat service too as we shouldn't forward them. also this is only for chat rooms, not private chats private static final KeyCodeCombination TAB_KEY = new KeyCodeCombination(KeyCode.TAB); private static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination CTRL_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN); private static final KeyCodeCombination SHIFT_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN); private static final KeyCodeCombination ENTER_KEY = new KeyCodeCombination(KeyCode.ENTER); private static final KeyCodeCombination BACKSPACE_KEY = new KeyCodeCombination(KeyCode.BACK_SPACE); private static final String SUBSCRIBED_MENU_ID = "subscribed"; private static final String UNSUBSCRIBED_MENU_ID = "unsubscribed"; private static final String COPY_LINK_MENU_ID = "copyLink"; private static final String OPEN_SUBSCRIBED = "OpenSubscribed"; private static final String OPEN_PRIVATE = "OpenPrivate"; private static final String OPEN_PUBLIC = "OpenPublic"; @FXML private TreeView roomTree; @FXML private SplitPane splitPane; @FXML private VBox content; @FXML private InputAreaGroup send; @FXML private VBox sendGroup; @FXML private TypingNotificationView typingNotification; @FXML private HBox previewGroup; @FXML private ImageView imagePreview; @FXML private Button previewSend; @FXML private Button previewCancel; @FXML private VBox userListContent; @FXML private Button invite; @FXML private HBox status; @FXML private Label roomName; @FXML private Label roomTopic; @FXML public Button createChatRoom; private final MessageClient messageClient; private final ChatClient chatClient; private final ProfileClient profileClient; private final LocationClient locationClient; private final WindowManager windowManager; private final TrayService trayService; private final ResourceBundle bundle; private final MarkdownService markdownService; private final UriService uriService; private final GeneralClient generalClient; private final ImageCache imageCache; private final SoundPlayerService soundPlayerService; private final ShareClient shareClient; private final UnreadService unreadService; private final NotificationClient notificationClient; private final TreeItem subscribedRooms; private final TreeItem privateRooms; private final TreeItem publicRooms; private String nickname; private final NicknameCompleter nicknameCompleter = new NicknameCompleter(); private ChatRoomInfo selectedRoom; private ChatListView selectedChatListView; private Node roomInfoView; private ChatRoomInfoController chatRoomInfoController; private Instant lastTypingNotification = Instant.EPOCH; private double[] dividerPositions; private Timeline lastTypingTimeline; private Disposable contactNotificationDisposable; public ChatViewController(MessageClient messageClient, ChatClient chatClient, ProfileClient profileClient, LocationClient locationClient, WindowManager windowManager, TrayService trayService, ResourceBundle bundle, MarkdownService markdownService, UriService uriService, GeneralClient generalClient, ImageCache imageCache, SoundPlayerService soundPlayerService, ShareClient shareClient, UnreadService unreadService, NotificationClient notificationClient) { this.messageClient = messageClient; this.chatClient = chatClient; this.profileClient = profileClient; this.locationClient = locationClient; this.windowManager = windowManager; this.trayService = trayService; this.bundle = bundle; this.markdownService = markdownService; this.uriService = uriService; this.generalClient = generalClient; this.imageCache = imageCache; this.soundPlayerService = soundPlayerService; this.shareClient = shareClient; this.unreadService = unreadService; this.notificationClient = notificationClient; subscribedRooms = new TreeItem<>(new RoomHolder(bundle.getString("subscribed"))); privateRooms = new TreeItem<>(new RoomHolder(bundle.getString("enum.room-type.private"))); publicRooms = new TreeItem<>(new RoomHolder(bundle.getString("enum.room-type.public"))); } @Override public void initialize() { profileClient.getOwn().doOnSuccess(profile -> Platform.runLater(() -> { assert profile != null; initializeReally(profile.getName()); })) .subscribe(); setupIdentityNotifications(); } private void initializeReally(String nickname) { this.nickname = nickname; var root = new TreeItem<>(new RoomHolder()); //noinspection unchecked root.getChildren().addAll(subscribedRooms, privateRooms, publicRooms); root.setExpanded(true); roomTree.setRoot(root); roomTree.setShowRoot(false); roomTree.setCellFactory(_ -> new ChatRoomCell()); createRoomTreeContextMenu(); // We need Platform.runLater() because when an entry is moved, the selection can change roomTree.getSelectionModel().selectedItemProperty() .addListener((_, _, newValue) -> Platform.runLater(() -> changeSelectedRoom(newValue))); UiUtils.setOnPrimaryMouseDoubleClicked(roomTree, _ -> { if (isRoomSelected()) { joinChatRoom(selectedRoom); } }); var loader = new FXMLLoader(ChatViewController.class.getResource("/view/chat/chat_roominfo.fxml"), bundle); try { roomInfoView = loader.load(); } catch (IOException e) { throw new RuntimeException(e); } chatRoomInfoController = loader.getController(); lastTypingTimeline = new Timeline(new KeyFrame(javafx.util.Duration.seconds(TYPING_NOTIFICATION_DELAY.getSeconds()))); lastTypingTimeline.setOnFinished(_ -> typingNotification.setText("")); VBox.setVgrow(roomInfoView, Priority.ALWAYS); switchChatContent(roomInfoView, null); sendGroup.setVisible(false); setPreviewGroupVisibility(false); previewSend.setOnAction(_ -> sendImage()); previewCancel.setOnAction(_ -> cancelImage()); // Handle the events even if the InputArea widget isn't selected content.addEventHandler(KeyEvent.KEY_PRESSED, this::handleInputKeys); send.addKeyFilter(this::handleInputKeys); send.addEnhancedContextMenu(this::handlePaste, locationClient); send.addEventHandler(StickerSelectedEvent.STICKER_SELECTED, event -> CompletableFuture.runAsync(() -> { try { var bufferedImage = ImageIO.read(event.getPath().toFile()); Platform.runLater(() -> sendStickerToMessage(bufferedImage)); } catch (IOException e) { log.error("Couldn't send the sticker: {}", e.getMessage()); } })); send.addEventHandler(ImageSelectedEvent.IMAGE_SELECTED, event -> { if (event.getFile().canRead()) { CompletableFuture.runAsync(() -> { try (var inputStream = new FileInputStream(event.getFile())) { var image = new Image(inputStream); Platform.runLater(() -> setPreviewImage(image)); } catch (IOException e) { UiUtils.showAlert(Alert.AlertType.ERROR, MessageFormat.format(bundle.getString("file-requester.error"), event.getFile(), e.getMessage())); } }); } }); send.addEventHandler(FileSelectedEvent.FILE_SELECTED, event -> { if (event.getFile().canRead()) { sendFile(event.getFile()); } }); invite.setOnAction(_ -> windowManager.openInvite(selectedRoom.getId())); getChatRoomContext(); createChatRoom.setOnAction(_ -> windowManager.openChatRoomCreation()); setupTrees(); } private void setupIdentityNotifications() { contactNotificationDisposable = notificationClient.getContactNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { List contacts = switch (sse.data()) { case AddOrUpdateContacts action -> action.contacts(); case RemoveContacts action -> action.contacts(); case null -> throw new IllegalArgumentException("sse data is null for contacts"); }; refreshUsers(contacts.stream() .map(Contact::identityId) .collect(Collectors.toSet())); })) .subscribe(); } private void refreshUsers(Set identityIds) { subscribedRooms.getChildren().forEach(room -> { var chatListView = room.getValue().getChatListView(); chatListView.getUserListView().getItems().forEach(chatRoomUser -> { var found = false; if (identityIds.contains(chatRoomUser.identityId())) { imageCache.evictImage(RemoteUtils.getControlUrl() + IDENTITIES_PATH + "/" + chatRoomUser.identityId() + "/image"); found = true; } if (found) { chatListView.getUserListView().refresh(); } }); }); } private void sendFile(File file) { shareClient.createTemporaryShare(file.getAbsolutePath()) .doOnSuccess(result -> { assert result != null; sendChatMessage(FileUriFactory.generate(file.getName(), getFileSize(file.toPath()), Sha1Sum.fromString(result.hash()))); }) .subscribe(); } // XXX: duplicate.. private static long getFileSize(Path path) { try { return Files.size(path); } catch (IOException _) { log.error("Failed to get the file size of {}", path); return 0; } } private void setupTrees() { var node = PreferenceUtils.getPreferences().node(CHAT_ROOMS); subscribedRooms.setExpanded(node.getBoolean(OPEN_SUBSCRIBED, false)); privateRooms.setExpanded(node.getBoolean(OPEN_PRIVATE, false)); publicRooms.setExpanded(node.getBoolean(OPEN_PUBLIC, false)); subscribedRooms.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_SUBSCRIBED, newValue)); privateRooms.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_PRIVATE, newValue)); publicRooms.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_PUBLIC, newValue)); } @EventListener public void handleOpenUriEvents(OpenUriEvent event) { if (event.uri() instanceof ChatRoomUri chatRoomUri) { var chatRoomId = chatRoomUri.id(); getAllTreeItem(chatRoomId).ifPresentOrElse(treeItem -> Platform.runLater(() -> roomTree.getSelectionModel().select(treeItem)), () -> UiUtils.showAlert(WARNING, bundle.getString("chat.room.not-found"))); } } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (contactNotificationDisposable != null && !contactNotificationDisposable.isDisposed()) { contactNotificationDisposable.dispose(); } } private void createRoomTreeContextMenu() { var subscribeItem = new MenuItem(bundle.getString("chat.room.join")); subscribeItem.setId(SUBSCRIBED_MENU_ID); subscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_ENTER)); subscribeItem.setOnAction(event -> joinChatRoom(((RoomHolder) event.getSource()).getRoomInfo())); var unsubscribeItem = new MenuItem(bundle.getString("chat.room.leave")); unsubscribeItem.setId(UNSUBSCRIBED_MENU_ID); unsubscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_EXIT)); unsubscribeItem.setOnAction(event -> leaveChatRoom(((RoomHolder) event.getSource()).getRoomInfo())); var copyLinkItem = new MenuItem(bundle.getString("copy-link")); copyLinkItem.setId(COPY_LINK_MENU_ID); copyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); copyLinkItem.setOnAction(event -> { var chatRoomInfo = ((RoomHolder) event.getSource()).getRoomInfo(); ClipboardUtils.copyTextToClipboard(new ChatRoomUri(chatRoomInfo.getName(), chatRoomInfo.getId()).toUriString()); }); var xContextMenu = new XContextMenu(subscribeItem, unsubscribeItem, new SeparatorMenuItem(), copyLinkItem); xContextMenu.addToNode(roomTree); xContextMenu.setOnShowing((contextMenu, roomHolder) -> { var chatRoomInfo = roomHolder.getRoomInfo(); contextMenu.getItems().stream() .filter(menuItem -> SUBSCRIBED_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(isAlreadyJoined(chatRoomInfo))); contextMenu.getItems().stream() .filter(menuItem -> UNSUBSCRIBED_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(!isAlreadyJoined(chatRoomInfo))); return chatRoomInfo.isReal(); }); } private boolean isAlreadyJoined(ChatRoomInfo chatRoomInfo) { return subscribedRooms.getChildren().stream() .anyMatch(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().equals(chatRoomInfo)); } private void joinChatRoom(ChatRoomInfo chatRoomInfo) { if (!isAlreadyJoined(chatRoomInfo)) { chatClient.joinChatRoom(chatRoomInfo.getId()) .subscribe(); } } private void leaveChatRoom(ChatRoomInfo chatRoomInfo) { subscribedRooms.getChildren().stream() .filter(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().equals(chatRoomInfo)) .findAny() .ifPresent(_ -> chatClient.leaveChatRoom(chatRoomInfo.getId()) .subscribe()); } private void getChatRoomContext() { chatClient.getChatRoomContext() .doOnSuccess(context -> { assert context != null; addRooms(context.chatRoomLists()); context.chatRoomLists().getSubscribedRooms().forEach(chatRoomInfo -> userJoined(chatRoomInfo.getId(), new ChatRoomUserEvent(context.ownUser().gxsId(), context.ownUser().nickname(), context.ownUser().identityId()))); }) .subscribe(); } public void addRooms(ChatRoomLists chatRoomLists) { var subscribedTree = subscribedRooms.getChildren(); var publicTree = publicRooms.getChildren(); var privateTree = privateRooms.getChildren(); chatRoomLists.getSubscribedRooms() .forEach(roomInfo -> addOrUpdate(subscribedTree, roomInfo)); // Make sure we don't add rooms that we're already subscribed to var unsubscribedRooms = chatRoomLists.getAvailableRooms().stream() .filter(roomInfo -> !isInside(subscribedTree, roomInfo)) .toList(); syncTreeWithChatRoomList(publicTree, unsubscribedRooms.stream() .filter(roomInfo -> roomInfo.getRoomType() == RoomType.PUBLIC) .toList()); syncTreeWithChatRoomList(privateTree, unsubscribedRooms.stream() .filter(roomInfo -> roomInfo.getRoomType() == RoomType.PRIVATE) .toList()); } private void syncTreeWithChatRoomList(ObservableList> tree, List list) { list.forEach(chatRoomInfo -> addOrUpdate(tree, chatRoomInfo)); var chatRoomIds = list.stream() .map(ChatRoomInfo::getId) .collect(Collectors.toSet()); tree.removeIf(roomHolderTreeItem -> !chatRoomIds.contains(roomHolderTreeItem.getValue().getRoomInfo().getId())); } public void roomJoined(long roomId) { // Must be idempotent moveRoom(roomId, publicRooms, subscribedRooms); moveRoom(roomId, privateRooms, subscribedRooms); } public void roomLeft(long roomId) { // Must be idempotent subscribedRooms.getChildren().stream() .filter(roomInfoTreeItem -> roomInfoTreeItem.getValue().getRoomInfo().getId() == roomId) .findFirst() .ifPresent(roomHolderTreeItem -> { subscribedRooms.getChildren().remove(roomHolderTreeItem); if (roomHolderTreeItem.getValue().getRoomInfo().getRoomType() == RoomType.PRIVATE) { privateRooms.getChildren().add(roomHolderTreeItem); sortByName(privateRooms.getChildren()); } else { publicRooms.getChildren().add(roomHolderTreeItem); sortByName(publicRooms.getChildren()); } roomHolderTreeItem.getValue().clearChatListView(); }); } private static void moveRoom(long roomId, TreeItem from, TreeItem to) { from.getChildren().stream() .filter(roomInfoTreeItem -> roomInfoTreeItem.getValue().getRoomInfo().getId() == roomId) .findFirst() .ifPresent(roomHolderTreeItem -> { from.getChildren().remove(roomHolderTreeItem); to.getChildren().add(roomHolderTreeItem); sortByName(to.getChildren()); }); } private static void sortByName(ObservableList> children) { children.sort((o1, o2) -> o1.getValue().getRoomInfo().getName().compareToIgnoreCase(o2.getValue().getRoomInfo().getName())); } public void userJoined(long roomId, ChatRoomUserEvent event) { performOnChatListView(roomId, chatListView -> chatListView.addUser(event, AddUserOrigin.JOIN)); } public void userLeft(long roomId, ChatRoomUserEvent event) { performOnChatListView(roomId, chatListView -> chatListView.removeUser(event)); } public void userKeepAlive(long roomId, ChatRoomUserEvent event) { performOnChatListView(roomId, chatListView -> chatListView.addUser(event, AddUserOrigin.KEEP_ALIVE)); } public void userTimeout(long roomId, ChatRoomTimeoutEvent event) { performOnChatListView(roomId, chatListView -> chatListView.timeoutUser(event)); } public void jumpToBottom() { if (selectedChatListView != null) { selectedChatListView.jumpToBottom(true); } } private void switchChatContent(Node contentNode, Node userListNode) { if (content.getChildren().size() > 1) { content.getChildren().removeFirst(); } content.getChildren().addFirst(contentNode); if (userListNode == null) { if (!isEmpty(userListContent.getChildren())) { userListContent.getChildren().removeFirst(); } if (splitPane.getItems().contains(userListContent)) { dividerPositions = splitPane.getDividerPositions(); splitPane.getItems().remove(userListContent); } } else { if (!isEmpty(userListContent.getChildren())) { userListContent.getChildren().removeFirst(); } userListContent.getChildren().addFirst(userListNode); if (!splitPane.getItems().contains(userListContent)) { splitPane.getItems().add(userListContent); splitPane.setDividerPositions(dividerPositions); } } lastTypingTimeline.jumpTo(javafx.util.Duration.INDEFINITE); } // right now I use a simple implementation. It also has a drawback that it doesn't update the counter private static void addOrUpdate(ObservableList> tree, ChatRoomInfo chatRoomInfo) { if (tree.stream() .map(TreeItem::getValue) .noneMatch(existingRoom -> existingRoom.getRoomInfo().equals(chatRoomInfo))) { tree.add(new TreeItem<>(new RoomHolder(chatRoomInfo))); sortByName(tree); } } private static boolean isInside(ObservableList> tree, ChatRoomInfo chatRoomInfo) { return tree.stream() .map(TreeItem::getValue) .anyMatch(roomHolder -> roomHolder.getRoomInfo().equals(chatRoomInfo)); } private void changeSelectedRoom(TreeItem treeItem) { var chatRoomInfo = treeItem != null ? treeItem.getValue().getRoomInfo() : null; selectedRoom = chatRoomInfo; getSubscribedTreeItem(chatRoomInfo != null ? chatRoomInfo.getId() : 0L).ifPresentOrElse(roomInfoTreeItem -> { assert chatRoomInfo != null; var chatListView = getChatListViewOrCreate(roomInfoTreeItem); selectedChatListView = chatListView; switchChatContent(chatListView.getChatView(), chatListView.getUserListView()); roomName.setText(chatRoomInfo.getName()); roomTopic.setText(chatRoomInfo.getTopic()); status.setVisible(true); sendGroup.setVisible(true); send.requestFocus(); selectedChatListView.jumpToBottom(true); setUnreadMessages(roomInfoTreeItem, false); }, () -> { chatRoomInfoController.setRoomInfo(chatRoomInfo); switchChatContent(roomInfoView, null); status.setVisible(false); sendGroup.setVisible(false); selectedChatListView = null; }); nicknameCompleter.setUsernameFinder(selectedChatListView); } private boolean isRoomSelected() { return selectedRoom != null && selectedRoom.getId() != 0L; } private Optional> getSubscribedTreeItem(long roomId) { return subscribedRooms.getChildren().stream() .filter(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().getId() == roomId) .findFirst(); } private Optional> getAllTreeItem(long roomId) { return Stream.concat(subscribedRooms.getChildren().stream(), Stream.concat(publicRooms.getChildren().stream(), privateRooms.getChildren().stream())) .filter(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().getId() == roomId) .findFirst(); } public void showMessage(ChatRoomMessage chatRoomMessage) { if (chatRoomMessage.isEmpty()) { if (isRoomSelected() && chatRoomMessage.getRoomId() == selectedRoom.getId()) { typingNotification.setText(MessageFormat.format(bundle.getString("chat.notification.typing"), chatRoomMessage.getSenderNickname())); lastTypingTimeline.playFromStart(); } } else { performOnChatListView(chatRoomMessage.getRoomId(), chatListView -> { if (chatRoomMessage.isOwn()) { chatListView.addOwnMessage(chatRoomMessage); } else { chatListView.addUserMessage(chatRoomMessage.getSenderNickname(), chatRoomMessage.getGxsId(), chatRoomMessage.getContent()); setHighlighted(chatRoomMessage.getContent()); } }); getSubscribedTreeItem(chatRoomMessage.getRoomId()).ifPresent(roomHolderTreeItem -> { if (isRoomSelected() && selectedRoom.getId() != chatRoomMessage.getRoomId()) { setUnreadMessages(roomHolderTreeItem, true); } }); if (isRoomSelected() && chatRoomMessage.getRoomId() == selectedRoom.getId()) { lastTypingTimeline.jumpTo(javafx.util.Duration.INDEFINITE); } unreadService.sendUnreadEvent(UnreadEvent.Element.CHAT_ROOM, true); } } private void setUnreadMessages(TreeItem roomHolderTreeItem, boolean unread) { roomHolderTreeItem.getValue().getRoomInfo().setNewMessages(unread); roomTree.refresh(); } private void setHighlighted(String message) { if (message.startsWith(nickname) || message.startsWith("@" + nickname) || message.contains(" " + nickname)) { soundPlayerService.play(SoundType.HIGHLIGHT); trayService.setEventIfIconified(); } } private void performOnChatListView(long roomId, Consumer action) { subscribedRooms.getChildren().stream() .map(this::getChatListViewOrCreate) .filter(chatListView -> chatListView.getId() == roomId) .findFirst() .ifPresent(action); } private ChatListView getChatListViewOrCreate(TreeItem roomInfoTreeItem) { var chatListView = roomInfoTreeItem.getValue().getChatListView(); if (chatListView == null) { var chatRoomId = roomInfoTreeItem.getValue().getRoomInfo().getId(); chatListView = new ChatListView(nickname, chatRoomId, markdownService, uriService, generalClient, imageCache, windowManager, send); chatListView.installClearHistoryContextMenu(() -> chatClient.deleteChatRoomBacklog(chatRoomId) .subscribe()); var finalChatListView = chatListView; chatClient.getChatRoomBacklog(chatRoomId).collectList() .doOnSuccess(backlogs -> Platform.runLater(() -> { assert backlogs != null; fillBacklog(finalChatListView, backlogs); })) .subscribe(); roomInfoTreeItem.getValue().setChatListView(chatListView); } return chatListView; } private void fillBacklog(ChatListView chatListView, List messages) { messages.forEach(message -> { if (message.gxsId() == null) { chatListView.addOwnMessage(message.created(), message.message()); } else { chatListView.addUserMessage(message.created(), message.nickname(), message.gxsId(), message.message()); } }); chatListView.jumpToBottom(true); } private void handleInputKeys(KeyEvent event) { if (TAB_KEY.match(event)) { nicknameCompleter.complete(send.getTextInputControl().getText(), send.getTextInputControl().getCaretPosition(), s -> { send.getTextInputControl().setText(s); send.getTextInputControl().positionCaret(s.length()); }); event.consume(); return; } nicknameCompleter.reset(); if (PASTE_KEY.match(event)) { if (handlePaste(send.getTextInputControl())) { event.consume(); } } else if (COPY_KEY.match(event)) { if (selectedChatListView != null && selectedChatListView.copy()) { event.consume(); } } else if (CTRL_ENTER.match(event) || SHIFT_ENTER.match(event) && isNotBlank(send.getTextInputControl().getText())) { send.getTextInputControl().insertText(send.getTextInputControl().getCaretPosition(), "\n"); sendTypingNotificationIfNeeded(); event.consume(); } else if (ENTER_KEY.match(event) && imagePreview.getImage() != null) { sendImage(); event.consume(); } else if (BACKSPACE_KEY.match(event) && imagePreview.getImage() != null) { cancelImage(); event.consume(); } else if (event.getCode() == KeyCode.ENTER) { if (isRoomSelected() && isNotBlank(send.getTextInputControl().getText())) { sendChatMessage(send.getTextInputControl().getText()); send.clear(); lastTypingNotification = Instant.EPOCH; } event.consume(); } else { sendTypingNotificationIfNeeded(); } } private void sendTypingNotificationIfNeeded() { var now = Instant.now(); if (Duration.between(lastTypingNotification, now).compareTo(TYPING_NOTIFICATION_DELAY.minusSeconds(1)) > 0) { var chatMessage = new ChatMessage(); messageClient.sendToChatRoom(selectedRoom.getId(), chatMessage); lastTypingNotification = now; } } private boolean handlePaste(TextInputControl textInputControl) { var object = ClipboardUtils.getSupportedObjectFromClipboard(); return switch (object) { case Image image -> { setPreviewImage(image); yield true; } case String string -> { TextInputControlUtils.pasteGuessedContent(textInputControl, string); yield true; } case null, default -> false; }; } private void sendImage() { sendChatMessage(""); resetPreviewImage(); jumpToBottom(); } private void sendStickerToMessage(BufferedImage image) { image = ImageUtils.limitMaximumImageSize(image, STICKER_WIDTH_MAX * STICKER_HEIGHT_MAX); sendChatMessage(""); } private void cancelImage() { resetPreviewImage(); } private void setPreviewImage(Image image) { imagePreview.setImage(image); ImageViewUtils.limitMaximumImageSize(imagePreview, PREVIEW_IMAGE_WIDTH_MAX * PREVIEW_IMAGE_HEIGHT_MAX); setPreviewGroupVisibility(true); } /** * Resets the size so that smaller images aren't magnified. */ private void resetPreviewImage() { imagePreview.setImage(null); setPreviewGroupVisibility(false); imagePreview.setFitWidth(0); imagePreview.setFitHeight(0); } private void sendChatMessage(String message) { var chatMessage = new ChatMessage(ChatCommand.parseCommands(message)); messageClient.sendToChatRoom(selectedRoom.getId(), chatMessage); } private void setPreviewGroupVisibility(boolean visible) { UiUtils.setPresent(previewGroup, visible); } public void openInvite(long chatRoomId, ChatRoomInviteEvent event) { Platform.runLater(() -> UiUtils.showAlertConfirm(MessageFormat.format(bundle.getString("chat.room.invite.request"), event.getLocationIdentifier(), event.getRoomName(), event.getRoomTopic()), () -> chatClient.joinChatRoom(chatRoomId).subscribe()) ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/PeerHolder.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.ui.model.location.Location; import io.xeres.ui.model.profile.Profile; class PeerHolder { private Profile profile; private Location location; public PeerHolder() { } public PeerHolder(Profile profile) { this.profile = profile; } public PeerHolder(Profile profile, Location location) { this.profile = profile; this.location = location; } public Profile getProfile() { return profile; } public Location getLocation() { return location; } public void setLocation(Location location) { this.location = location; } public boolean hasLocation() { return location != null; } @Override public String toString() { return hasLocation() ? getLocation().getName() : getProfile().getName(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/chat/RoomHolder.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.chat; import io.xeres.common.message.chat.ChatRoomInfo; public class RoomHolder { private ChatListView chatListView; private final ChatRoomInfo chatRoomInfo; public RoomHolder(String name) { chatRoomInfo = new ChatRoomInfo(name); } public RoomHolder(ChatRoomInfo chatRoomInfo) { this.chatRoomInfo = chatRoomInfo; } public RoomHolder() { chatRoomInfo = new ChatRoomInfo(""); } public void setChatListView(ChatListView chatListView) { this.chatListView = chatListView; } public void clearChatListView() { chatListView = null; } public ChatListView getChatListView() { return chatListView; } public ChatRoomInfo getRoomInfo() { return chatRoomInfo; } @Override public String toString() { return chatRoomInfo.getName(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/common/GxsGroup.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.common; import io.xeres.common.id.GxsId; import java.time.Instant; public interface GxsGroup { /** * Checks if it's a real Gxs Group, that means not a tree directory. * * @return true if it's a real gxs group */ boolean isReal(); long getId(); GxsId getGxsId(); String getName(); String getDescription(); /** * Checks if the group comes from other people than us. * @return true if it's an external group, that is now a group created by us */ boolean isExternal(); int getVisibleMessageCount(); Instant getLastActivity(); boolean isSubscribed(); void setSubscribed(boolean subscribed); boolean hasNewMessages(); void setUnreadCount(int unreadCount); void addUnreadCount(int value); void subtractUnreadCount(int value); } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/common/GxsGroupCellCount.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.common; import javafx.scene.control.TreeTableCell; public class GxsGroupCellCount extends TreeTableCell { @Override protected void updateItem(Integer value, boolean empty) { super.updateItem(value, empty); if (empty || value == null) { setText(null); } else { if (value == 0) { setText(null); } else { setText(value.toString()); } } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/common/GxsGroupTreeTableAction.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.common; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; public interface GxsGroupTreeTableAction { void onSubscribeToGroup(T group); void onUnsubscribeFromGroup(T group); void onSelectSubscribedGroup(T group); void onSelectUnsubscribedGroup(T group); void onUnselectGroup(); void onEditGroup(T group); void onCopyGroupLink(T group); void onOpenUrl(GxsId gxsId, MsgId msgId); } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/common/GxsGroupTreeTableView.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.common; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.ui.client.GxsGroupClient; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.preference.PreferenceUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.control.*; import javafx.scene.control.cell.TreeItemPropertyValueFactory; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignE; import org.kordamp.ikonli.materialdesign2.MaterialDesignL; import org.kordamp.ikonli.materialdesign2.MaterialDesignS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.*; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Stream; /** * A listview that keeps a list of GXS groups and allows to switch to them. Also known * as a sidebar. * * @param */ public class GxsGroupTreeTableView extends TreeTableView { private static final Logger log = LoggerFactory.getLogger(GxsGroupTreeTableView.class); private static final String SUBSCRIBE_MENU_ID = "subscribe"; private static final String UNSUBSCRIBE_MENU_ID = "unsubscribe"; private static final String MARK_AS_READ_MENU_ID = "mark-as-read"; private static final String MARK_AS_UNREAD_MENU_ID = "mark-as-unread"; private static final String COPY_LINK_MENU_ID = "copyLink"; private static final String EDIT_MENU_ID = "edit"; private static final String OPEN_OWN = "OpenOwn"; private static final String OPEN_SUBSCRIBED = "OpenSubscribed"; private static final String OPEN_POPULAR = "OpenPopular"; private static final String OPEN_OTHER = "OpenOther"; @FXML private TreeTableColumn groupNameColumn; @FXML private TreeTableColumn groupCountColumn; private GxsGroupTreeTableAction action; private TreeItem ownGroups; private TreeItem subscribedGroups; private TreeItem popularGroups; private TreeItem otherGroups; private static final ResourceBundle bundle = I18nUtils.getBundle(); private GxsGroupClient groupClient; private T selectedGroup; private final ReadOnlyBooleanWrapper unread = new ReadOnlyBooleanWrapper(); public GxsGroupTreeTableView() { var loader = new FXMLLoader(GxsGroupTreeTableView.class.getResource("/view/custom/gxs_group_tree_table_view.fxml"), bundle); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } public void initialize(String preferenceNodeName, GxsGroupClient groupClient, Function groupCreator, Supplier> cellCreator, GxsGroupTreeTableAction action) { this.action = action; this.groupClient = groupClient; ownGroups = new TreeItem<>(groupCreator.apply(bundle.getString("own"))); subscribedGroups = new TreeItem<>(groupCreator.apply(bundle.getString("subscribed"))); popularGroups = new TreeItem<>(groupCreator.apply(bundle.getString("gxs-group.tree.popular"))); otherGroups = new TreeItem<>(groupCreator.apply(bundle.getString("gxs-group.tree.other"))); var root = new TreeItem<>(groupCreator.apply("")); //noinspection unchecked root.getChildren().addAll(ownGroups, subscribedGroups, popularGroups, otherGroups); root.setExpanded(true); setRoot(root); setShowRoot(false); groupNameColumn.setCellFactory(_ -> cellCreator.get()); groupNameColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue())); groupCountColumn.setCellFactory(_ -> new GxsGroupCellCount<>()); groupCountColumn.setCellValueFactory(new TreeItemPropertyValueFactory<>("unreadCount")); createTreeContextMenu(); // We need Platform.runLater() because when an entry is moved, the selection can change getSelectionModel().selectedItemProperty() .addListener((_, _, newValue) -> Platform.runLater(() -> { selectedGroup = newValue != null ? newValue.getValue() : null; if (selectedGroup == null) { action.onUnselectGroup(); } else { getSubscribedGroups() .filter(forumGroupTreeItem -> forumGroupTreeItem.getValue().getId() == selectedGroup.getId()) .findFirst() .ifPresentOrElse(_ -> action.onSelectSubscribedGroup(selectedGroup), () -> action.onSelectUnsubscribedGroup(selectedGroup)); } })); UiUtils.setOnPrimaryMouseDoubleClicked(this, _ -> { if (isGroupSelected()) { subscribeToGroup(selectedGroup); } }); setupTrees(preferenceNodeName); getGroups(); } public ReadOnlyBooleanProperty unreadProperty() { return unread.getReadOnlyProperty(); } public boolean isUnread() { return unread.get(); } public long getSelectedGroupId() { if (selectedGroup == null) { log.error("getSelectedGroupId() has been called while there's no selected group"); return 0L; } return selectedGroup.getId(); } public GxsId getSelectedGroupGxsId() { if (selectedGroup == null) { log.error("getSelectedGroupGxsId() has been called while there's no selected group"); return null; } return selectedGroup.getGxsId(); } private Stream> getAllGroups() { return Stream.of(ownGroups.getChildren().stream(), // Concat all streams subscribedGroups.getChildren().stream(), popularGroups.getChildren().stream(), otherGroups.getChildren().stream() ).reduce(Stream.empty(), Stream::concat); } public void refreshUnreadCount(long groupId) { getSubscribedGroups() .filter(groupTreeItem -> groupTreeItem.getValue().getId() == groupId) .findFirst() .ifPresent(groupTreeItem -> groupClient.getUnreadCount(groupTreeItem.getValue().getId()) .doOnSuccess(count -> Platform.runLater(() -> { assert count != null; groupTreeItem.getValue().setUnreadCount(count); })) .doFinally(_ -> Platform.runLater(this::refreshUnreadCount)) .subscribe()); } public void refreshUnreadCount(Set groups) { groups.forEach(gxsId -> getSubscribedTreeItemByGxsId(gxsId).ifPresent(groupTreeItem -> groupClient.getUnreadCount(groupTreeItem.getValue().getId()) .doOnSuccess(count -> Platform.runLater(() -> { assert count != null; groupTreeItem.getValue().setUnreadCount(count); })) .doFinally(_ -> Platform.runLater(this::refreshUnreadCount)) .subscribe())); } public void addGroups(List groups) { groups.forEach(group -> { if (!group.isExternal()) { addOrUpdate(ownGroups, group); } else if (group.isSubscribed()) { addOrUpdate(subscribedGroups, group); } else { addOrUpdate(popularGroups, group); } }); updateGroupsUnreadCount(groups); } public void setUnreadCount(long groupId, boolean read) { if (selectedGroup.getId() == groupId) { selectedGroup.addUnreadCount(read ? -1 : 1); refreshUnreadCount(); } } public boolean openUrl(GxsId groupGxsId, MsgId msgId) { var treeItem = getAllGroups() .filter(groupTreeItem -> groupTreeItem.getValue().getGxsId().equals(groupGxsId)) .findFirst() .orElse(null); if (treeItem == null) { return false; } action.onOpenUrl(groupGxsId, msgId); if (treeItem.getValue() != selectedGroup) { Platform.runLater(() -> getSelectionModel().select(treeItem)); } return true; } private Stream> getSubscribedGroups() { return Stream.concat(subscribedGroups.getChildren().stream(), ownGroups.getChildren().stream()); } private void addOrUpdate(TreeItem parent, T group) { var tree = parent.getChildren(); tree.stream() .filter(existingTree -> existingTree.getValue().getId() == group.getId()) .findAny().ifPresentOrElse(found -> found.setValue(group), () -> { tree.add(new TreeItem<>(group)); parent.getValue().addUnreadCount(1); sortByName(tree); removeFromOthers(parent, group); }); } private void subscribeToGroup(T group) { var alreadySubscribed = subscribedGroups.getChildren().stream() .anyMatch(holderTreeItem -> holderTreeItem.getValue().getId() == group.getId()); if (!alreadySubscribed) { groupClient.subscribeToGroup(group.getId()) .doOnSuccess(_ -> { group.setSubscribed(true); addOrUpdate(subscribedGroups, group); }) .subscribe(); } } private void unsubscribeFromGroup(T group) { subscribedGroups.getChildren().stream() .filter(holderTreeItem -> holderTreeItem.getValue().getId() == group.getId()) .findAny() .ifPresent(_ -> groupClient.unsubscribeFromGroup(group.getId()) .doOnSuccess(_ -> { group.setSubscribed(false); addOrUpdate(popularGroups, group); }) // XXX: wrong, could be something else then "others" .subscribe()); } private void markAllAsRead(T group, boolean read) { groupClient.setGroupMessagesReadState(group.getId(), read) .subscribe(); } private void updateGroupsUnreadCount(List groups) { groups.forEach(group -> groupClient.getUnreadCount(group.getId()) .doOnSuccess(unreadCount -> { assert unreadCount != null; Platform.runLater(() -> getSubscribedTreeItemByGxsId(group.getGxsId()) .ifPresent(groupTreeItem -> groupTreeItem.getValue().setUnreadCount(unreadCount))); }) .doFinally(_ -> Platform.runLater(this::refreshUnreadCount)) .subscribe()); } private Optional> getSubscribedTreeItemByGxsId(GxsId gxsId) { return Stream.concat(subscribedGroups.getChildren().stream(), ownGroups.getChildren().stream()) .filter(groupTreeItem -> groupTreeItem.getValue().getGxsId().equals(gxsId)) .findFirst(); } private void refreshUnreadCount() { boolean hasUnreadMessages = hasUnreadMessages(); unread.set(hasUnreadMessages); } private boolean hasUnreadMessages() { return hasUnreadMessagesRecursive(getRoot()); } private boolean hasUnreadMessagesRecursive(TreeItem item) { var group = item.getValue(); if (group != null && group.hasNewMessages()) { return true; } for (TreeItem child : item.getChildren()) { if (hasUnreadMessagesRecursive(child)) { return true; } } return false; } private void sortByName(ObservableList> children) { children.sort((o1, o2) -> o1.getValue().getName().compareToIgnoreCase(o2.getValue().getName())); } private void removeFromOthers(TreeItem parent, T group) { var removalList = new ArrayList<>(List.of(ownGroups, subscribedGroups, popularGroups, otherGroups)); removalList.remove(parent); removalList.forEach(treeItems -> treeItems.getChildren().stream() .filter(boardHolderTreeItem -> boardHolderTreeItem.getValue().getId() == group.getId()) .findFirst() .ifPresent(boardGroupTreeItem -> { treeItems.getChildren().remove(boardGroupTreeItem); treeItems.getValue().subtractUnreadCount(1); })); } private void setupTrees(String nodeName) { var node = PreferenceUtils.getPreferences().node(nodeName); ownGroups.setExpanded(node.getBoolean(OPEN_OWN, false)); subscribedGroups.setExpanded(node.getBoolean(OPEN_SUBSCRIBED, false)); popularGroups.setExpanded(node.getBoolean(OPEN_POPULAR, false)); otherGroups.setExpanded(node.getBoolean(OPEN_OTHER, false)); ownGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_OWN, newValue)); subscribedGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_SUBSCRIBED, newValue)); popularGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_POPULAR, newValue)); otherGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_OTHER, newValue)); } private void getGroups() { groupClient.getGroups().collectList() .doOnSuccess(groups -> { assert groups != null; addGroups(groups); }) .subscribe(); } private boolean isGroupSelected() { return selectedGroup != null && selectedGroup.isReal(); } private void createTreeContextMenu() { var editItem = new MenuItem("Edit"); editItem.setId(EDIT_MENU_ID); editItem.setGraphic(new FontIcon(MaterialDesignS.SQUARE_EDIT_OUTLINE)); editItem.setOnAction(event -> { //noinspection unchecked var group = ((TreeItem) event.getSource()).getValue(); action.onEditGroup(group); }); var subscribeItem = new MenuItem(bundle.getString("gxs-group.tree.subscribe")); subscribeItem.setId(SUBSCRIBE_MENU_ID); subscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_ENTER)); subscribeItem.setOnAction(event -> { //noinspection unchecked var group = ((TreeItem) event.getSource()).getValue(); subscribeToGroup(group); action.onSubscribeToGroup(group); }); var unsubscribeItem = new MenuItem(bundle.getString("gxs-group.tree.unsubscribe")); unsubscribeItem.setId(UNSUBSCRIBE_MENU_ID); unsubscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_EXIT)); unsubscribeItem.setOnAction(event -> { //noinspection unchecked var group = ((TreeItem) event.getSource()).getValue(); unsubscribeFromGroup(group); action.onUnsubscribeFromGroup(group); }); var markAllAsReadItem = new MenuItem("Mark All as Read"); markAllAsReadItem.setId(MARK_AS_READ_MENU_ID); markAllAsReadItem.setGraphic(new FontIcon(MaterialDesignE.EMAIL)); markAllAsReadItem.setOnAction(event -> { @SuppressWarnings("unchecked") var group = ((TreeItem) event.getSource()).getValue(); markAllAsRead(group, true); }); var markAllAsUnReadItem = new MenuItem("Mark All as Unread"); markAllAsUnReadItem.setId(MARK_AS_UNREAD_MENU_ID); markAllAsUnReadItem.setGraphic(new FontIcon(MaterialDesignE.EMAIL_MARK_AS_UNREAD)); markAllAsUnReadItem.setOnAction(event -> { @SuppressWarnings("unchecked") var group = ((TreeItem) event.getSource()).getValue(); markAllAsRead(group, false); }); var copyLinkItem = new MenuItem(bundle.getString("copy-link")); copyLinkItem.setId(COPY_LINK_MENU_ID); copyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); //noinspection unchecked copyLinkItem.setOnAction(event -> action.onCopyGroupLink(((TreeItem) event.getSource()).getValue())); var optionalSeparatorItem = new SeparatorMenuItem(); var xContextMenu = new XContextMenu>(subscribeItem, unsubscribeItem, editItem, optionalSeparatorItem, markAllAsReadItem, markAllAsUnReadItem, new SeparatorMenuItem(), copyLinkItem); xContextMenu.addToNode(this); xContextMenu.setOnShowing((contextMenu, treeItem) -> { if (treeItem == null) { return false; } var value = treeItem.getValue(); if (!value.isReal()) { return false; } contextMenu.getItems().stream() .filter(menuItem -> SUBSCRIBE_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> { menuItem.setDisable(value.isSubscribed()); menuItem.setVisible(value.isExternal()); }); contextMenu.getItems().stream() .filter(menuItem -> UNSUBSCRIBE_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> { menuItem.setDisable(!treeItem.getValue().isSubscribed()); menuItem.setVisible(value.isExternal()); }); contextMenu.getItems().stream() .filter(menuItem -> EDIT_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setVisible(!value.isExternal())); contextMenu.getItems().stream() .filter(menuItem -> menuItem.equals(optionalSeparatorItem)) .findFirst().ifPresent(menuItem -> menuItem.setVisible(!value.isExternal())); return true; }); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/common/GxsMessage.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.common; import io.xeres.common.id.GxsId; import java.time.Instant; public interface GxsMessage { long getId(); GxsId getGxsId(); long getOriginalId(); Instant getPublished(); boolean isRead(); void setRead(boolean read); } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/contact/AvailabilityCellStatus.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.contact; import io.xeres.common.location.Availability; import javafx.scene.control.TableCell; import org.kordamp.ikonli.javafx.FontIcon; class AvailabilityCellStatus extends TableCell { @Override protected void updateItem(Availability item, boolean empty) { super.updateItem(item, empty); setGraphic(empty ? null : AvailabilityCellUtil.updateAvailability((FontIcon) getGraphic(), item)); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/contact/AvailabilityCellUtil.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.contact; import io.xeres.common.location.Availability; import org.kordamp.ikonli.javafx.FontIcon; final class AvailabilityCellUtil { private AvailabilityCellUtil() { throw new UnsupportedOperationException("Utility class"); } public static FontIcon updateAvailability(FontIcon icon, Availability availability) { if (icon == null) { icon = new FontIcon(); } icon.getStyleClass().removeAll("success", "warning", "danger"); switch (availability) { case AVAILABLE -> { icon.setIconLiteral("mdi2c-circle"); icon.getStyleClass().add("success"); icon.setVisible(true); } case AWAY -> { icon.setIconLiteral("mdi2c-clock-time-two"); icon.getStyleClass().add("warning"); icon.setVisible(true); } case BUSY -> { icon.setIconLiteral("mdi2m-minus-circle"); icon.getStyleClass().add("danger"); icon.setVisible(true); } case OFFLINE -> icon.setVisible(false); } return icon; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/contact/AvailabilityTreeCellStatus.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.contact; import io.xeres.common.location.Availability; import javafx.scene.control.TreeTableCell; import org.kordamp.ikonli.javafx.FontIcon; class AvailabilityTreeCellStatus extends TreeTableCell { @Override protected void updateItem(Availability item, boolean empty) { super.updateItem(item, empty); setGraphic(empty ? null : AvailabilityCellUtil.updateAvailability((FontIcon) getGraphic(), item)); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/contact/ContactCellName.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.contact; import io.xeres.common.rest.contact.Contact; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.custom.asyncimage.AsyncImageView; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.support.contact.ContactUtils; import javafx.scene.control.TreeTableCell; import static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID; class ContactCellName extends TreeTableCell { private static final int CONTACT_SIZE = 32; private final GeneralClient generalClient; private final ImageCache imageCache; public ContactCellName(GeneralClient generalClient, ImageCache imageCache) { super(); this.generalClient = generalClient; this.imageCache = imageCache; } @Override protected void updateItem(Contact item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : item.name()); setGraphic(empty ? null : updateContact((AsyncImageView) getGraphic(), item)); } private AsyncImageView updateContact(AsyncImageView asyncImageView, Contact contact) { if (asyncImageView == null) { asyncImageView = new AsyncImageView( url -> generalClient.getImage(url).block(), imageCache); asyncImageView.setFitWidth(CONTACT_SIZE); asyncImageView.setFitHeight(CONTACT_SIZE); } asyncImageView.setUrl(ContactUtils.getIdentityImageUrl(contact)); if (contact.profileId() == OWN_PROFILE_ID) { setStyle("-fx-font-weight: bold"); } else if (!contact.accepted()) { setStyle("-fx-text-fill: -color-fg-subtle"); } else { setStyle(""); } return asyncImageView; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/contact/ContactFilter.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.contact; import io.xeres.common.rest.contact.Contact; import javafx.collections.transformation.FilteredList; import javafx.scene.control.TreeItem; import org.apache.commons.lang3.StringUtils; import java.util.Locale; import java.util.function.Predicate; class ContactFilter implements Predicate> { private final FilteredList> filteredList; private boolean showAllContacts = true; private String nameFilter; public ContactFilter(FilteredList> filteredList) { this.filteredList = filteredList; } public void setShowAllContacts(boolean showAllContacts) { this.showAllContacts = showAllContacts; changePredicate(); } public void setNameFilter(String filter) { nameFilter = filter; changePredicate(); } /** * Forces a change of predicate, otherwise the property will think we're the same. */ private void changePredicate() { filteredList.setPredicate(null); filteredList.setPredicate(this); } @Override public boolean test(TreeItem contact) { if (StringUtils.isNotEmpty(nameFilter)) { // When searching, show all contacts return contact.getValue().name().toLowerCase(Locale.ROOT).contains(nameFilter.toLowerCase(Locale.ROOT)); } return showAllContacts || contact.getValue().accepted(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/contact/ContactViewController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.contact; import atlantafx.base.controls.CustomTextField; import io.xeres.common.id.Id; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.location.Availability; import io.xeres.common.pgp.Trust; import io.xeres.common.protocol.HostPort; import io.xeres.common.rest.contact.Contact; import io.xeres.common.rest.notification.contact.AddOrUpdateContacts; import io.xeres.common.rest.notification.contact.RemoveContacts; import io.xeres.common.rest.profile.ProfileKeyAttributes; import io.xeres.common.util.OsUtils; import io.xeres.ui.client.*; import io.xeres.ui.controller.Controller; import io.xeres.ui.custom.ImageSelectorView; import io.xeres.ui.custom.asyncimage.AsyncImageView; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.model.connection.Connection; import io.xeres.ui.model.location.Location; import io.xeres.ui.model.profile.Profile; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contact.ContactUtils; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.preference.PreferenceUtils; import io.xeres.ui.support.uri.IdentityUri; import io.xeres.ui.support.uri.ProfileUri; import io.xeres.ui.support.util.*; import io.xeres.ui.support.window.WindowManager; import javafx.application.ConditionalFeature; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.collections.transformation.FilteredList; import javafx.collections.transformation.SortedList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.Cursor; import javafx.scene.control.*; import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.effect.DropShadow; import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.paint.ImagePattern; import javafx.scene.shape.Circle; import javafx.stage.FileChooser; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignA; import org.kordamp.ikonli.materialdesign2.MaterialDesignC; import org.kordamp.ikonli.materialdesign2.MaterialDesignL; import org.kordamp.ikonli.materialdesign2.MaterialDesignM; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.Disposable; import java.text.MessageFormat; import java.util.*; import static io.xeres.common.dto.identity.IdentityConstants.NO_IDENTITY_ID; import static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID; import static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID; import static io.xeres.common.dto.profile.ProfileConstants.NO_PROFILE_ID; import static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID; import static io.xeres.ui.support.preference.PreferenceUtils.CONTACTS; import static io.xeres.ui.support.util.DateUtils.DATE_TIME_FORMAT; import static io.xeres.ui.support.util.UiUtils.getWindow; import static javafx.scene.control.Alert.AlertType.WARNING; @Component @FxmlView(value = "/view/contact/contact_view.fxml") public class ContactViewController implements Controller { private static final Logger log = LoggerFactory.getLogger(ContactViewController.class); private static final String SHOW_ALL_CONTACTS = "ShowAllContacts"; private static final String CHAT_MENU_ID = "chat"; private static final String DISTANT_CHAT_MENU_ID = "distant-chat"; private static final String CONNECT_MENU_ID = "connect"; private static final String DELETE_MENU_ID = "delete"; private static final String COPY_LINK_MENU_ID = "copyLink"; private final ConfigClient configClient; private final ConnectionClient connectionClient; private enum Information { PROFILE, IDENTITY, MERGED } @FXML private TreeTableView contactTreeTableView; @FXML private TreeTableColumn contactTreeTableNameColumn; @FXML private TreeTableColumn contactTreeTablePresenceColumn; @FXML private CustomTextField searchTextField; @FXML private ImageSelectorView contactImageSelectorView; @FXML private AsyncImageView ownContactImageView; @FXML private Circle ownContactCircle; @FXML private Circle ownContactState; @FXML private Label ownContactName; @FXML private HBox ownContactGroup; @FXML private Label nameLabel; @FXML private Label idLabel; @FXML private Label typeLabel; @FXML private Label createdOrUpdated; @FXML private Label createdLabel; @FXML private Label trustLabel; @FXML private ChoiceBox trust; @FXML private HBox detailsHeader; @FXML private VBox detailsView; @FXML private GridPane profilePane; @FXML private Label badgeOwn; @FXML private Label badgePartial; @FXML private Label badgeAccepted; @FXML private Label badgeUnvalidated; @FXML private VBox locationsView; @FXML private Button chatButton; @FXML private CheckMenuItem showAllContacts; @FXML private TableView locationTableView; @FXML private TableColumn locationTableNameColumn; @FXML private TableColumn locationTablePresenceColumn; @FXML private TableColumn locationTableIPColumn; @FXML private TableColumn locationTablePortColumn; @FXML private TableColumn locationTableLastConnectedColumn; private final ContactClient contactClient; private final GeneralClient generalClient; private final ProfileClient profileClient; private final IdentityClient identityClient; private final NotificationClient notificationClient; private final ImageCache imageCacheService; private final WindowManager windowManager; private final ResourceBundle bundle; private Disposable contactNotificationDisposable; private Disposable availabilityNotificationDisposable; private final ObservableList> contactObservableList = FXCollections.observableArrayList(p -> new Observable[]{p.valueProperty()}); // Changing the value will mark the list as changed so that sorting works, etc... private final SortedList> sortedList = new SortedList<>(contactObservableList); private final FilteredList> filteredList = new FilteredList<>(sortedList); private final ContactFilter contactFilter = new ContactFilter(filteredList); private FontIcon searchClear; private final TreeItem treeRoot = new TreeItem<>(Contact.EMPTY); private TreeItem ownContact; // Workaround for https://bugs.openjdk.org/browse/JDK-8090563 private TreeItem selectedItem; private TreeItem displayedContact; private boolean contactListLocked; public ContactViewController(ContactClient contactClient, GeneralClient generalClient, ProfileClient profileClient, IdentityClient identityClient, NotificationClient notificationClient, ImageCache imageCacheService, ResourceBundle bundle, WindowManager windowManager, ConfigClient configClient, ConnectionClient connectionClient) { this.contactClient = contactClient; this.generalClient = generalClient; this.profileClient = profileClient; this.identityClient = identityClient; this.notificationClient = notificationClient; this.imageCacheService = imageCacheService; this.bundle = bundle; this.windowManager = windowManager; this.configClient = configClient; this.connectionClient = connectionClient; } @Override public void initialize() { searchClear = new FontIcon(MaterialDesignC.CLOSE_CIRCLE); contactImageSelectorView.setImageLoader(url -> generalClient.getImage(url).block()); contactImageSelectorView.setImageCache(imageCacheService); setupContactSearch(); setupContactTreeTableView(); setupLocationTableView(); setupMenuFilters(); contactImageSelectorView.setOnSelectAction(this::selectOwnContactImage); contactImageSelectorView.setOnDeleteAction(_ -> UiUtils.showAlertConfirm(bundle.getString("contact-view.avatar-delete.confirm"), () -> identityClient.deleteIdentityImage(OWN_IDENTITY_ID).subscribe())); chatButton.setOnAction(_ -> startChat(displayedContact.getValue())); setupOwnContact(); trust.getItems().addAll(Arrays.stream(Trust.values()).filter(t -> t != Trust.ULTIMATE).toList()); setupContactNotifications(); setupConnectionNotifications(); getContacts(); } private void setupOwnContact() { ownContactImageView.setLoader(url -> generalClient.getImage(url).block()); ownContactImageView.setOnSuccess(() -> { ownContactCircle.setVisible(true); ownContactCircle.setFill(new ImagePattern(ownContactImageView.getImage())); }); if (Platform.isSupported(ConditionalFeature.EFFECT)) { ownContactCircle.setEffect(new DropShadow(6, Color.rgb(0, 0, 0, 0.7))); ownContactState.setEffect(new DropShadow(4, Color.rgb(0, 0, 0, 0.9))); } ownContactImageView.setImageCache(imageCacheService); profileClient.getOwn() .doOnSuccess(profile -> { assert profile != null; ownContactName.setText(profile.getName()); ownContact = new TreeItem<>(Contact.withName(Contact.OWN, profile.getName())); }) .subscribe(); displayOwnContactImage(); UiUtils.setOnPrimaryMouseClicked(ownContactGroup, _ -> displayOwnContact()); createStateContextMenu(); } private void displayOwnContact() { if (ownContact == null) { log.error("Failure to load own contact, can't display"); return; } contactTreeTableView.getSelectionModel().clearSelection(); displayContact(ownContact); } private void displayOwnContactImage() { ownContactImageView.setUrl(ContactUtils.getIdentityImageUrl(Contact.OWN)); } private void setupContactSearch() { searchClear.setCursor(Cursor.HAND); UiUtils.setOnPrimaryMouseClicked(searchClear, _ -> searchTextField.clear()); TextInputControlUtils.addEnhancedInputContextMenu(searchTextField, null, null); searchTextField.textProperty().addListener((_, _, newValue) -> contactFilter.setNameFilter(newValue)); searchTextField.lengthProperty().addListener((_, _, newValue) -> { if (newValue.intValue() > 0) { searchTextField.setRight(searchClear); } else { searchTextField.setRight(null); } }); } private void setupContactTreeTableView() { contactTreeTableNameColumn.setCellFactory(_ -> new ContactCellName(generalClient, imageCacheService)); contactTreeTableNameColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue())); contactTreeTablePresenceColumn.setCellFactory(_ -> new AvailabilityTreeCellStatus<>()); contactTreeTablePresenceColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue().availability())); // Sort by connected first then name contactTreeTableView.setSortPolicy(_ -> true); // This is needed otherwise the default sorting will break everything contactTreeTablePresenceColumn.setSortType(TreeTableColumn.SortType.ASCENDING); contactTreeTablePresenceColumn.setSortable(true); contactTreeTableNameColumn.setSortType(TreeTableColumn.SortType.ASCENDING); contactTreeTableNameColumn.setSortable(true); // Do not allow selection of multiple entries contactTreeTableView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); contactTreeTableView.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> { if (contactListLocked) { return; } //log.debug("Selection property changed, old: {}, new: {}", oldValue, newValue); displayContact(newValue); }); treeRoot.setExpanded(true); Bindings.bindContent(treeRoot.getChildren(), filteredList); sortedList.comparatorProperty().bind(contactTreeTableView.comparatorProperty()); // Because of JDK-8248217 (and others), we have // to save the selection before the sortedList (and then filteredList) are // updated. sortedList.addListener((InvalidationListener) _ -> { //log.debug("Sorting invalidated, selected index: {}", contactTreeTableView.getSelectionModel().getSelectedIndex()); if (!sortedList.isEmpty()) // Empty lists don't get passed on to filtered list and would get locked forever { contactListLocked = true; } selectedItem = contactTreeTableView.getSelectionModel().getSelectedItem(); }); // Then we restore the selection after filteredList has been // updated. filteredList.addListener((ListChangeListener>) _ -> { //log.debug("FilteredList changed, actions: {}", c); // We must call this otherwise the selection is lost contactTreeTableView.getSelectionModel().select(selectedItem); contactListLocked = false; }); contactTreeTableView.setRoot(treeRoot); contactTreeTableView.setShowRoot(false); createContactTableViewContextMenu(); } private void scrollToSelectedContact() { var index = contactTreeTableView.getSelectionModel().getSelectedIndex(); if (index != -1) { contactTreeTableView.scrollTo(index); } } private void setupLocationTableView() { locationTableView.setRowFactory(_ -> new LocationRow()); locationTableNameColumn.setCellValueFactory(new PropertyValueFactory<>("name")); locationTablePresenceColumn.setCellFactory(_ -> new AvailabilityCellStatus<>()); locationTablePresenceColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(getLocationAvailability(param.getValue()))); locationTableIPColumn.setCellValueFactory(param -> { var hostPort = getConnectedAddress(param.getValue()); return new SimpleStringProperty(hostPort != null ? hostPort.host() : "-"); }); locationTablePortColumn.setCellValueFactory(param -> { var hostPort = getConnectedAddress(param.getValue()); return new SimpleStringProperty(hostPort != null ? String.valueOf(hostPort.port()) : "-"); }); locationTableLastConnectedColumn.setCellValueFactory(param -> new SimpleStringProperty(getLastConnection(param.getValue()))); createLocationTableContextMenu(); } /** * Gets the true availability state of a location. Location has no concept of offline presence. * * @param location the location * @return the location's availability state */ private static Availability getLocationAvailability(Location location) { return location.isConnected() ? location.getAvailability() : Availability.OFFLINE; } private void setupMenuFilters() { var prefsNode = PreferenceUtils.getPreferences().node(CONTACTS); showAllContacts.selectedProperty().addListener((_, _, newValue) -> { contactFilter.setShowAllContacts(newValue); prefsNode.putBoolean(SHOW_ALL_CONTACTS, newValue); }); showAllContacts.selectedProperty().set(prefsNode.getBoolean(SHOW_ALL_CONTACTS, false)); } private void getContacts() { Map> contacts = new HashMap<>(); List> identities = new ArrayList<>(); contactClient.getContacts() .doOnNext(contact -> { if (contact.profileId() != NO_PROFILE_ID) { if (contact.identityId() != NO_IDENTITY_ID) { if (contact.identityId() == OWN_IDENTITY_ID || contact.profileId() == OWN_PROFILE_ID) { // Own profile, we don't add it to the list // because it has its own section above. return; } if (contacts.containsKey(contact.profileId())) { var profile = contacts.get(contact.profileId()); updateProfileWithIdentity(profile, new TreeItem<>(contact)); } else { contacts.put(contact.profileId(), new TreeItem<>(contact)); } } else { if (contact.profileId() == OWN_IDENTITY_ID) { // Own profile, we don't add it to the list // because it has its own section above. return; } if (contacts.put(contact.profileId(), new TreeItem<>(contact)) != null) { throw new IllegalStateException("Profile overwritten"); } } } else { identities.add(new TreeItem<>(contact)); } }) .doOnComplete(() -> Platform.runLater(() -> { // Add all contacts contactObservableList.addAll(contacts.values()); contactObservableList.addAll(identities); //noinspection unchecked contactTreeTableView.getSortOrder().setAll(contactTreeTablePresenceColumn, contactTreeTableNameColumn); })) .subscribe(); } private void updateProfileWithIdentity(TreeItem profile, TreeItem identity) { if (profile.getValue().identityId() != NO_IDENTITY_ID) { // Profile with an identity already if (profile.getValue().identityId() == identity.getValue().identityId()) { // Same identity, we replace it profile.setValue(identity.getValue()); refreshContactIfNeeded(profile); } else { if (profile.getChildren().isEmpty()) { // Not the same, we replace if we have a matching name if (!replaceIfSameName(profile, identity)) { profile.getChildren().add(identity); } } else { if (!replaceIfSameName(profile, identity)) { replaceOrAddChildren(profile, identity); } } } } else { // Lone profile that gets an identity added if (!replaceIfSameName(profile, identity)) { profile.getChildren().add(identity); } } } private boolean replaceIfSameName(TreeItem profile, TreeItem identity) { if (profile.getValue().name().equalsIgnoreCase(identity.getValue().name())) { profile.setValue(identity.getValue()); refreshContactIfNeeded(profile); return true; } return false; } private void replaceOrAddChildren(TreeItem parent, TreeItem identity) { for (TreeItem child : parent.getChildren()) { if (child.getValue().identityId() == identity.getValue().identityId()) { child.setValue(identity.getValue()); refreshContactIfNeeded(child); return; } } parent.getChildren().add(identity); } private void setupContactNotifications() { contactNotificationDisposable = notificationClient.getContactNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { Objects.requireNonNull(sse.data()); switch (sse.data()) { case AddOrUpdateContacts action -> action.contacts().forEach(this::addContact); case RemoveContacts action -> action.contacts().forEach(this::removeContact); } })) .subscribe(); } private void setupConnectionNotifications() { availabilityNotificationDisposable = notificationClient.getAvailabilityNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { Objects.requireNonNull(sse.data()); updateContactConnection(sse.data().profileId(), sse.data().locationId(), sse.data().availability()); })) .subscribe(); } private TreeItem findProfile(long profileId) { return contactObservableList.stream() .filter(existingContact -> existingContact.getValue().profileId() == profileId) .findFirst().orElse(null); } private void clearCachedImages(TreeItem contact) { imageCacheService.evictImage(ContactUtils.getIdentityImageUrl(contact.getValue())); // Make sure AsyncImageView doesn't refuse to load the // url because it thinks it's already loaded. if (displayedContact != null && displayedContact.getValue().equals(contact.getValue())) { contactImageSelectorView.setImage(null); } } private void addContact(Contact contact) { //log.debug("Adding contact {}", contact); if (contact.profileId() != NO_PROFILE_ID && contact.identityId() != NO_IDENTITY_ID) { if (contact.identityId() == OWN_IDENTITY_ID) { // Own identity, special handling Objects.requireNonNull(ownContact); clearCachedImages(ownContact); ownContactImageView.setUrl(null); ownContactCircle.setVisible(false); displayOwnContact(); displayOwnContactImage(); return; } // Full contact var existing = findProfile(contact.profileId()); var item = new TreeItem<>(contact); if (existing != null) { clearCachedImages(existing); updateProfileWithIdentity(existing, item); } else { contactObservableList.add(item); } } else if (contact.profileId() != NO_PROFILE_ID) { if (contact.profileId() == OWN_PROFILE_ID) { // Own profile, special handling return; } // Lone profile var existing = findProfile(contact.profileId()); var item = new TreeItem<>(contact); if (existing != null) { // This is a profile update (eg. different trust). We need to restore // the identity otherwise it won't display its image if (existing.getValue().identityId() != NO_IDENTITY_ID) { existing.setValue(Contact.withIdentityId(contact, existing.getValue().identityId())); } else { existing.setValue(contact); } refreshContactIfNeeded(existing); } else { contactObservableList.add(item); } } else if (contact.identityId() != NO_IDENTITY_ID) { // Lone identity var existing = contactObservableList.stream() .filter(existingContact -> existingContact.getValue().identityId() == contact.identityId()) .findFirst().orElse(null); if (existing != null) { clearCachedImages(existing); existing.setValue(contact); refreshContactIfNeeded(existing); } else { contactObservableList.add(new TreeItem<>(contact)); } } else { throw new IllegalStateException("Empty contact (identity == 0L and profile == 0L). Shouldn't happen."); } } private void removeContact(Contact contact) { //log.debug("Removing contact {}", contact); if (contact.identityId() != NO_IDENTITY_ID) { contactObservableList.removeIf(existingContact -> existingContact.getValue().identityId() == contact.identityId()); } else if (contact.profileId() != NO_PROFILE_ID) { contactObservableList.removeIf(existingContact -> existingContact.getValue().profileId() == contact.profileId()); } // XXX: unselect if it was selected? } private void updateContactConnection(long profileId, long locationId, Availability availability) { log.debug("Updating contact connection {} with availability {}", profileId, availability); if (locationId == OWN_LOCATION_ID) { setOwnContactState(availability); return; } var existing = contactObservableList.stream() .filter(existingContact -> existingContact.getValue().profileId() == profileId) .findFirst().orElse(null); if (existing == null) { log.debug("Contact for profile {} not found. Removed then disconnected?", profileId); return; } // XXX: we do need to comment out the next one otherwise a new location is not detected! how to filter the double refresh problem though? //if (existing.getValue().availability() != availability) // Avoid useless refreshes { if (existing.isLeaf()) { existing.setValue(Contact.withAvailability(existing.getValue(), availability)); refreshContactIfNeeded(existing); } else { // There are children, we need to use a different algorithm then. profileClient.findById(profileId) .doOnSuccess(profile -> Platform.runLater(() -> { assert profile != null; existing.setValue(Contact.withAvailability(existing.getValue(), profile.getLocations().stream() .filter(Location::isConnected) .min(Comparator.comparing(location -> location.getAvailability().ordinal())) .map(Location::getAvailability) .orElse(Availability.OFFLINE))); refreshContactIfNeeded(existing); })) .subscribe(); } } } private void setOwnContactState(Availability availability) { switch (availability) { case AVAILABLE -> ownContactState.setFill(Color.LIMEGREEN); case AWAY -> ownContactState.setFill(Color.ORANGE); case BUSY -> ownContactState.setFill(Color.RED); case OFFLINE -> ownContactState.setFill(Color.GRAY); } } private HostPort getConnectedAddress(Location location) { if (location.isConnected()) { var connection = location.getConnections().stream().max(Comparator.comparing(Connection::getLastConnected, Comparator.nullsFirst(Comparator.naturalOrder()))).orElse(null); if (connection != null) { return HostPort.parse(connection.getAddress()); } } return null; } private String getLastConnection(Location location) { if (location.isConnected()) { return bundle.getString("contact-view.location.last-connected.now"); } else { var lastConnected = location.getLastConnected(); if (lastConnected == null) { return bundle.getString("contact-view.location.last-connected.never"); } else { return DATE_TIME_FORMAT.format(lastConnected); } } } private void setTrust(Profile profile) { clearTrust(); trust.getSelectionModel().select(profile.getTrust()); if (profile.isOwn()) { trustLabel.setVisible(false); trust.setVisible(false); } else { trustLabel.setVisible(true); trust.setVisible(true); } trust.setDisable(profile.isOwn()); trust.setOnAction(_ -> profileClient.setTrust(profile.getId(), trust.getSelectionModel().getSelectedItem()).subscribe()); } private void clearTrust() { trust.setOnAction(null); } /** * Displays the contact. To be called by the list selector or the own contact display. * * @param contact the contact to display */ private void displayContact(TreeItem contact) { displayContact(contact, false); } /** * Refreshes the contact if needed. To be called after each modification of any contact because the listview * won't do it by itself. * * @param contact the contact */ private void refreshContactIfNeeded(TreeItem contact) { if (displayedContact == contact) { displayContact(contact, true); } } /** * Displays the contact. Do not call this method directly! Use {@link #displayContact(TreeItem)} or * {@link #refreshContactIfNeeded(TreeItem)} instead. * * @param contact the contact * @param force to force the refresh */ private void displayContact(TreeItem contact, boolean force) { if (contactListLocked && !force) { return; } if (contact == null) { displayedContact = null; clearSelection(); return; } displayedContact = contact; hideBadges(); clearTrust(); TooltipUtils.uninstall(idLabel); TooltipUtils.uninstall(typeLabel); TooltipUtils.uninstall(createdLabel); contactImageSelectorView.setEditable(false); detailsHeader.setVisible(true); detailsView.setVisible(true); nameLabel.setText(contact.getValue().name()); setChatButtonVisual(contact.getValue()); contactImageSelectorView.setImageUrl(ContactUtils.getIdentityImageUrl(contact.getValue())); if (contact.getValue().profileId() != NO_PROFILE_ID && contact.getValue().identityId() != NO_IDENTITY_ID) { typeLabel.setText(bundle.getString("contact-view.information.linked-to-profile")); fetchProfile(contact.getValue().profileId(), Information.MERGED, isSubContact(contact)); fetchIdentity(contact.getValue().identityId(), Information.MERGED); } else if (contact.getValue().profileId() != NO_PROFILE_ID) { typeLabel.setText(bundle.getString("contact-view.information.profile")); fetchProfile(contact.getValue().profileId(), Information.PROFILE, false); } else if (contact.getValue().identityId() != NO_IDENTITY_ID) { profilePane.setVisible(false); typeLabel.setText(bundle.getString("contact-view.information.identity")); hideTableLocations(); fetchIdentity(contact.getValue().identityId(), Information.IDENTITY); } } private void setChatButtonVisual(Contact contact) { if (contact.profileId() == OWN_PROFILE_ID || contact.identityId() == OWN_IDENTITY_ID) { chatButton.setGraphic(new FontIcon(MaterialDesignM.MESSAGE)); chatButton.setDisable(true); TooltipUtils.uninstall(chatButton); } else if (contact.profileId() != NO_PROFILE_ID) { chatButton.setGraphic(new FontIcon(MaterialDesignM.MESSAGE)); chatButton.setDisable(contact.availability() == Availability.OFFLINE); TooltipUtils.install(chatButton, bundle.getString("contact-view.chat.start")); } else { chatButton.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT)); chatButton.setDisable(false); TooltipUtils.install(chatButton, bundle.getString("contact-view.distant-chat.start")); } } private void clearSelection() { contactImageSelectorView.setEditable(false); detailsHeader.setVisible(false); detailsView.setVisible(false); nameLabel.setText(null); idLabel.setText(null); TooltipUtils.uninstall(idLabel); TooltipUtils.uninstall(createdLabel); typeLabel.setText(null); TooltipUtils.uninstall(typeLabel); createdLabel.setText(null); contactImageSelectorView.setImage(null); profilePane.setVisible(false); hideTableLocations(); } private void fetchProfile(long profileId, Information information, boolean isLeaf) { profileClient.findById(profileId) .doOnSuccess(profile -> Platform.runLater(() -> { assert profile != null; showProfileInformation(profile, information); showBadges(profile); setTrust(profile); trust.setDisable(isLeaf || !profile.isAccepted()); profilePane.setVisible(true); showTableLocations(profile.getLocations()); })) .doOnError(throwable -> { if (throwable instanceof WebClientResponseException wEx && wEx.getStatusCode() == HttpStatus.NOT_FOUND) { Platform.runLater(() -> UiUtils.setPresent(badgeUnvalidated, true)); } }) .subscribe(); } private void showProfileInformation(Profile profile, Information information) { if (information == Information.PROFILE) { idLabel.setText(Id.toString(profile.getPgpIdentifier())); showProfileKeyInformation(profile, idLabel); } if (information == Information.PROFILE || information == Information.MERGED) { createdOrUpdated.setText(bundle.getString("contact-view.information.created")); createdLabel.setText(profile.getCreated() != null ? DATE_TIME_FORMAT.format(profile.getCreated()) : bundle.getString("contact-view.information.created-unknown")); if (information == Information.MERGED) { typeLabel.setText(bundle.getString("contact-view.information.linked-to-profile") + " " + Id.toString(profile.getPgpIdentifier())); showProfileKeyInformation(profile, typeLabel); } } } private void showProfileKeyInformation(Profile profile, Label node) { if (!profile.isPartial()) { TooltipUtils.install(node, delayedTooltip -> profileClient.findProfileKeyAttributes(profile.getId()) .doOnSuccess(profileKeyAttributes -> Platform.runLater(() -> { assert profileKeyAttributes != null; delayedTooltip.show(getKeyInformation(profileKeyAttributes)); })) .subscribe()); } } private String getKeyInformation(ProfileKeyAttributes profileKeyAttributes) { // EC keys don't return the length for some reason if (profileKeyAttributes.keyBits() > 0) { return MessageFormat.format(bundle.getString("contact-view.information.key-information-with-length"), profileKeyAttributes.version(), PublicKeyUtils.getKeyAlgorithmName(profileKeyAttributes.keyAlgorithm()), profileKeyAttributes.keyBits(), PublicKeyUtils.getSignatureHash(profileKeyAttributes.signatureHash())); } else { return MessageFormat.format(bundle.getString("contact-view.information.key-information"), profileKeyAttributes.version(), PublicKeyUtils.getKeyAlgorithmName(profileKeyAttributes.keyAlgorithm()), PublicKeyUtils.getSignatureHash(profileKeyAttributes.signatureHash())); } } private void showBadges(Profile profile) { UiUtils.setPresent(badgeOwn, profile.isOwn()); UiUtils.setPresent(badgePartial, profile.isPartial()); UiUtils.setPresent(badgeAccepted, profile.isAccepted()); UiUtils.setAbsent(badgeUnvalidated); } private void hideBadges() { UiUtils.setAbsent(badgeOwn); UiUtils.setAbsent(badgePartial); UiUtils.setAbsent(badgeAccepted); UiUtils.setAbsent(badgeUnvalidated); } private void fetchIdentity(long identityId, Information information) { identityClient.findById(identityId) .doOnSuccess(identity -> Platform.runLater(() -> { assert identity != null; if (information == Information.IDENTITY || information == Information.MERGED) { idLabel.setText(Id.toString(identity.getGxsId())); TooltipUtils.install(createdLabel, "Last updated: " + DateUtils.formatDateTime(identity.getUpdated(), "unknown")); } if (information == Information.IDENTITY) { createdOrUpdated.setText(bundle.getString("contact-view.information.updated")); createdLabel.setText(DATE_TIME_FORMAT.format(identity.getUpdated())); } if (identityId == OWN_IDENTITY_ID) { contactImageSelectorView.setEditable(true, identity.hasImage()); } })) .doOnError(_ -> Platform.runLater(this::clearSelection)) .subscribe(); } private void showTableLocations(List locations) { if (locations.isEmpty()) { hideTableLocations(); } else { locationTableView.getItems().setAll(locations); locationsView.setVisible(true); } } private void hideTableLocations() { locationsView.setVisible(false); } private void createContactTableViewContextMenu() { // The chat menu item can morph between chat and distant chat var chatItem = new MenuItem(bundle.getString("contact-view.action.chat")); chatItem.setId(CHAT_MENU_ID); chatItem.setOnAction(event -> { @SuppressWarnings("unchecked") var contact = ((TreeItem) event.getSource()).getValue(); startChat(contact); }); // And the distant chat menu item can disappear all together var distantChatItem = new MenuItem(bundle.getString("contact-view.action.distant-chat")); distantChatItem.setId(DISTANT_CHAT_MENU_ID); distantChatItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT)); distantChatItem.setOnAction(event -> { @SuppressWarnings("unchecked") var contact = ((TreeItem) event.getSource()).getValue(); startDistantChat(contact); }); var deleteItem = new MenuItem(bundle.getString("profiles.delete")); deleteItem.setId(DELETE_MENU_ID); deleteItem.setGraphic(new FontIcon(MaterialDesignA.ACCOUNT_REMOVE)); deleteItem.setOnAction(event -> { @SuppressWarnings("unchecked") var contact = (TreeItem) event.getSource(); if (contact.getValue().profileId() != NO_PROFILE_ID && contact.getValue().profileId() != OWN_PROFILE_ID) { UiUtils.showAlertConfirm(MessageFormat.format(bundle.getString("contact-view.profile-delete.confirm"), contact.getValue().name()), () -> profileClient.delete(contact.getValue().profileId()) .subscribe()); } }); var copyLinkItem = new MenuItem(bundle.getString("copy-link")); copyLinkItem.setId(COPY_LINK_MENU_ID); copyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); copyLinkItem.setOnAction(event -> { @SuppressWarnings("unchecked") var contact = (TreeItem) event.getSource(); if (contact.getValue().profileId() != NO_PROFILE_ID) { profileClient.findById(contact.getValue().profileId()) .doOnSuccess(profile -> Platform.runLater(() -> { assert profile != null; ClipboardUtils.copyTextToClipboard(new ProfileUri(profile.getName(), profile.getPgpIdentifier()).toUriString()); })) .subscribe(); } else if (contact.getValue().identityId() != NO_IDENTITY_ID) { identityClient.findById(contact.getValue().identityId()) .doOnSuccess(identity -> Platform.runLater(() -> { assert identity != null; ClipboardUtils.copyTextToClipboard(new IdentityUri(identity.getName(), identity.getGxsId(), null).toUriString()); })) .subscribe(); } }); var xContextMenu = new XContextMenu>(chatItem, distantChatItem, copyLinkItem, new SeparatorMenuItem(), deleteItem); xContextMenu.setOnShowing((contextMenu, contact) -> { if (contact == null) { return false; } contextMenu.getItems().stream() .filter(menuItem -> CHAT_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> { if (contact.getValue().profileId() == OWN_PROFILE_ID || contact.getValue().identityId() == OWN_IDENTITY_ID) { menuItem.setText(bundle.getString("contact-view.action.chat")); menuItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE)); menuItem.setDisable(true); } else if (contact.getValue().profileId() != NO_PROFILE_ID) { menuItem.setText(bundle.getString("contact-view.action.chat")); menuItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE)); menuItem.setDisable(contact.getValue().availability() == Availability.OFFLINE); } else { menuItem.setText(bundle.getString("contact-view.action.distant-chat")); menuItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT)); menuItem.setDisable(false); } }); contextMenu.getItems().stream() .filter(menuItem -> DISTANT_CHAT_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> { if (contact.getValue().profileId() != NO_PROFILE_ID) { menuItem.setVisible(contact.getValue().availability() == Availability.OFFLINE); } else { menuItem.setVisible(false); } }); contextMenu.getItems().stream() .filter(menuItem -> COPY_LINK_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(contact.getValue().profileId() == NO_PROFILE_ID && contact.getValue().identityId() == NO_IDENTITY_ID)); contextMenu.getItems().stream() .filter(menuItem -> DELETE_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(isSubContact(contact) || contact.getValue().profileId() == NO_PROFILE_ID || contact.getValue().profileId() == OWN_PROFILE_ID)); return true; }); xContextMenu.addToNode(contactTreeTableView); } private void createStateContextMenu() { var availableItem = createStateMenuItem(Availability.AVAILABLE); var awayItem = createStateMenuItem(Availability.AWAY); var busyItem = createStateMenuItem(Availability.BUSY); var contextMenu = new ContextMenu(availableItem, awayItem, busyItem); ownContactState.setOnContextMenuRequested(event -> { contextMenu.show(ownContactState, event.getScreenX(), event.getScreenY()); event.consume(); }); UiUtils.setOnPrimaryMouseClicked(ownContactState, event -> contextMenu.show(ownContactState, event.getScreenX(), event.getScreenY())); } private void createLocationTableContextMenu() { var chatItem = new MenuItem(bundle.getString("contact-view.action.chat")); chatItem.setId(CHAT_MENU_ID); chatItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE)); chatItem.setOnAction(event -> { var location = (Location) event.getSource(); startChat(location.getLocationIdentifier()); }); var connectItem = new MenuItem(bundle.getString("contact-view.action.connect")); connectItem.setId(CONNECT_MENU_ID); connectItem.setGraphic(new FontIcon(MaterialDesignC.CONNECTION)); connectItem.setOnAction(event -> { var location = (Location) event.getSource(); connectionClient.connect(location.getLocationIdentifier(), -1) .subscribe(); }); var copyLinkItem = new MenuItem(bundle.getString("copy")); copyLinkItem.setId(COPY_LINK_MENU_ID); copyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); copyLinkItem.setOnAction(event -> { var location = (Location) event.getSource(); ClipboardUtils.copyTextToClipboard(location.getLocationIdentifier().toString()); }); var xContextMenu = new XContextMenu(chatItem, connectItem, copyLinkItem); xContextMenu.setOnShowing((contextMenu, location) -> { contextMenu.getItems().stream() .filter(menuItem -> CHAT_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(location == null || location.getId() == OWN_LOCATION_ID)); contextMenu.getItems().stream() .filter(menuItem -> CONNECT_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(location == null || location.getId() == OWN_LOCATION_ID || location.isConnected())); return location != null; }); xContextMenu.addToNode(locationTableView); } private MenuItem createStateMenuItem(Availability availability) { var menuItem = new MenuItem(availability.toString()); menuItem.setGraphic(AvailabilityCellUtil.updateAvailability(null, availability)); menuItem.setOnAction(_ -> configClient.changeAvailability(availability).subscribe()); return menuItem; } private boolean isSubContact(TreeItem contact) { return contact.getParent() != treeRoot && contact.isLeaf(); } private void selectOwnContactImage(ActionEvent event) { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("main.select-avatar")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); ChooserUtils.setSupportedLoadImageFormats(fileChooser); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); if (selectedFile != null && selectedFile.canRead()) { identityClient.uploadIdentityImage(OWN_IDENTITY_ID, selectedFile) .subscribe(); } } private void startChat(Contact contact) { if (contact.profileId() != NO_PROFILE_ID) { profileClient.findById(contact.profileId()) .doOnSuccess(profile -> { assert profile != null; profile.getLocations().stream() .filter(Location::isConnected).min(Comparator.comparing(Location::getAvailability)) .ifPresent(location -> windowManager.openMessaging(location.getLocationIdentifier())); } ) .subscribe(); } else { startDistantChat(contact); } } private void startChat(LocationIdentifier locationIdentifier) { windowManager.openMessaging(locationIdentifier); } private void startDistantChat(Contact contact) { identityClient.findById(contact.identityId()) .doOnSuccess(identity -> { assert identity != null; windowManager.openMessaging(identity.getGxsId()); }) .subscribe(); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (contactNotificationDisposable != null && !contactNotificationDisposable.isDisposed()) { contactNotificationDisposable.dispose(); } if (availabilityNotificationDisposable != null && !availabilityNotificationDisposable.isDisposed()) { availabilityNotificationDisposable.dispose(); } } @EventListener public void handleOpenUriEvent(OpenUriEvent event) { if (event.uri() instanceof IdentityUri identityUri) { identityClient.findByGxsId(identityUri.gxsId()).collectList() .doOnSuccess(identities -> Platform.runLater(() -> { assert identities != null; if (identities.isEmpty()) { UiUtils.showAlert(WARNING, bundle.getString("contact-view.open.identity-not-found")); } else { var identity = identities.getFirst(); if (identity.getId() == OWN_IDENTITY_ID) { // This is our own identity. displayOwnContact(); return; } contactObservableList.stream() .filter(contact -> contact.getValue().identityId() == identity.getId()) .findFirst() .ifPresentOrElse(contact -> { contactTreeTableView.getSelectionModel().select(contact); scrollToSelectedContact(); }, () -> UiUtils.showAlert(WARNING, bundle.getString("contact-view.open.identity-not-found"))); } })) .subscribe(); } else if (event.uri() instanceof ProfileUri profileUri) { profileClient.findByPgpIdentifier(profileUri.hash(), true).collectList() .doOnSuccess(profiles -> Platform.runLater(() -> { assert profiles != null; if (profiles.isEmpty()) { UiUtils.showAlert(WARNING, bundle.getString("contact-view.open.profile-not-found")); } else { var profile = profiles.getFirst(); if (profile.getId() == OWN_IDENTITY_ID) { // This is our own profile. displayOwnContact(); } contactObservableList.stream() .filter(contact -> contact.getValue().profileId() == profile.getId()) .findFirst() .ifPresentOrElse(contact -> { contactTreeTableView.getSelectionModel().select(contact); scrollToSelectedContact(); }, () -> UiUtils.showAlert(WARNING, bundle.getString("contact-view.open.profile-not-found"))); } })) .subscribe(); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/contact/LocationRow.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.contact; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.model.location.Location; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.TableRow; import java.util.ResourceBundle; import java.util.regex.Pattern; class LocationRow extends TableRow { private static final Pattern RETROSHARE_VERSION_DETECTOR = Pattern.compile("^\\d.*$"); private static final ResourceBundle bundle = I18nUtils.getBundle(); @Override protected void updateItem(Location item, boolean empty) { super.updateItem(item, empty); if (empty) { TooltipUtils.uninstall(this); } else { var sb = new StringBuilder(); sb.append(bundle.getString("contact-view.information.location.id")); sb.append(" "); sb.append(item.getLocationIdentifier().toString()); if (item.hasVersion()) { sb.append("\n"); sb.append(bundle.getString("contact-view.information.location.version")); sb.append(" "); // Retroshare only sends the version so we prefix it with its name if (RETROSHARE_VERSION_DETECTOR.matcher(item.getVersion()).matches()) { sb.append("Retroshare "); } sb.append(item.getVersion()); } TooltipUtils.install(this, sb.toString()); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/debug/DebugRequesterWindowController.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.debug; import io.xeres.ui.controller.WindowController; import javafx.fxml.FXML; import javafx.scene.control.ComboBox; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.io.IOException; @Component @FxmlView(value = "/view/debug/debug_requester_view.fxml") public class DebugRequesterWindowController implements WindowController { @FXML private ComboBox comboBox; @Override public void initialize() throws IOException { comboBox.getSelectionModel().selectFirst(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileAddDownloadViewWindowController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.rest.file.AddDownloadRequest; import io.xeres.common.util.ByteUnitUtils; import io.xeres.ui.client.FileClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.ReadOnlyTextField; import io.xeres.ui.support.util.TooltipUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.Button; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.text.MessageFormat; import java.util.ResourceBundle; @Component @FxmlView(value = "/view/file/add_download.fxml") public class FileAddDownloadViewWindowController implements WindowController { @FXML private ReadOnlyTextField name; @FXML private ReadOnlyTextField size; @FXML private ReadOnlyTextField hash; @FXML private Button downloadButton; @FXML private Button cancelButton; private final FileClient fileClient; private final ResourceBundle bundle; public FileAddDownloadViewWindowController(FileClient fileClient, ResourceBundle bundle) { this.fileClient = fileClient; this.bundle = bundle; } @Override public void initialize() { cancelButton.setOnAction(UiUtils::closeWindow); Platform.runLater(this::handleArgument); } private void handleArgument() { var args = (AddDownloadRequest) UiUtils.getUserData(name); if (args == null) { throw new IllegalArgumentException("Missing user data"); } name.setText(args.name()); size.setText(ByteUnitUtils.fromBytes(args.size())); TooltipUtils.install(size, MessageFormat.format(bundle.getString("download-add.bytes"), args.size())); hash.setText(args.hash().toString()); downloadButton.setOnAction(_ -> fileClient.download(args.name(), args.hash(), args.size(), args.locationIdentifier()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(name))) .doOnError(UiUtils::webAlertError) .subscribe()); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileDownloadViewController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.rest.file.FileProgress; import io.xeres.common.util.ExecutorUtils; import io.xeres.common.util.OsUtils; import io.xeres.ui.client.FileClient; import io.xeres.ui.client.SettingsClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.TabActivation; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.MenuItem; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.cell.ProgressBarTableCell; import javafx.scene.control.cell.PropertyValueFactory; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignF; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.nio.file.Paths; import java.util.ResourceBundle; import java.util.concurrent.ScheduledExecutorService; import static io.xeres.ui.controller.file.FileProgressDisplay.State.*; import static javafx.scene.control.Alert.AlertType.ERROR; @Component @FxmlView(value = "/view/file/download.fxml") public class FileDownloadViewController implements Controller, TabActivation { private static final Logger log = LoggerFactory.getLogger(FileDownloadViewController.class); private static final int UPDATE_IN_SECONDS = 2; private static final String REMOVE_MENU_ID = "remove"; private static final String OPEN_MENU_ID = "open"; private static final String SHOW_IN_FOLDER_MENU_ID = "showInFolder"; private final FileClient fileClient; private final SettingsClient settingsClient; private final ResourceBundle bundle; @FXML private TableView downloadTableView; @FXML private TableColumn tableName; @FXML private TableColumn tableState; @FXML private TableColumn tableProgress; @FXML private TableColumn tableTotalSize; @FXML private TableColumn tableHash; private ScheduledExecutorService executorService; private boolean wasRunning; public FileDownloadViewController(FileClient fileClient, SettingsClient settingsClient, ResourceBundle bundle) { this.fileClient = fileClient; this.settingsClient = settingsClient; this.bundle = bundle; } @Override public void initialize() { createContextMenu(); tableName.setCellValueFactory(new PropertyValueFactory<>("name")); tableState.setCellValueFactory(new PropertyValueFactory<>("state")); tableProgress.setCellFactory(ProgressBarTableCell.forTableColumn()); tableProgress.setCellValueFactory(new PropertyValueFactory<>("progress")); tableTotalSize.setCellFactory(_ -> new FileProgressSizeCell()); tableTotalSize.setCellValueFactory(new PropertyValueFactory<>("totalSize")); tableHash.setCellValueFactory(new PropertyValueFactory<>("hash")); } private void start() { executorService = ExecutorUtils.createFixedRateExecutor(() -> fileClient.getDownloads().collectMap(FileProgress::hash) .doOnSuccess(incomingProgresses -> Platform.runLater(() -> { assert incomingProgresses != null; var it = downloadTableView.getItems().iterator(); while (it.hasNext()) { var currentProgress = it.next(); var incomingProgress = incomingProgresses.get(currentProgress.getHash()); if (incomingProgress != null) { var newProgress = (double) incomingProgress.currentSize() / incomingProgress.totalSize(); var newState = getState(currentProgress, incomingProgress, newProgress); if (currentProgress.getState() != REMOVING) { currentProgress.setState(newState); } currentProgress.setProgress(newProgress); incomingProgresses.remove(incomingProgress.hash()); } else { it.remove(); } } incomingProgresses.forEach((_, fileProgress) -> downloadTableView.getItems().add(new FileProgressDisplay(fileProgress.id(), fileProgress.name(), fileProgress.completed() ? DONE : SEARCHING, 0.0, fileProgress.totalSize(), fileProgress.hash()))); })) .subscribe(), 1, UPDATE_IN_SECONDS); } private static FileProgressDisplay.State getState(FileProgressDisplay currentProgress, FileProgress incomingProgress, double newProgress) { if (incomingProgress.completed()) { return DONE; } if (currentProgress.getProgress() != 0.0 && newProgress != currentProgress.getProgress()) // The first check is to not show transferring when resuming after a restart { return TRANSFERRING; } return SEARCHING; } public void stop() { ExecutorUtils.cleanupExecutor(executorService); } public void resume() { if (wasRunning) { start(); } } @Override public void activate() { start(); wasRunning = true; } @Override public void deactivate() { stop(); wasRunning = false; } private void createContextMenu() { var removeItem = new MenuItem(bundle.getString("remove")); removeItem.setId(REMOVE_MENU_ID); removeItem.setGraphic(new FontIcon(MaterialDesignF.FILE_REMOVE)); removeItem.setOnAction(event -> { if (event.getSource() instanceof FileProgressDisplay fileProgressDisplay) { log.debug("Removing download of file {}", fileProgressDisplay.getName()); fileClient.removeDownload(fileProgressDisplay.getId()) .doOnSuccess(_ -> fileProgressDisplay.setState(REMOVING)) .doOnError(UiUtils::webAlertError) .subscribe(); } }); var openItem = new MenuItem(bundle.getString("open")); openItem.setId(OPEN_MENU_ID); openItem.setGraphic(new FontIcon(MaterialDesignF.FILE_EYE)); openItem.setOnAction(event -> { if (event.getSource() instanceof FileProgressDisplay fileProgressDisplay) { log.debug("Opening file {}", fileProgressDisplay.getName()); settingsClient.getSettings() .doOnSuccess(settings -> { assert settings != null; var file = Paths.get(settings.getIncomingDirectory(), fileProgressDisplay.getName()).toFile(); try { OsUtils.shellOpen(file); } catch (IllegalStateException e) { Platform.runLater(() -> { UiUtils.showAlert(ERROR, bundle.getString("download-view.open-error") + " " + e.getMessage() + "."); log.error("Failed to open the file", e); }); } }) .doOnError(UiUtils::webAlertError) .subscribe(); } }); var showInExplorerItem = new MenuItem(bundle.getString("download-view.show-in-folder")); showInExplorerItem.setId(SHOW_IN_FOLDER_MENU_ID); showInExplorerItem.setGraphic(new FontIcon(MaterialDesignF.FOLDER_OPEN)); showInExplorerItem.setOnAction(event -> { if (event.getSource() instanceof FileProgressDisplay fileProgressDisplay) { log.debug("Showing file {} in folder", fileProgressDisplay.getName()); settingsClient.getSettings() .doOnSuccess(settings -> { assert settings != null; var file = Paths.get(settings.getIncomingDirectory(), fileProgressDisplay.getName()).toFile(); try { OsUtils.showInFolder(file); } catch (IllegalStateException e) { Platform.runLater(() -> { UiUtils.showAlert(ERROR, bundle.getString("download-view.show-error") + " " + e.getMessage() + "."); log.error("Failed to show the file in folder", e); }); } }) .doOnError(UiUtils::webAlertError) .subscribe(); } }); var xContextMenu = new XContextMenu(openItem, showInExplorerItem, new SeparatorMenuItem(), removeItem); xContextMenu.addToNode(downloadTableView); xContextMenu.setOnShowing((contextMenu, file) -> { if (file == null) { return false; } // Disable "Remove" if the file is already being removed contextMenu.getItems().stream() .filter(menuItem -> REMOVE_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(file.getState() == REMOVING)); // Disable Open and Show in Folder unless the file is fully downloaded contextMenu.getItems().stream() .filter(menuItem -> SHOW_IN_FOLDER_MENU_ID.equals(menuItem.getId()) || OPEN_MENU_ID.equals(menuItem.getId())) .forEach(menuItem -> menuItem.setDisable(file.getState() != DONE)); return true; }); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileMainController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.TabActivation; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.support.uri.SearchUri; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.TabPane; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; @Component @FxmlView(value = "/view/file/main.fxml") public class FileMainController implements Controller { @FXML private TabPane tabPane; @FXML private FileSearchViewController fileSearchViewController; @FXML private FileDownloadViewController fileDownloadViewController; @FXML private FileUploadViewController fileUploadViewController; @FXML private FileTrendViewController fileTrendViewController; @Override public void initialize() { tabPane.getSelectionModel().selectedItemProperty() .addListener((_, oldValue, newValue) -> Platform.runLater(() -> { idToController(oldValue.getId()).deactivate(); idToController(newValue.getId()).activate(); })); } @EventListener public void handleOpenUriEvents(OpenUriEvent event) { if (event.uri() instanceof SearchUri _) { tabPane.getSelectionModel().select(0); } } private TabActivation idToController(String id) { return switch (id) { case "search" -> fileSearchViewController; case "downloads" -> fileDownloadViewController; case "uploads" -> fileUploadViewController; case "trends" -> fileTrendViewController; default -> throw new IllegalStateException("Unexpected value: " + id); }; } public void resume() { fileDownloadViewController.resume(); fileUploadViewController.resume(); } public void suspend() { fileDownloadViewController.stop(); fileUploadViewController.stop(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileProgressDisplay.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.i18n.I18nEnum; import io.xeres.common.i18n.I18nUtils; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleLongProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import java.util.ResourceBundle; // Public modifier needed by JavaFX public class FileProgressDisplay { public enum State implements I18nEnum { SEARCHING, TRANSFERRING, REMOVING, DONE; private final ResourceBundle bundle = I18nUtils.getBundle(); @Override public String toString() { return bundle.getString(getMessageKey(this)); } } private final long id; private final SimpleStringProperty name; private final SimpleObjectProperty state; private final SimpleDoubleProperty progress; private final SimpleLongProperty totalSize; private final SimpleStringProperty hash; public FileProgressDisplay(long id, String name, State state, double progress, long totalSize, String hash) { this.id = id; this.name = new SimpleStringProperty(name); this.state = new SimpleObjectProperty<>(state); this.progress = new SimpleDoubleProperty(progress); this.totalSize = new SimpleLongProperty(totalSize); this.hash = new SimpleStringProperty(hash); } public String getName() { return name.get(); } @SuppressWarnings("unused") public SimpleStringProperty nameProperty() { return name; } public void setName(String name) { this.name.set(name); } public State getState() { return state.get(); } @SuppressWarnings("unused") public SimpleObjectProperty stateProperty() { return state; } public void setState(State state) { this.state.set(state); } public double getProgress() { return progress.get(); } @SuppressWarnings("unused") public SimpleDoubleProperty progressProperty() { return progress; } public void setProgress(double progress) { this.progress.set(progress); } @SuppressWarnings("unused") public long getTotalSize() { return totalSize.get(); } @SuppressWarnings("unused") public SimpleLongProperty totalSizeProperty() { return totalSize; } @SuppressWarnings("unused") public void setTotalSize(long totalSize) { this.totalSize.set(totalSize); } public String getHash() { return hash.get(); } @SuppressWarnings("unused") public SimpleStringProperty hashProperty() { return hash; } public void setHash(String hash) { this.hash.set(hash); } public long getId() { return id; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileProgressSizeCell.java ================================================ package io.xeres.ui.controller.file; import io.xeres.common.util.ByteUnitUtils; import javafx.scene.control.TableCell; class FileProgressSizeCell extends TableCell { @Override protected void updateItem(Long value, boolean empty) { super.updateItem(value, empty); setText(empty ? null : ByteUnitUtils.fromBytes(value)); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileResult.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.file.FileType; import java.util.Objects; public record FileResult( String name, long size, FileType type, String hash ) { @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } var file = (FileResult) o; return Objects.equals(hash, file.hash); } @Override public int hashCode() { return Objects.hashCode(hash); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileResultNameCell.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.file.FileType; import javafx.scene.Node; import javafx.scene.control.TableCell; import java.util.function.Function; class FileResultNameCell extends TableCell { private final Function converter; public FileResultNameCell(Function converter) { super(); this.converter = converter; } @Override protected void updateItem(FileResult item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : item.name()); setGraphic(empty ? null : converter.apply(item.type())); setGraphicTextGap(4.0); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileResultSizeCell.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.util.ByteUnitUtils; import javafx.scene.control.TableCell; class FileResultSizeCell extends TableCell { @Override protected void updateItem(Long value, boolean empty) { super.updateItem(value, empty); setText(empty ? null : ByteUnitUtils.fromBytes(value)); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileResultView.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.file.FileType; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.id.Sha1Sum; import io.xeres.ui.client.FileClient; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.uri.FileUri; import javafx.application.Platform; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.concurrent.Task; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.StackPane; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignF; import org.kordamp.ikonli.materialdesign2.MaterialDesignL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ResourceBundle; public class FileResultView extends Tab { private static final Logger log = LoggerFactory.getLogger(FileResultView.class); private static final String DOWNLOAD_MENU_ID = "download"; private static final String COPY_LINK_MENU_ID = "copyLink"; public static final int FILE_ICON_SIZE = 24; private final FileClient fileClient; private final ResourceBundle bundle; private final int searchId; @FXML private TableView filesTableView; @FXML private TableColumn tableName; @FXML private TableColumn tableSize; @FXML private TableColumn tableType; @FXML private TableColumn tableHash; @FXML private ProgressBar progressBar; public FileResultView(FileClient fileClient, String text, int searchId) { super(text); this.fileClient = fileClient; this.searchId = searchId; bundle = I18nUtils.getBundle(); var loader = new FXMLLoader(FileResultView.class.getResource("/view/custom/file_results_view.fxml"), bundle); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } @FXML private void initialize() { createFilesTableViewContextMenu(); tableName.setCellFactory(_ -> new FileResultNameCell(this::getGraphicForType)); tableName.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue())); tableSize.setCellFactory(_ -> new FileResultSizeCell()); tableSize.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().size())); tableType.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().type().toString())); tableHash.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().hash())); showProgress(); } public int getSearchId() { return searchId; } public void addResult(String name, long size, String hash) { var file = new FileResult(name, size, FileType.getTypeByExtension(name), hash); if (!filesTableView.getItems().contains(file)) { filesTableView.getItems().add(file); } } private Node getGraphicForType(FileType type) { var pane = new StackPane(new FontIcon(getIconCodeForType(type))); pane.setPrefWidth(FILE_ICON_SIZE); pane.setPrefHeight(FILE_ICON_SIZE); pane.setAlignment(Pos.CENTER); return pane; } private static String getIconCodeForType(FileType type) { return switch (type) { case AUDIO -> "mdi2f-file-music"; case VIDEO -> "mdi2f-file-video"; case PICTURE -> "mdi2f-file-image"; case DOCUMENT -> "mdi2f-file-document"; case ARCHIVE -> "mdi2f-file-cabinet"; case PROGRAM -> "mdi2a-application"; case COLLECTION -> "mdi2l-layers"; case SUBTITLES -> "mdi2c-closed-caption"; case DIRECTORY, ANY -> "mdi2f-file"; }; } private void createFilesTableViewContextMenu() { var downloadItem = new MenuItem(bundle.getString("download")); downloadItem.setId(DOWNLOAD_MENU_ID); downloadItem.setGraphic(new FontIcon(MaterialDesignF.FILE_DOWNLOAD)); downloadItem.setOnAction(event -> { if (event.getSource() instanceof FileResult file) { log.debug("Downloading file {}", file.name()); fileClient.download(file.name(), Sha1Sum.fromString(file.hash()), file.size(), null) .subscribe(); } }); var copyLinkItem = new MenuItem(bundle.getString("copy-link")); copyLinkItem.setId(COPY_LINK_MENU_ID); copyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); copyLinkItem.setOnAction(event -> { if (event.getSource() instanceof FileResult file) { var fileUri = new FileUri(file.name(), file.size(), Sha1Sum.fromString(file.hash())); ClipboardUtils.copyTextToClipboard(fileUri.toUriString()); } }); var xContextMenu = new XContextMenu(downloadItem, new SeparatorMenuItem(), copyLinkItem); xContextMenu.addToNode(filesTableView); xContextMenu.setOnShowing((_, file) -> file != null); } private void showProgress() { var task = new Task() { @Override protected Void call() throws Exception { for (var d = 0.0; d <= 1.0; d += 0.001) { Thread.sleep(20); double finalD = d; Platform.runLater(() -> progressBar.setProgress(finalD)); } Platform.runLater(() -> { progressBar.setProgress(1.0); filesTableView.setPlaceholder(new Label(bundle.getString("no-results"))); }); return null; } }; Thread.ofVirtual().name("Search Progress Indicator Task").start(task); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileSearchViewController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.ui.client.FileClient; import io.xeres.ui.client.NotificationClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.TabActivation; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.uri.SearchUri; import io.xeres.ui.support.util.TextInputControlUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.MenuItem; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TextField; import javafx.scene.input.KeyCode; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.Disposable; import java.util.ResourceBundle; @Component @FxmlView(value = "/view/file/search.fxml") public class FileSearchViewController implements Controller, TabActivation { private static final Logger log = LoggerFactory.getLogger(FileSearchViewController.class); private static final String COPY_LINK_MENU_ID = "copyLink"; private final FileClient fileClient; private final ResourceBundle bundle; @FXML private TextField search; @FXML private TabPane resultTabPane; private final NotificationClient notificationClient; private Disposable notificationDisposable; public FileSearchViewController(FileClient fileClient, NotificationClient notificationClient, ResourceBundle bundle) { this.fileClient = fileClient; this.notificationClient = notificationClient; this.bundle = bundle; } @Override public void initialize() { TextInputControlUtils.addEnhancedInputContextMenu(search, null, null); search.setOnKeyPressed(event -> { if (event.getCode() == KeyCode.ENTER) { var searchText = search.getText(); log.debug("Searching for: {}", searchText); search.clear(); fileClient.search(searchText) .doOnSuccess(fileSearchResponse -> Platform.runLater(() -> { assert fileSearchResponse != null; var fileResultView = new FileResultView(fileClient, searchText, fileSearchResponse.id()); resultTabPane.getTabs().add(fileResultView); })) .subscribe(); } }); createContextMenu(); setupFileSearchNotifications(); } private void addToResultTab(int requestId, String name, long size, String hash) { resultTabPane.getTabs().stream() .filter(tab -> ((FileResultView) tab).getSearchId() == requestId) .findFirst() .ifPresent(tab -> ((FileResultView) tab).addResult(name, size, hash)); } private void setupFileSearchNotifications() { notificationDisposable = notificationClient.getFileSearchNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { if (sse.data() != null && sse.data().name() != null) { addToResultTab(sse.data().requestId(), sse.data().name(), sse.data().size(), sse.data().hash()); } })) .subscribe(); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (notificationDisposable != null && !notificationDisposable.isDisposed()) { notificationDisposable.dispose(); } } @EventListener public void handleOpenUriEvents(OpenUriEvent event) { if (event.uri() instanceof SearchUri(String keywords)) { search.setText(keywords); } } @Override public void activate() { } @Override public void deactivate() { } private void createContextMenu() { var copyLinkItem = new MenuItem(bundle.getString("copy-link")); copyLinkItem.setId(COPY_LINK_MENU_ID); copyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); copyLinkItem.setOnAction(event -> { var fileResultView = (FileResultView) event.getSource(); var searchUri = new SearchUri(fileResultView.getText()); ClipboardUtils.copyTextToClipboard(searchUri.toUriString()); }); var xContextMenu = new XContextMenu(copyLinkItem); xContextMenu.addToNode(resultTabPane); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileTrendViewController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.ui.client.NotificationClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.TabActivation; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.Disposable; import java.time.Instant; import java.util.LinkedList; @Component @FxmlView(value = "/view/file/trend.fxml") public class FileTrendViewController implements Controller, TabActivation { private static final String NAME_CONTAINS_ALL = "NAME CONTAINS ALL "; private static final int MAXIMUM_BACKLOG = 300; private static final int MAXIMUM_DUPLICATE_SEARCH = 5; private final NotificationClient notificationClient; private Disposable notificationDisposable; private final ObservableList trendResult = FXCollections.observableList(new LinkedList<>()); @FXML private TableView trendTableView; // XXX: make sure the table is NOT sortable!! @FXML private TableColumn tableFrom; @FXML private TableColumn tableTerms; @FXML private TableColumn tableTime; public FileTrendViewController(NotificationClient notificationClient) { this.notificationClient = notificationClient; } @Override public void initialize() { trendTableView.setItems(trendResult); tableTerms.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().keywords())); tableFrom.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().senderName())); tableTime.setCellFactory(param -> new TimeCell()); tableTime.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().when())); setupFileTrendNotifications(); } private void setupFileTrendNotifications() { notificationDisposable = notificationClient.getFileTrendNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { assert sse.data() != null; var keywords = sse.data().keywords(); if (keywords.startsWith(NAME_CONTAINS_ALL)) { keywords = keywords.substring(NAME_CONTAINS_ALL.length()); } // Don't add if it's already in the first few // entries. This avoids duplicates. if (isAlreadyTrending(keywords)) { return; } trendResult.addFirst(new TrendResult(keywords, sse.data().senderName(), Instant.now())); if (trendTableView.getItems().size() > MAXIMUM_BACKLOG) { trendTableView.getItems().removeLast(); } })) .subscribe(); } private boolean isAlreadyTrending(String keywords) { return trendResult.stream() .limit(MAXIMUM_DUPLICATE_SEARCH) .anyMatch(result -> result.keywords().equals(keywords)); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (notificationDisposable != null && !notificationDisposable.isDisposed()) { notificationDisposable.dispose(); } } @Override public void activate() { } @Override public void deactivate() { } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/FileUploadViewController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import io.xeres.common.rest.file.FileProgress; import io.xeres.common.util.ExecutorUtils; import io.xeres.ui.client.FileClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.TabActivation; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.cell.PropertyValueFactory; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.concurrent.ScheduledExecutorService; import static io.xeres.ui.controller.file.FileProgressDisplay.State.TRANSFERRING; @Component @FxmlView(value = "/view/file/upload.fxml") public class FileUploadViewController implements Controller, TabActivation { private static final int UPDATE_IN_SECONDS = 6; // Longer time to avoid flickering when switching between chunk requests private final FileClient fileClient; @FXML private TableView uploadTableView; @FXML private TableColumn tableName; @FXML private TableColumn tableTotalSize; @FXML private TableColumn tableHash; private ScheduledExecutorService executorService; private boolean wasRunning; public FileUploadViewController(FileClient fileClient) { this.fileClient = fileClient; } @Override public void initialize() { tableName.setCellValueFactory(new PropertyValueFactory<>("name")); tableTotalSize.setCellFactory(_ -> new FileProgressSizeCell()); tableTotalSize.setCellValueFactory(new PropertyValueFactory<>("totalSize")); tableHash.setCellValueFactory(new PropertyValueFactory<>("hash")); } private void start() { executorService = ExecutorUtils.createFixedRateExecutor(() -> fileClient.getUploads().collectMap(FileProgress::hash) .doOnSuccess(incomingProgresses -> Platform.runLater(() -> { assert incomingProgresses != null; var it = uploadTableView.getItems().iterator(); while (it.hasNext()) { var currentProgress = it.next(); var incomingProgress = incomingProgresses.get(currentProgress.getHash()); if (incomingProgress != null) { incomingProgresses.remove(incomingProgress.hash()); } else { it.remove(); } } incomingProgresses.forEach((_, fileProgress) -> uploadTableView.getItems().add(new FileProgressDisplay(fileProgress.id(), fileProgress.name(), TRANSFERRING, 0.0, fileProgress.totalSize(), fileProgress.hash()))); })) .subscribe(), 0, UPDATE_IN_SECONDS); } public void stop() { ExecutorUtils.cleanupExecutor(executorService); } public void resume() { if (wasRunning) { start(); } } @Override public void activate() { start(); wasRunning = true; } @Override public void deactivate() { stop(); wasRunning = false; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/TimeCell.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import javafx.scene.control.TableCell; import java.time.Instant; import static io.xeres.ui.support.util.DateUtils.TIME_PRECISE_FORMAT; class TimeCell extends TableCell { @Override protected void updateItem(Instant item, boolean empty) { super.updateItem(item, empty); if (empty) { setText(null); } else { setText(TIME_PRECISE_FORMAT.format(item)); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/file/TrendResult.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.file; import java.time.Instant; record TrendResult(String keywords, String senderName, Instant when) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/DateCell.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.ui.model.forum.ForumMessage; import javafx.scene.control.TreeTableCell; import java.time.Instant; import static io.xeres.ui.support.util.DateUtils.DATE_TIME_FORMAT; class DateCell extends TreeTableCell { public DateCell() { super(); } @Override protected void updateItem(Instant item, boolean empty) { super.updateItem(item, empty); if (empty) { setText(null); } else { setText(DATE_TIME_FORMAT.format(item)); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/ForumCell.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.model.forum.ForumGroup; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.TreeTableCell; import java.text.MessageFormat; import java.util.ResourceBundle; public class ForumCell extends TreeTableCell { private static final ResourceBundle bundle = I18nUtils.getBundle(); public ForumCell() { super(); TooltipUtils.install(this, () -> { if (getItem().getId() == 0) { return null; } return MessageFormat.format(bundle.getString("gxs-group.tree.info"), getItem().getName(), getItem().getGxsId(), getItem().getVisibleMessageCount(), DateUtils.formatDateTime(getItem().getLastActivity(), bundle.getString("unknown-lc")) ); }, null); } @Override protected void updateItem(ForumGroup item, boolean empty) { super.updateItem(item, empty); if (empty) { setText(null); clearStyle(); } else { setText(item.getName()); if (item.hasNewMessages()) { setStyle("-fx-font-weight: bold;"); } else { clearStyle(); } } } private void clearStyle() { setStyle(""); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/ForumCellAuthor.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.custom.asyncimage.AsyncImageView; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.model.forum.ForumMessage; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.control.TreeTableCell; import javafx.scene.image.ImageView; import java.text.MessageFormat; import java.util.ResourceBundle; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; class ForumCellAuthor extends TreeTableCell { private static final int AUTHOR_WIDTH = 24; private static final int AUTHOR_HEIGHT = 24; private final GeneralClient generalClient; private final ImageCache imageCache; private static final ResourceBundle bundle = I18nUtils.getBundle(); public ForumCellAuthor(GeneralClient generalClient, ImageCache imageCache) { super(); this.generalClient = generalClient; this.imageCache = imageCache; TooltipUtils.install(this, () -> MessageFormat.format(bundle.getString("chat.room.user-info"), super.getItem().getAuthorName(), super.getItem().getAuthorGxsId()), () -> new ImageView(((ImageView) super.getGraphic()).getImage())); } @Override protected void updateItem(ForumMessage item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : getAuthorName(item)); setGraphic(empty ? null : updateAuthor((AsyncImageView) getGraphic(), item)); } private static String getAuthorName(ForumMessage item) { return item.getAuthorName() != null ? item.getAuthorName() : item.getGxsId().toString(); } private AsyncImageView updateAuthor(AsyncImageView asyncImageView, ForumMessage message) { if (asyncImageView == null) { asyncImageView = new AsyncImageView( url -> generalClient.getImage(url).block(), imageCache); asyncImageView.setFitWidth(AUTHOR_WIDTH); asyncImageView.setFitHeight(AUTHOR_HEIGHT); } asyncImageView.setUrl(getIdentityImageUrl(message)); return asyncImageView; } public static String getIdentityImageUrl(ForumMessage message) { if (message.getAuthorGxsId() != null) { return RemoteUtils.getControlUrl() + IDENTITIES_PATH + "/image?gxsId=" + message.getAuthorGxsId() + "&find=true"; } return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/ForumEditorWindowController.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.common.rest.forum.ForumPostRequest; import io.xeres.ui.client.ForumClient; import io.xeres.ui.client.LocationClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.EditorView; import io.xeres.ui.model.forum.ForumMessage; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.ProgressBar; import javafx.scene.control.TextField; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.ResourceBundle; import static org.apache.commons.lang3.StringUtils.isBlank; @Component @FxmlView(value = "/view/forum/forum_editor_view.fxml") public class ForumEditorWindowController implements WindowController { @FXML private TextField forumName; @FXML private TextField title; @FXML private EditorView editorView; @FXML private ProgressBar progressBar; @FXML private Button send; private ForumPostRequest forumPostRequest; private final ForumClient forumClient; private final LocationClient locationClient; private final MarkdownService markdownService; private final ResourceBundle bundle; public ForumEditorWindowController(ForumClient forumClient, LocationClient locationClient, MarkdownService markdownService, ResourceBundle bundle) { this.forumClient = forumClient; this.locationClient = locationClient; this.markdownService = markdownService; this.bundle = bundle; } @Override public void initialize() { Platform.runLater(() -> title.requestFocus()); editorView.lengthProperty.addListener((_, _, newValue) -> checkSendable((Integer) newValue)); editorView.setInputContextMenu(locationClient); editorView.setMarkdownService(markdownService); title.setOnKeyTyped(_ -> checkSendable(editorView.lengthProperty.getValue())); send.setOnAction(_ -> postMessage()); } @Override public void onShown() { var userData = UiUtils.getUserData(title); if (userData == null) { throw new IllegalArgumentException("Missing PostRequest"); } forumPostRequest = (ForumPostRequest) userData; forumClient.getForumGroupById(forumPostRequest.forumId()) .doOnSuccess(forumGroup -> Platform.runLater(() -> { assert forumGroup != null; forumName.setText(forumGroup.getName()); })) .subscribe(); if (forumPostRequest.messageId() != 0L) { // We're editing our message forumClient.getForumMessage(forumPostRequest.messageId()) .doOnSuccess(forumMessage -> Platform.runLater(() -> { assert forumMessage != null; title.setText(forumMessage.getName()); editorView.setText(forumMessage.getContent()); })) .subscribe(); send.setText(bundle.getString("update")); } else if (forumPostRequest.replyToId() != 0L) { // We're writing a new message and replying title.setDisable(true); forumClient.getForumMessage(forumPostRequest.replyToId()) .doOnSuccess(forumMessage -> Platform.runLater(() -> { assert forumMessage != null; addReply(forumMessage); })) .subscribe(); } // Prevent the message from being discarded by mistake UiUtils.getWindow(send).setOnCloseRequest(event -> { if (editorView.isModified()) { UiUtils.showAlertConfirm(bundle.getString("forum.editor.cancel"), () -> UiUtils.getWindow(send).hide()); event.consume(); } }); } private void checkSendable(int editorLength) { send.setDisable(isBlank(title.getText()) || editorLength == 0); } private void addReply(ForumMessage forumMessage) { title.setText((forumMessage.getParentId() == 0L ? "Re: " : "") + forumMessage.getName()); editorView.setReply(forumMessage.getContent()); } private void setWaiting(boolean waiting) { title.setDisable(waiting); editorView.setDisable(waiting); send.setDisable(waiting); UiUtils.setPresent(progressBar, waiting); } private void postMessage() { setWaiting(true); forumClient.createForumMessage(forumPostRequest.forumId(), title.getText(), editorView.getText(), forumPostRequest.replyToId(), forumPostRequest.messageId()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(forumName))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/ForumGroupWindowController.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.ui.client.ForumClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.ProgressBar; import javafx.scene.control.TextField; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.lang3.Strings; import org.springframework.stereotype.Component; import java.util.ResourceBundle; @Component @FxmlView(value = "/view/forum/forum_group_view.fxml") public class ForumGroupWindowController implements WindowController { @FXML private Button createOrUpdateButton; @FXML private Button cancelButton; @FXML private TextField forumName; @FXML private TextField forumDescription; @FXML private ProgressBar progressBar; private final ForumClient forumClient; private final ResourceBundle bundle; private long forumId; private String initialName; private String initialDescription; public ForumGroupWindowController(ForumClient forumClient, ResourceBundle bundle) { this.forumClient = forumClient; this.bundle = bundle; } @Override public void initialize() { forumName.textProperty().addListener(_ -> checkCreatable()); forumDescription.textProperty().addListener(_ -> checkCreatable()); cancelButton.setOnAction(UiUtils::closeWindow); } @Override public void onShown() { var userData = UiUtils.getUserData(forumName); if (userData != null) { forumId = (long) userData; } if (forumId != 0L) { forumClient.getForumGroupById(forumId) .doOnSuccess(forumGroup -> Platform.runLater(() -> { assert forumGroup != null; forumName.setText(forumGroup.getName()); forumDescription.setText(forumGroup.getDescription()); initialName = forumName.getText(); initialDescription = forumDescription.getText(); createOrUpdateButton.setDisable(true); })) .subscribe(); createOrUpdateButton.setText(bundle.getString("update")); createOrUpdateButton.setOnAction(_ -> { setWaiting(true); forumClient.updateForumGroup(forumId, forumName.getText(), forumDescription.getText()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(forumName))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); }); } else { createOrUpdateButton.setOnAction(_ -> { setWaiting(true); forumClient.createForumGroup(forumName.getText(), forumDescription.getText()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(forumName))) .doOnError(UiUtils::webAlertError) .doFinally(_ -> setWaiting(false)) .subscribe(); }); } } private void setWaiting(boolean waiting) { forumName.setDisable(waiting); forumDescription.setDisable(waiting); createOrUpdateButton.setDisable(waiting); cancelButton.setDisable(waiting); UiUtils.setPresent(progressBar, waiting); } private void checkCreatable() { createOrUpdateButton.setDisable(forumId == 0L && forumName.getText().isBlank() || (forumId == 0L && forumDescription.getText().isBlank()) || ( Strings.CS.equals(initialName, forumName.getText()) && Strings.CS.equals(initialDescription, forumDescription.getText()) ) ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/ForumMessageCell.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.ui.model.forum.ForumMessage; import javafx.scene.control.TreeTableRow; public class ForumMessageCell extends TreeTableRow { public ForumMessageCell() { super(); } @Override protected void updateItem(ForumMessage item, boolean empty) { super.updateItem(item, empty); if (empty) { clearStyle(); } else { if (item.isRead()) { clearStyle(); } else { setStyle("-fx-font-weight: bold"); } } } private void clearStyle() { setStyle(""); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.common.rest.forum.ForumPostRequest; import io.xeres.common.rest.notification.forum.AddOrUpdateForumGroups; import io.xeres.common.rest.notification.forum.AddOrUpdateForumMessages; import io.xeres.common.rest.notification.forum.SetForumGroupMessagesReadState; import io.xeres.common.rest.notification.forum.SetForumMessageReadState; import io.xeres.ui.client.ForumClient; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.client.IdentityClient; import io.xeres.ui.client.NotificationClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.controller.common.GxsGroupTreeTableAction; import io.xeres.ui.controller.common.GxsGroupTreeTableView; import io.xeres.ui.custom.ProgressPane; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.event.OpenUriEvent; import io.xeres.ui.event.UnreadEvent; import io.xeres.ui.model.forum.ForumGroup; import io.xeres.ui.model.forum.ForumMapper; import io.xeres.ui.model.forum.ForumMessage; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contentline.Content; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.loader.OnDemandLoader; import io.xeres.ui.support.loader.OnDemandLoaderAction; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.markdown.MarkdownService.Rendering; import io.xeres.ui.support.unread.UnreadService; import io.xeres.ui.support.uri.ForumUri; import io.xeres.ui.support.uri.IdentityUri; import io.xeres.ui.support.uri.UriService; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.TextFlowDragSelection; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.control.cell.TreeItemPropertyValueFactory; import javafx.scene.layout.GridPane; import javafx.scene.text.TextFlow; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.Disposable; import reactor.core.scheduler.Schedulers; import java.time.Instant; import java.util.*; import static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID; import static io.xeres.ui.support.preference.PreferenceUtils.FORUMS; import static io.xeres.ui.support.util.DateUtils.DATE_TIME_PRECISE_FORMAT; import static javafx.scene.control.Alert.AlertType.WARNING; import static javafx.scene.control.TreeTableColumn.SortType.DESCENDING; @Component @FxmlView(value = "/view/forum/forum_view.fxml") public class ForumViewController implements Controller, GxsGroupTreeTableAction, OnDemandLoaderAction { private static final Logger log = LoggerFactory.getLogger(ForumViewController.class); private static final String EDIT_FORUM_MESSAGE_MENU_ID = "editForumMessage"; private static final String COPY_LINK_MENU_ID = "copyLink"; @FXML private GxsGroupTreeTableView forumTree; @FXML private SplitPane splitPaneVertical; @FXML private SplitPane splitPaneHorizontal; @FXML private TreeTableView forumMessagesTreeTableView; @FXML private TreeTableColumn treeTableSubject; @FXML private TreeTableColumn treeTableAuthor; @FXML private TreeTableColumn treeTableDate; @FXML private ProgressPane forumMessagesProgress; @FXML private ScrollPane messagePane; @FXML private TextFlow messageContent; @FXML public Button createForum; @FXML private Button newThread; @FXML private GridPane messageHeader; @FXML private Label messageAuthor; @FXML private Label messageDate; @FXML private Label messageSubject; @FXML private ChoiceBox versionChoiceBox; private final ObservableList messages = FXCollections.observableArrayList(); private OnDemandLoader onDemandLoader; private final ObservableList versions = FXCollections.observableArrayList(); private int versionsFetcherRun; private final ResourceBundle bundle; private final ForumClient forumClient; private final NotificationClient notificationClient; private final WindowManager windowManager; private final MarkdownService markdownService; private final UriService uriService; private final GeneralClient generalClient; private final ImageCache imageCacheService; private final UnreadService unreadService; private final IdentityClient identityClient; private ForumMessage selectedForumMessage; private Disposable notificationDisposable; private TreeItem forumMessagesRoot; private MsgId toSelectMsgId; private UrlToOpen urlToOpen; private GxsId ownIdentityGxsId; private final ChangeListener changeVersionListener = (_, _, messageVersion) -> { if (messageVersion != null) { changeSelectedForumMessageVersion(messageVersion.id()); } }; public ForumViewController(ForumClient forumClient, ResourceBundle bundle, NotificationClient notificationClient, WindowManager windowManager, MarkdownService markdownService, UriService uriService, GeneralClient generalClient, ImageCache imageCacheService, UnreadService unreadService, IdentityClient identityClient) { this.forumClient = forumClient; this.bundle = bundle; this.notificationClient = notificationClient; this.windowManager = windowManager; this.markdownService = markdownService; this.uriService = uriService; this.generalClient = generalClient; this.imageCacheService = imageCacheService; this.unreadService = unreadService; this.identityClient = identityClient; } @Override public void initialize() { log.debug("Trying to get forums list..."); forumTree.initialize(FORUMS, forumClient, ForumGroup::new, ForumCell::new, this); forumTree.unreadProperty().addListener((_, _, newValue) -> unreadService.sendUnreadEvent(UnreadEvent.Element.FORUM, newValue)); forumMessagesTreeTableView.setRowFactory(_ -> new ForumMessageCell()); createForumMessageTableViewContextMenu(); treeTableSubject.setCellValueFactory(new TreeItemPropertyValueFactory<>("name")); treeTableAuthor.setCellFactory(_ -> new ForumCellAuthor(generalClient, imageCacheService)); treeTableAuthor.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue())); treeTableDate.setCellFactory(_ -> new DateCell()); treeTableDate.setCellValueFactory(new TreeItemPropertyValueFactory<>("published")); forumMessagesRoot = new TreeItem<>(new ForumMessage()); forumMessagesTreeTableView.setRoot(forumMessagesRoot); forumMessagesTreeTableView.setShowRoot(false); forumMessagesTreeTableView.getSortOrder().add(treeTableDate); treeTableDate.setSortType(DESCENDING); treeTableDate.setSortable(true); forumMessagesTreeTableView.getSelectionModel().selectedItemProperty() .addListener((_, _, newValue) -> changeSelectedForumMessage(newValue != null ? newValue.getValue() : null)); identityClient.findById(OWN_IDENTITY_ID) .doOnSuccess(identity -> Platform.runLater(() -> { assert identity != null; ownIdentityGxsId = identity.getGxsId(); })) .subscribe(); versionChoiceBox.setItems(versions); onDemandLoader = new OnDemandLoader<>(forumMessagesTreeTableView, messages, forumClient, this); createForum.setOnAction(_ -> windowManager.openForumCreation(0L)); newThread.setOnAction(_ -> newForumPost(false)); setupForumNotifications(); TextFlowDragSelection.enableSelection(messageContent, messagePane); } @EventListener public void handleOpenUriEvent(OpenUriEvent event) { if (event.uri() instanceof ForumUri forumUri) { if (!forumTree.openUrl(forumUri.gxsId(), forumUri.msgId())) { UiUtils.showAlert(WARNING, bundle.getString("forum.view.group.not-found")); } } } @Override public void onOpenUrl(GxsId gxsId, MsgId msgId) { if (gxsId.equals(forumTree.getSelectedGroupGxsId())) { selectMessage(msgId); } else { urlToOpen = new UrlToOpen(gxsId, msgId); } } private void setMessageToSelect(MsgId msgId) { if (msgId != null) { toSelectMsgId = msgId; } } private void selectMessageIfNeeded() { if (toSelectMsgId != null) { forumMessagesRoot.getChildren().stream() .filter(forumMessageTreeItem -> forumMessageTreeItem.getValue().getMsgId().equals(toSelectMsgId)) .findFirst() .ifPresent(forumMessageTreeItem -> Platform.runLater(() -> forumMessagesTreeTableView.getSelectionModel().select(forumMessageTreeItem))); } } private void selectMessage(MsgId msgId) { forumMessagesRoot.getChildren().stream() .filter(forumMessageTreeItem -> forumMessageTreeItem.getValue().getMsgId().equals(msgId)) .findFirst() .ifPresentOrElse(forumMessageTreeItem -> Platform.runLater(() -> forumMessagesTreeTableView.getSelectionModel().select(forumMessageTreeItem)), () -> UiUtils.showAlert(WARNING, bundle.getString("forum.view.message.not-found"))); } private void createForumMessageTableViewContextMenu() { var replyItem = new MenuItem(bundle.getString("forum.view.reply")); replyItem.setGraphic(new FontIcon(MaterialDesignR.REPLY)); replyItem.setOnAction(_ -> newForumPost(true)); var markUnreadItem = new MenuItem(bundle.getString("mark-unread")); markUnreadItem.setGraphic(new FontIcon(MaterialDesignE.EMAIL_MARK_AS_UNREAD)); markUnreadItem.setOnAction(_ -> markAsUnread()); var editItem = new MenuItem(bundle.getString("edit")); editItem.setId(EDIT_FORUM_MESSAGE_MENU_ID); editItem.setGraphic(new FontIcon(MaterialDesignS.SQUARE_EDIT_OUTLINE)); editItem.setOnAction(_ -> editForumPost()); var copyLinkItem = new MenuItem(bundle.getString("copy-link")); copyLinkItem.setId(COPY_LINK_MENU_ID); copyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); copyLinkItem.setOnAction(event -> { @SuppressWarnings("unchecked") var forumMessage = ((TreeItem) event.getSource()).getValue(); var forumUri = new ForumUri(forumMessage.getName(), forumMessage.getGxsId(), forumMessage.getMsgId()); ClipboardUtils.copyTextToClipboard(forumUri.toUriString()); }); var xContextMenu = new XContextMenu>(replyItem, markUnreadItem, editItem, new SeparatorMenuItem(), copyLinkItem); xContextMenu.setOnShowing((contextMenu, treeItem) -> { if (treeItem == null) { return false; } contextMenu.getItems().stream() .filter(menuItem -> EDIT_FORUM_MESSAGE_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setVisible(treeItem.getValue().getAuthorGxsId() != null && treeItem.getValue().getAuthorGxsId().equals(ownIdentityGxsId))); return true; }); xContextMenu.addToNode(forumMessagesTreeTableView); } private void newForumPost(boolean replyTo) { var replyToId = 0L; if (selectedForumMessage != null) { replyToId = replyTo ? selectedForumMessage.getId() : 0L; } var postRequest = new ForumPostRequest(forumTree.getSelectedGroupId(), replyToId, 0L); windowManager.openForumEditor(postRequest); } private void markAsUnread() { if (selectedForumMessage == null) // Should not happen { return; } forumClient.setForumMessageReadState(selectedForumMessage.getId(), false) .subscribe(); } private void editForumPost() { if (selectedForumMessage != null) { var postRequest = new ForumPostRequest(forumTree.getSelectedGroupId(), 0L, selectedForumMessage.getId()); windowManager.openForumEditor(postRequest); } } private void setupForumNotifications() { notificationDisposable = notificationClient.getForumNotifications() .doOnError(UiUtils::webAlertError) .doOnNext(sse -> Platform.runLater(() -> { switch (sse.data()) { case AddOrUpdateForumGroups action -> forumTree.addGroups(action.forumGroups().stream() .map(ForumMapper::fromDTO) .toList()); case AddOrUpdateForumMessages action -> addForumMessages(action.forumMessages().stream() .map(ForumMapper::fromDTO) .toList()); case SetForumMessageReadState action -> setMessageReadState(action.groupId(), action.messageId(), action.read()); case SetForumGroupMessagesReadState action -> setGroupMessagesReadState(action.groupId(), action.read()); case null -> throw new IllegalArgumentException("Forum notifications have not been set"); } })) .subscribe(); } private void forumMessagesState(boolean loading) { Platform.runLater(() -> forumMessagesProgress.showProgress(loading)); } // XXX: implement threaded support for the 2 following methods. // if the message has a parentId, find it in the list then add the message to it. // could be slow if the list is big so find tricks to speed it up private List> toTreeItemForumMessages(List forumMessages) { return forumMessages.stream() .map(TreeItem::new) .toList(); } private void changeSelectedForumMessage(ForumMessage forumMessage) { selectedForumMessage = forumMessage; if (forumMessage != null) { forumClient.getForumMessage(forumMessage.getId()) .doOnSuccess(message -> Platform.runLater(() -> { assert message != null; setCommonMessageAttributes(message); messageAuthor.setText(message.getAuthorName()); createAuthorContextMenu(message.getAuthorName(), message.getAuthorGxsId()); setupMessageVersionSelector(message); UiUtils.setPresent(messageHeader); if (!message.isRead()) { forumClient.setForumMessageReadState(message.getId(), true) .subscribe(); } })) .doOnError(UiUtils::webAlertError) .subscribe(); } else { clearMessage(); } } private void changeSelectedForumMessageVersion(long id) { if (selectedForumMessage != null) { forumClient.getForumMessage(id) .doOnSuccess(message -> Platform.runLater(() -> { assert message != null; setCommonMessageAttributes(message); })) .doOnError(UiUtils::webAlertError) .subscribe(); } } private void setCommonMessageAttributes(ForumMessage forumMessage) { messageContent.getChildren().clear(); messagePane.setVvalue(messagePane.getVmin()); // Reset scroll position addMessageContent(forumMessage.getContent()); messageDate.setText(DATE_TIME_PRECISE_FORMAT.format(forumMessage.getPublished())); messageSubject.setText(forumMessage.getName()); } private void setupMessageVersionSelector(ForumMessage forumMessage) { versionChoiceBox.getSelectionModel().selectedItemProperty().removeListener(changeVersionListener); // Prevent listener from kicking in while we fill and select entries versionChoiceBox.setVisible(forumMessage.getOriginalId() != 0L); versions.clear(); versions.addFirst(new MessageVersion(null, forumMessage.getId())); versionChoiceBox.getSelectionModel().selectFirst(); versionChoiceBox.getSelectionModel().selectedItemProperty().addListener(changeVersionListener); if (forumMessage.getOriginalId() != 0L) { fetchVersions(forumMessage.getOriginalId(), ++versionsFetcherRun, 0); } } private void fetchVersions(long id, int run, int recursion) { if (versionsFetcherRun != run) { return; } forumClient.getForumMessage(id) .publishOn(Schedulers.boundedElastic()) .doOnSuccess(message -> Platform.runLater(() -> { assert message != null; versions.add(new MessageVersion(message.getPublished(), message.getId())); if (message.getOriginalId() != 0L && recursion < 16) { fetchVersions(message.getOriginalId(), run, recursion + 1); } })) .subscribe(); } private void clearMessage() { UiUtils.setAbsent(messageHeader); messageAuthor.setText(null); messageAuthor.setContextMenu(null); messageDate.setText(null); messageSubject.setText(null); messageContent.getChildren().clear(); } private void addForumMessages(List forumMessages) { Set forumsToUpdate = new HashSet<>(); for (ForumMessage forumMessage : forumMessages) { onDemandLoader.insertMessage(forumMessage); forumsToUpdate.add(forumMessage.getGxsId()); } forumTree.refreshUnreadCount(forumsToUpdate); refreshMessageList(); } private void setMessageReadState(long groupId, long messageId, boolean read) { // Avoids flickering because of some current Flowless limitation if (selectedForumMessage != null && selectedForumMessage.getId() == messageId && !selectedForumMessage.isRead()) { forumTree.setUnreadCount(groupId, read); selectedForumMessage.setRead(read); forumMessagesTreeTableView.refresh(); } } private void setGroupMessagesReadState(long groupId, boolean read) { onDemandLoader.setGroupMessagesReadState(groupId, read); forumTree.refreshUnreadCount(groupId); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (notificationDisposable != null && !notificationDisposable.isDisposed()) { notificationDisposable.dispose(); } } private void createAuthorContextMenu(String name, GxsId gxsId) { var infoItem = new MenuItem(bundle.getString("chat.room.user-menu")); infoItem.setGraphic(new FontIcon(MaterialDesignA.ACCOUNT_BOX)); infoItem.setOnAction(_ -> uriService.openUri(new IdentityUri(name, gxsId, null))); messageAuthor.setContextMenu(new ContextMenu(infoItem)); } @Override public void onSubscribeToGroup(ForumGroup group) { } @Override public void onUnsubscribeFromGroup(ForumGroup group) { } @Override public void onCopyGroupLink(ForumGroup group) { var forumUri = new ForumUri(group.getName(), group.getGxsId(), null); ClipboardUtils.copyTextToClipboard(forumUri.toUriString()); } @Override public void onSelectSubscribedGroup(ForumGroup group) { showInfo(group); forumMessagesState(true); onDemandLoader.changeSelection(group); newThread.setDisable(false); } private void saveSelection() { var selectedItem = forumMessagesTreeTableView.getSelectionModel().getSelectedItem(); if (selectedItem != null) { setMessageToSelect(selectedItem.getValue().getMsgId()); forumMessagesTreeTableView.getSelectionModel().clearSelection(); } } private void refreshMessageList() { saveSelection(); forumMessagesRoot.getChildren().clear(); forumMessagesRoot.getChildren().addAll(toTreeItemForumMessages(messages)); forumMessagesTreeTableView.sort(); newThread.setDisable(false); selectMessageIfNeeded(); forumMessagesState(false); } @Override public void onSelectUnsubscribedGroup(ForumGroup group) { onDemandLoader.changeSelection(group); newThread.setDisable(true); showInfo(group); } @Override public void onUnselectGroup() { onDemandLoader.changeSelection(null); newThread.setDisable(true); showInfo(null); } @Override public void onEditGroup(ForumGroup group) { windowManager.openForumCreation(group.getId()); } private void showInfo(ForumGroup group) { selectedForumMessage = null; forumMessagesTreeTableView.getSelectionModel().clearSelection(); forumMessagesRoot.getChildren().clear(); clearMessage(); if (group != null && group.isReal()) { addMessageContent(""" ## %s %s %s: %s\\ %s: %s """.formatted( group.getName(), group.getDescription(), bundle.getString("posts-at-remote-nodes"), group.getVisibleMessageCount(), bundle.getString("last-activity"), DateUtils.formatDateTime(group.getLastActivity(), bundle.getString("unknown-lc")) )); } forumMessagesState(false); toSelectMsgId = null; } private void addMessageContent(String input) { messageContent.getChildren().addAll(markdownService.parse(input, EnumSet.noneOf(Rendering.class)).stream() .map(Content::getNode).toList()); } @Override public void onMessagesLoaded(ForumGroup group) { refreshMessageList(); if (urlToOpen != null) { if (group.getGxsId().equals(urlToOpen.gxsId())) { selectMessage(urlToOpen.msgId()); urlToOpen = null; } } } record UrlToOpen(GxsId gxsId, MsgId msgId) { } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/forum/MessageVersion.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.forum; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.support.util.DateUtils; import java.time.Instant; record MessageVersion(Instant instant, Long id) { @Override public String toString() { return instant != null ? DateUtils.DATE_TIME_PRECISE_FORMAT.format(instant) : I18nUtils.getBundle().getString("latest"); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/help/HelpWindowController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.help; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.EditorView; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.uri.ExternalUri; import io.xeres.ui.support.uri.UriService; import io.xeres.ui.support.util.UiUtils; import javafx.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ListView; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.lang3.StringUtils; import org.springframework.core.io.Resource; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Arrays; import java.util.Locale; import java.util.Set; import java.util.stream.Stream; @Component @FxmlView(value = "/view/help/help.fxml") public class HelpWindowController implements WindowController { public static final String INDEX_MD = "00.Index.md"; private static final Set SUPPORTED_LOCALES = Set.of("en", "es", "fr", "ru", "zh"); @FXML private Button back; @FXML private Button forward; @FXML private Button home; @FXML private ListView indexList; @FXML private EditorView editorView; private String language; private final MarkdownService markdownService; private final ResourcePatternResolver resourcePatternResolver; private final UriService uriService; private Navigator navigator; public HelpWindowController(MarkdownService markdownService, ResourcePatternResolver resourcePatternResolver, UriService uriService) { this.markdownService = markdownService; this.resourcePatternResolver = resourcePatternResolver; this.uriService = uriService; } @Override public void initialize() throws IOException { language = Stream.of(Locale.getDefault().getLanguage()) .filter(SUPPORTED_LOCALES::contains) .findFirst() .orElse("en"); var resources = Arrays.stream(resourcePatternResolver.getResources("classpath:help/" + language + "/*.md")) .filter(resource -> !StringUtils.defaultString(resource.getFilename()).equals(INDEX_MD)) .toList(); indexList.getItems().addAll(resources); indexList.setCellFactory(_ -> new IndexCell()); indexList.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> { if (newValue != null) { navigator.navigate(new ExternalUri(newValue.getFilename())); } }); navigator = new Navigator(uri -> { if (uri instanceof ExternalUri externalUri) { var plain = uri.toUriString(); if (navigator.isNavigable(uri)) { var resource = HelpWindowController.class.getResourceAsStream("/help/" + language + "/" + plain); if (resource != null) { editorView.setMarkdown(resource); selectListViewItemIfNeeded(plain); } else { UiUtils.showAlert(Alert.AlertType.ERROR, "Couldn't find resource for link '" + plain + "'"); } } else { uriService.openUri(externalUri); } } else { UiUtils.showAlert(Alert.AlertType.ERROR, "Unhandled URI '" + uri + "'"); } }); editorView.setMarkdownService(markdownService); back.disableProperty().bind(navigator.backProperty.not()); forward.disableProperty().bind(navigator.forwardProperty.not()); home.setOnAction(_ -> navigator.navigate(new ExternalUri(INDEX_MD))); back.setOnAction(_ -> navigator.navigateBackwards()); forward.setOnAction(_ -> navigator.navigateForwards()); editorView.setUriAction(navigator::navigate); navigator.navigate(new ExternalUri(INDEX_MD)); } private void selectListViewItemIfNeeded(String url) { if (url == null) { return; } indexList.getItems().stream() .filter(resource -> url.equals(resource.getFilename())) .findFirst() .ifPresentOrElse(resource -> indexList.getSelectionModel().select(resource), () -> indexList.getSelectionModel().clearSelection()); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/help/IndexCell.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.help; import javafx.scene.control.ListCell; import org.springframework.core.io.Resource; class IndexCell extends ListCell { @Override protected void updateItem(Resource resource, boolean empty) { super.updateItem(resource, empty); if (empty || resource == null) { setText(null); } else { setText(prettify(resource.getFilename())); } } private static String prettify(String fileName) { if (fileName == null) { return "???"; } return fileName.substring(3, fileName.length() - 3); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/help/Navigator.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.help; import io.xeres.ui.support.uri.ExternalUri; import io.xeres.ui.support.uri.Uri; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.function.Consumer; /** * This classe handles a navigation using a forward and backwards paradigm. A bit like a web * browser but without having to suffer frames navigation (which I believe is broken anyway). */ class Navigator { private final List history = new ArrayList<>(); private int historyIndex = -1; private final Consumer action; final BooleanProperty backProperty = new SimpleBooleanProperty(false); final BooleanProperty forwardProperty = new SimpleBooleanProperty(false); public Navigator(Consumer action) { this.action = action; } public void navigateBackwards() { if (historyIndex == 0) { return; } action.accept(history.get(--historyIndex)); updateProperties(); } public void navigateForwards() { if (historyIndex == history.size() - 1) { return; } action.accept(history.get(++historyIndex)); updateProperties(); } public void navigate(Uri uri) { Objects.requireNonNull(uri, "uri must not be null"); if (uri.equals(getCurrentUri())) { return; } if (isNavigable(uri)) // We don't want to reopen web URLs when coming back, etc... { addToHistoryAndTrim(uri); } action.accept(uri); updateProperties(); } public Uri getCurrentUri() { if (history.isEmpty()) { return null; } return history.get(historyIndex); } public boolean isNavigable(Uri uri) { if (uri instanceof ExternalUri externalUri) { return externalUri.toUriString().endsWith(".md"); } return false; } private void addToHistoryAndTrim(Uri uri) { while (history.size() - 1 > historyIndex) { history.removeLast(); } history.addLast(uri); historyIndex = history.size() - 1; } private void updateProperties() { backProperty.set(historyIndex > 0); forwardProperty.set(historyIndex < history.size() - 1); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/id/AddRsIdWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.id; import io.xeres.common.geoip.Country; import io.xeres.common.id.Id; import io.xeres.common.pgp.Trust; import io.xeres.common.protocol.HostPort; import io.xeres.common.protocol.i2p.I2pAddress; import io.xeres.common.protocol.ip.IP; import io.xeres.common.protocol.tor.OnionAddress; import io.xeres.common.util.NoSuppressedRunnable; import io.xeres.ui.client.GeoIpClient; import io.xeres.ui.client.ProfileClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.model.connection.Connection; import io.xeres.ui.support.util.TextInputControlUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.animation.PauseTransition; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.image.ImageView; import javafx.util.Duration; import net.rgielen.fxweaver.core.FxmlView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Arrays; import java.util.Comparator; import java.util.Locale; import java.util.ResourceBundle; import java.util.concurrent.CompletableFuture; import java.util.regex.Pattern; import static org.apache.commons.collections4.CollectionUtils.emptyIfNull; @Component @FxmlView(value = "/view/id/rsid_add.fxml") public class AddRsIdWindowController implements WindowController { private static final Logger log = LoggerFactory.getLogger(AddRsIdWindowController.class); private static final Pattern RSID_CLEANER = Pattern.compile("([\r\n\t])"); @FXML private Button cancelButton; @FXML private Button addButton; @FXML private TextArea rsIdTextArea; @FXML private TextField certName; @FXML private TextField certId; @FXML private TextField certFingerprint; @FXML private TextField certLocId; @FXML private ComboBox certIps; @FXML private ImageView imageFlag; @FXML private ChoiceBox trust; @FXML private TitledPane titledPane; @FXML private Label status; @FXML private Button scanQrCode; private final ProfileClient profileClient; private final GeoIpClient geoIpClient; private final ResourceBundle bundle; private final WindowManager windowManager; public AddRsIdWindowController(ProfileClient profileClient, GeoIpClient geoIpClient, ResourceBundle bundle, WindowManager windowManager) { this.profileClient = profileClient; this.geoIpClient = geoIpClient; this.bundle = bundle; this.windowManager = windowManager; } @Override public void initialize() { scanQrCode.setOnAction(_ -> windowManager.openCamera(this)); addButton.setOnAction(_ -> addPeer()); cancelButton.setOnAction(UiUtils::closeWindow); var debouncer = new PauseTransition(Duration.millis(250.0)); rsIdTextArea.textProperty().addListener((_, _, newValue) -> { debouncer.setOnFinished(_ -> checkRsId(newValue)); debouncer.playFromStart(); }); TextInputControlUtils.addEnhancedInputContextMenu(rsIdTextArea, null, null); certIps.setCellFactory(_ -> new AddressCell()); certIps.setConverter(new AddressConverter()); Platform.runLater(this::handleArgument); } private void handleArgument() { var userData = UiUtils.getUserData(rsIdTextArea); if (userData != null) { setRsId((String) userData); rsIdTextArea.setEditable(false); UiUtils.setAbsent(scanQrCode); } else { rsIdTextArea.requestFocus(); } } public void setRsId(String rsId) { rsIdTextArea.setText(rsId); addButton.requestFocus(); } private void addPeer() { var profile = profileClient.create(rsIdTextArea.getText(), certIps.getSelectionModel().getSelectedIndex(), trust.getSelectionModel().getSelectedItem()); profile.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(cancelButton))) .doOnError(UiUtils::webAlertError) .subscribe(); } private void checkRsId(String rsId) { profileClient.checkRsId(RSID_CLEANER.matcher(rsId).replaceAll("")) .doOnSuccess(profile -> Platform.runLater(() -> { assert profile != null; status.setText(""); addButton.setDisable(false); UiUtils.clearError(rsIdTextArea, status); certName.setText(profile.getName()); certId.setText(Id.toString(profile.getPgpIdentifier())); certFingerprint.setText(profile.getProfileFingerprint().toString()); certIps.getItems().clear(); profile.getLocations().stream() .findFirst() .ifPresent(location -> { certLocId.setText(location.getLocationIdentifier().toString()); // The same sorting is used in PeerConnectionJob/connectImmediately() var allIps = location.getConnections().stream() .sorted(Comparator.comparing(Connection::isExternal).reversed()) .map(Connection::getAddress) .toList(); certIps.getItems().addAll(allIps.stream() .map(s -> new AddressCountry(s, null)) .toList()); CompletableFuture.runAsync((NoSuppressedRunnable) () -> Platform.runLater(this::findFlags)); }); setDefaultTrust(trust); titledPane.setExpanded(true); })) .doOnError(_ -> Platform.runLater(() -> { addButton.setDisable(true); if (rsIdTextArea.getText().isBlank()) { status.setText(""); UiUtils.clearError(rsIdTextArea, status); } else { status.setText(bundle.getString("rs-id.add.invalid")); UiUtils.highlightError(rsIdTextArea, status); } titledPane.setExpanded(false); })) .subscribe(); } private static void setDefaultTrust(ChoiceBox trust) { trust.getItems().clear(); trust.getItems().addAll(Arrays.stream(Trust.values()).filter(t -> t != Trust.ULTIMATE).toList()); trust.getSelectionModel().select(Trust.UNKNOWN); } private void findFlags() { for (var i = 0; i < certIps.getItems().size(); i++) { var item = certIps.getItems().get(i); Country country; if (OnionAddress.isValidAddress(item.address())) { country = Country.TOR; } else if (I2pAddress.isValidAddress(item.address())) { country = Country.I2P; } else { var hostPort = HostPort.parse(item.address()); if (IP.isLanIp(hostPort.host())) { country = Country.LAN; } else { country = findByGeoIp(hostPort.host()); } } if (country != null) { certIps.getItems().set(i, new AddressCountry(item.address(), country)); } } certIps.getSelectionModel().select(0); emptyIfNull(certIps.getItems()).stream() .min(Comparator.comparing(AddressCountry::country)) .ifPresent(addressCountry -> imageFlag.setImage(FlagUtils.getFlag(imageFlag, addressCountry.country()))); } private Country findByGeoIp(String ip) { var countryResponse = geoIpClient.getIsoCountry(ip).block(); if (countryResponse != null) { try { return Country.valueOf(countryResponse.isoCountry().toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException _) { log.warn("Country not found for iso {}", countryResponse.isoCountry()); } } return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/id/AddressCell.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.id; import javafx.scene.control.ListCell; import javafx.scene.image.ImageView; public class AddressCell extends ListCell { public AddressCell() { super(); } @Override protected void updateItem(AddressCountry item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : item.address()); setGraphic(empty ? null : new ImageView(FlagUtils.getFlag(this, item.country()))); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/id/AddressConverter.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.id; import javafx.util.StringConverter; public class AddressConverter extends StringConverter { @Override public String toString(AddressCountry object) { if (object != null) { return object.address(); } return null; } @Override public AddressCountry fromString(String string) { return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/id/AddressCountry.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.id; import io.xeres.common.geoip.Country; public record AddressCountry(String address, Country country) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/id/FlagUtils.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.id; import io.xeres.common.geoip.Country; import io.xeres.ui.support.util.TooltipUtils; import javafx.scene.Node; import javafx.scene.control.Cell; import javafx.scene.image.Image; import java.util.Locale; import java.util.Objects; public final class FlagUtils { private FlagUtils() { throw new UnsupportedOperationException("Utility class"); } public static Image getFlag(Node node, Country country) { if (country != null) { var flagPath = FlagUtils.class.getResourceAsStream("/image/flags/" + country.name().toLowerCase(Locale.ROOT) + ".png"); if (flagPath != null) { if (node instanceof Cell cell) { TooltipUtils.install(cell, country::toString, null); } else { TooltipUtils.install(node, country.toString()); } return new Image(flagPath); } } return new Image(Objects.requireNonNull(FlagUtils.class.getResourceAsStream("/image/flags/_unknown.png"))); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/messaging/BroadcastWindowController.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.messaging; import io.xeres.common.message.chat.ChatMessage; import io.xeres.ui.client.message.MessageClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.support.chat.ChatCommand; import io.xeres.ui.support.util.UiUtils; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.TextArea; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; @Component @FxmlView(value = "/view/messaging/broadcast.fxml") public class BroadcastWindowController implements WindowController { @FXML private Button send; @FXML private Button cancel; @FXML private TextArea textArea; private final MessageClient messageClient; public BroadcastWindowController(MessageClient messageClient) { this.messageClient = messageClient; } @Override public void initialize() { send.setOnAction(event -> { var message = new ChatMessage(ChatCommand.parseCommands(textArea.getText())); messageClient.sendBroadcast(message); cancel.fire(); }); textArea.textProperty().addListener(observable -> send.setDisable(textArea.getText().isBlank())); cancel.setOnAction(UiUtils::closeWindow); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/messaging/Destination.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.messaging; import io.xeres.common.id.Identifier; import org.apache.commons.lang3.StringUtils; class Destination { private final Identifier identifier; private String name; private String place; private long locationId; public Destination(Identifier identifier) { this.identifier = identifier; } public Identifier getIdentifier() { return identifier; } public String getName() { return name; } public void setName(String name) { this.name = name; } public boolean hasPlace() { return StringUtils.isNotEmpty(place); } public String getPlace() { return place; } public void setPlace(String place) { this.place = place; } public long getLocationId() { return locationId; } public void setLocationId(long locationId) { this.locationId = locationId; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.messaging; import atlantafx.base.controls.Message; import io.xeres.common.id.GxsId; import io.xeres.common.id.Identifier; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.id.Sha1Sum; import io.xeres.common.location.Availability; import io.xeres.common.message.chat.ChatAvatar; import io.xeres.common.message.chat.ChatBacklog; import io.xeres.common.message.chat.ChatMessage; import io.xeres.common.pgp.Trust; import io.xeres.common.protocol.xrs.RsServiceType; import io.xeres.common.rest.file.AddDownloadRequest; import io.xeres.common.util.RemoteUtils; import io.xeres.common.util.image.ImageUtils; import io.xeres.ui.client.*; import io.xeres.ui.client.message.MessageClient; import io.xeres.ui.client.preview.PreviewClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.controller.chat.ChatListView; import io.xeres.ui.custom.InputAreaGroup; import io.xeres.ui.custom.TypingNotificationView; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.custom.event.FileSelectedEvent; import io.xeres.ui.custom.event.ImageSelectedEvent; import io.xeres.ui.custom.event.StickerSelectedEvent; import io.xeres.ui.model.profile.Profile; import io.xeres.ui.support.chat.ChatCommand; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.uri.FileUri; import io.xeres.ui.support.uri.FileUriFactory; import io.xeres.ui.support.uri.Uri; import io.xeres.ui.support.uri.UriService; import io.xeres.ui.support.util.ImageViewUtils; import io.xeres.ui.support.util.TextInputControlUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.animation.*; import javafx.application.Platform; import javafx.embed.swing.SwingFXUtils; import javafx.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.TextInputControl; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.*; import javafx.scene.layout.VBox; import javafx.stage.Stage; import javafx.util.Duration; import net.rgielen.fxweaver.core.FxmlView; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.SignalType; import reactor.core.scheduler.Schedulers; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.text.MessageFormat; import java.time.Instant; import java.util.ArrayDeque; import java.util.List; import java.util.Queue; import java.util.ResourceBundle; import java.util.concurrent.CompletableFuture; import static io.xeres.common.message.chat.ChatConstants.TYPING_NOTIFICATION_DELAY; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; import static io.xeres.ui.support.util.UiUtils.getWindow; import static org.apache.commons.lang3.StringUtils.isEmpty; import static org.apache.commons.lang3.StringUtils.isNotBlank; @FxmlView(value = "/view/messaging/messaging.fxml") public class MessagingWindowController implements WindowController { private static final Logger log = LoggerFactory.getLogger(MessagingWindowController.class); private static final int IMAGE_WIDTH_MAX = 800; private static final int IMAGE_HEIGHT_MAX = 600; private static final int STICKER_WIDTH_MAX = 256; private static final int STICKER_HEIGHT_MAX = 256; private static final int MESSAGE_MAXIMUM_SIZE = 260_000; // Maximum packet size is 262143 (that is the buffer a Retroshare pqistreamer allocates, so we leave some room) private static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination CTRL_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN); private static final KeyCodeCombination SHIFT_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN); @FXML private InputAreaGroup send; @FXML private TypingNotificationView notification; @FXML private VBox content; @FXML private Message notice; private Availability availability = Availability.AVAILABLE; private ChatListView receive; private final ProfileClient profileClient; private final IdentityClient identityClient; private final MarkdownService markdownService; private final WindowManager windowManager; private final UriService uriService; private final ResourceBundle bundle; private final Destination destination; private final MessageClient messageClient; private final ShareClient shareClient; private final ChatClient chatClient; private final GeneralClient generalClient; private final PreviewClient previewClient; private final ImageCache imageCache; private final LocationClient locationClient; private Instant lastTypingNotification = Instant.EPOCH; private Timeline lastTypingTimeline; private ParallelTransition sendAnimation; private final boolean isIncoming; private Queue filesToSend; public MessagingWindowController(ProfileClient profileClient, IdentityClient identityClient, WindowManager windowManager, UriService uriService, MessageClient messageClient, ShareClient shareClient, MarkdownService markdownService, Identifier destinationIdentifier, ResourceBundle bundle, ChatClient chatClient, GeneralClient generalClient, PreviewClient previewClient, ImageCache imageCache, LocationClient locationClient, boolean isIncoming) { this.profileClient = profileClient; this.identityClient = identityClient; this.windowManager = windowManager; this.uriService = uriService; this.messageClient = messageClient; this.shareClient = shareClient; this.markdownService = markdownService; this.chatClient = chatClient; this.bundle = bundle; this.generalClient = generalClient; this.previewClient = previewClient; this.imageCache = imageCache; destination = new Destination(destinationIdentifier); this.locationClient = locationClient; this.isIncoming = isIncoming; } @Override public void initialize() { var ownProfileResult = profileClient.getOwn(); ownProfileResult.doOnSuccess(profile -> Platform.runLater(() -> { assert profile != null; setupChatListView(profile.getName(), profile.getId()); // XXX: race condition here, sometimes showMessage() might be called before (and receive is null) })) .subscribe(); send.addKeyFilter(this::handleInputKeys); send.addEnhancedContextMenu(this::handlePaste); send.addEventHandler(StickerSelectedEvent.STICKER_SELECTED, event -> { event.consume(); CompletableFuture.runAsync(() -> { try (var inputStream = new FileInputStream(event.getPath().toFile())) { var imageView = new ImageView(new Image(inputStream)); Platform.runLater(() -> sendStickerToMessage(imageView)); } catch (IOException e) { log.error("Couldn't send the sticker: {}", e.getMessage()); } }); }); send.addEventHandler(ImageSelectedEvent.IMAGE_SELECTED, event -> { if (event.getFile().canRead()) { CompletableFuture.runAsync(() -> { try (var inputStream = new FileInputStream(event.getFile())) { var imageView = new ImageView(new Image(inputStream)); Platform.runLater(() -> sendImageViewToMessage(imageView)); } catch (IOException e) { UiUtils.showAlert(Alert.AlertType.ERROR, MessageFormat.format(bundle.getString("file-requester.error"), event.getFile(), e.getMessage())); } }); } }); send.addEventHandler(FileSelectedEvent.FILE_SELECTED, event -> { if (event.getFile().canRead()) { sendFile(event.getFile()); } }); send.callPressedProperty().addListener((_, _, newValue) -> { if (Boolean.TRUE.equals(newValue)) { windowManager.doVoip(destination.getIdentifier().toString(), null); } }); lastTypingTimeline = new Timeline( new KeyFrame(Duration.ZERO, _ -> notification.setText(MessageFormat.format(bundle.getString("chat.notification.typing"), destination.getName()))), new KeyFrame(Duration.seconds(TYPING_NOTIFICATION_DELAY.getSeconds()))); lastTypingTimeline.setOnFinished(_ -> notification.setText("")); setupAnimations(); } private void sendMessage(String message) { if (isEmpty(message)) { return; } var chatMessage = new ChatMessage(ChatCommand.parseCommands(message)); messageClient.sendToDestination(destination.getIdentifier(), chatMessage); } private void sendTypingNotificationIfNeeded() { var now = Instant.now(); if (java.time.Duration.between(lastTypingNotification, now).compareTo(TYPING_NOTIFICATION_DELAY.minusSeconds(1)) > 0) { var message = new ChatMessage(); messageClient.sendToDestination(destination.getIdentifier(), message); lastTypingNotification = now; } } private void setupChatListView(String nickname, long id) { receive = new ChatListView(nickname, id, markdownService, this::handleUriAction, generalClient, imageCache, windowManager, send); content.getChildren().add(1, receive.getChatView()); content.setOnDragOver(event -> { if (event.getDragboard().hasFiles()) { event.acceptTransferModes(TransferMode.COPY_OR_MOVE); } event.consume(); }); content.setOnDragDropped(event -> { var files = event.getDragboard().getFiles(); sendFiles(files); event.setDropCompleted(true); event.consume(); }); } private void sendFiles(List files) { if (filesToSend == null) { filesToSend = new ArrayDeque<>(); } filesToSend.addAll(CollectionUtils.emptyIfNull(files)); sendNextFile(); } private void sendFile(File file) { filesToSend.add(file); sendNextFile(); } private void sendNextFile() { var file = filesToSend.poll(); if (file != null) { shareClient.createTemporaryShare(file.getAbsolutePath()) .doOnSuccess(result -> { assert result != null; sendMessage(FileUriFactory.generate(file.getName(), getFileSize(file.toPath()), Sha1Sum.fromString(result.hash()))); }) .doFinally(signalType -> { if (signalType != SignalType.CANCEL) { Platform.runLater(this::sendNextFile); } }) .subscribe(); } } private static long getFileSize(Path path) { try { return Files.size(path); } catch (IOException _) { log.error("Failed to get the file size of {}", path); return 0; } } private void handleUriAction(Uri uri) { if (uri instanceof FileUri(String name, long size, Sha1Sum hash)) { windowManager.openAddDownload( new AddDownloadRequest(name, size, hash, destination.getIdentifier() instanceof LocationIdentifier locationIdentifier ? locationIdentifier : null)); } else { uriService.openUri(uri); } } @Override public void onShown() { if (destination.getIdentifier() instanceof LocationIdentifier locationIdentifier) { profileClient.findByLocationIdentifier(locationIdentifier, true).collectList() .doOnSuccess(profiles -> { assert profiles != null; var profile = profiles.stream().findFirst().orElseThrow(); Platform.runLater(() -> { if (profile.getTrust() == Trust.FULL) { // Only peers we trust can show previews receive.setPreviewClient(previewClient); } var location = profile.getLocations().getFirst(); setAvailability(location.isConnected() ? location.getAvailability() : Availability.OFFLINE); getProfileImage(profile); destination.setName(profile.getName()); destination.setPlace(location.getName()); destination.setLocationId(location.getId()); updateTitle(); receive.installClearHistoryContextMenu(() -> chatClient.deleteChatBacklog(location.getId()).subscribe()); chatClient.getChatBacklog(location.getId()).collectList() .doOnSuccess(backlogs -> Platform.runLater(() -> { assert backlogs != null; fillBacklog(backlogs); })) // No need to use userData to pass the incoming message, it's already in the backlog .subscribe(); }); }) .doOnError(UiUtils::webAlertError) .subscribe(); } else if (destination.getIdentifier() instanceof GxsId gxsId) { identityClient.findByGxsId(gxsId).collectList() .doOnSuccess(gxsIds -> { assert gxsIds != null; var identity = gxsIds.stream().findFirst().orElseThrow(); Platform.runLater(() -> { destination.setName(identity.getName()); updateTitle(); fetchIdentityImage(identity.hasImage() ? identity.getId() : 0L, identity.getGxsId()); receive.installClearHistoryContextMenu(() -> chatClient.deleteDistantChatBacklog(identity.getId()).subscribe()); chatClient.getDistantChatBacklog(identity.getId()).collectList() .doOnSuccess(backlogs -> Platform.runLater(() -> { assert backlogs != null; fillBacklog(backlogs); })) // Incoming message already in the backlog .subscribe(); if (isIncoming) { setAvailability(Availability.AVAILABLE); } else { chatClient.createDistantChat(identity.getId()) .doOnSuccess(_ -> Platform.runLater(() -> { setAvailability(Availability.OFFLINE); notification.setProgress(bundle.getString("messaging.tunneling")); })) .doOnError(WebClientResponseException.class, e -> { if (e.getStatusCode() == HttpStatus.CONFLICT) { Platform.runLater(() -> setAvailability(Availability.OFFLINE)); } }) .subscribe(); } }); }) .doOnError(UiUtils::webAlertError) .subscribe(); UiUtils.getWindow(send).setOnCloseRequest(event -> { if (availability != Availability.OFFLINE) { UiUtils.showAlertConfirm(bundle.getString("messaging.closing-tunnel.confirm"), () -> UiUtils.getWindow(send).hide()); event.consume(); } }); } } /** * Fetches the profile image. Tries to use the identity first, and if not possible, * uses a fallback to the old avatar sending system. * * @param profile the profile */ private void getProfileImage(Profile profile) { profileClient.findContactsForProfile(profile.getId()).collectList() .publishOn(Schedulers.boundedElastic()) .doOnSuccess(contacts -> { assert contacts != null; if (contacts.isEmpty()) { messageClient.requestAvatar(destination.getIdentifier()); } else { var contact = contacts.getFirst(); identityClient.findById(contact.identityId()) .doOnSuccess(identity -> { assert identity != null; fetchIdentityImage(identity.hasImage() ? identity.getId() : 0L, identity.getGxsId()); }) .subscribe(); } }) .subscribe(); } private void fetchIdentityImage(long identityId, GxsId gxsId) { String url; if (identityId != 0L) { url = RemoteUtils.getControlUrl() + IDENTITIES_PATH + "/" + identityId + "/image"; } else { url = RemoteUtils.getControlUrl() + IDENTITIES_PATH + "/image?gxsId=" + gxsId; } generalClient.getImage(url) .doOnSuccess(imageData -> Platform.runLater(() -> setWindowIcon(imageData))) .subscribe(); } private void setWindowIcon(byte[] imageData) { var icon = new Image(new ByteArrayInputStream(imageData)); var stage = (Stage) getWindow(send); stage.getIcons().add(icon); } @Override public void onHidden() { if (destination.getIdentifier() instanceof GxsId gxsId) { identityClient.findByGxsId(gxsId).collectList() .doOnSuccess(gxsIds -> { assert gxsIds != null; var identity = gxsIds.stream().findFirst().orElseThrow(); Platform.runLater(() -> chatClient.closeDistantChat(identity.getId()) .subscribe()); }) .subscribe(); } } public void showMessage(ChatMessage message) { if (message != null) { if (message.isEmpty()) { lastTypingTimeline.playFromStart(); } else { if (message.isOwn()) { receive.addOwnMessage(message); } else { receive.addUserMessage(destination.getName(), message.getContent()); } lastTypingTimeline.jumpTo(Duration.INDEFINITE); } } } private void fillBacklog(List messages) { messages.forEach(message -> { if (message.own()) { receive.addOwnMessage(message.created(), message.message()); } else { receive.addUserMessage(message.created(), destination.getName(), message.message()); } }); receive.jumpToBottom(true); } public void showAvatar(ChatAvatar chatAvatar) { if (chatAvatar.getImage() != null) { setWindowIcon(chatAvatar.getImage()); } } public void setAvailability(Availability availability) { this.availability = availability; updateTitle(); notification.stopProgress(); } private void updateTitle() { var stage = (Stage) getWindow(send); stage.setTitle(destination.getName() + (destination.hasPlace() ? (" @ " + destination.getPlace()) : "") + " " + getAvailability()); } private String getAvailability() { return switch (availability) { case AVAILABLE -> { setUserOnline(true); yield ""; } case AWAY -> { setUserOnline(true); yield "(" + Availability.AWAY + ")"; } case BUSY -> { setUserOnline(true); yield "(" + Availability.BUSY + ")"; } case OFFLINE -> { setUserOnline(false); yield "(" + Availability.OFFLINE + ")"; } }; } private void setUserOnline(boolean online) { UiUtils.setPresent(notice, !online); send.setOffline(!online); // Enable the VoIP button only if we're a full node and the destination // has it enabled as well. if (!RemoteUtils.isRemoteUiClient() && online && destination.hasPlace()) { locationClient.isServiceSupported(destination.getLocationId(), RsServiceType.VOIP.getType()) .doOnSuccess(_ -> Platform.runLater(() -> send.setVoipCapable(true))) .doOnError(_ -> Platform.runLater(() -> send.setVoipCapable(false))) .subscribe(); } else { send.setVoipCapable(false); } } private void handleInputKeys(KeyEvent event) { if (PASTE_KEY.match(event)) { if (handlePaste(send.getTextInputControl())) { event.consume(); } } else if (COPY_KEY.match(event)) { if (receive.copy()) { event.consume(); } } else if (CTRL_ENTER.match(event) || SHIFT_ENTER.match(event) && isNotBlank(send.getTextInputControl().getText())) { send.getTextInputControl().insertText(send.getTextInputControl().getCaretPosition(), "\n"); sendTypingNotificationIfNeeded(); event.consume(); } else if (event.getCode() == KeyCode.ENTER) { if (isNotBlank(send.getTextInputControl().getText())) { if (notice.isVisible()) { sendAnimation.play(); } else { sendMessage(send.getTextInputControl().getText()); send.clear(); lastTypingNotification = Instant.EPOCH; } } event.consume(); } else { sendTypingNotificationIfNeeded(); } } private boolean handlePaste(TextInputControl textInputControl) { var object = ClipboardUtils.getSupportedObjectFromClipboard(); return switch (object) { case Image image -> { sendImageViewToMessage(new ImageView(image)); yield true; } case String string -> { TextInputControlUtils.pasteGuessedContent(textInputControl, string); yield true; } case null, default -> false; }; } private void sendImageViewToMessage(ImageView imageView) { ImageViewUtils.limitMaximumImageSize(imageView, IMAGE_WIDTH_MAX * IMAGE_HEIGHT_MAX); var imageData = ImageUtils.writeImage(SwingFXUtils.fromFXImage(imageView.getImage(), null), MESSAGE_MAXIMUM_SIZE); if (StringUtils.isNotEmpty(imageData)) { sendMessage(""); } else { UiUtils.showAlert(Alert.AlertType.WARNING, "Couldn't compress PNG to a small enough size"); } imageView.setImage(null); } private void sendStickerToMessage(ImageView imageView) { ImageViewUtils.limitMaximumImageSize(imageView, STICKER_WIDTH_MAX * STICKER_HEIGHT_MAX); sendMessage(""); imageView.setImage(null); } private void setupAnimations() { var translateTransition = new TranslateTransition(javafx.util.Duration.millis(50)); translateTransition.setFromX(-5.0); translateTransition.setToX(5.0); translateTransition.setCycleCount(6); translateTransition.setAutoReverse(true); translateTransition.setInterpolator(Interpolator.LINEAR); translateTransition.setNode(send); translateTransition.setOnFinished(_ -> send.setTranslateX(0.0)); var fadeTransition = new FadeTransition(javafx.util.Duration.millis(100)); fadeTransition.setByValue(-1.0); fadeTransition.setAutoReverse(true); fadeTransition.setCycleCount(4); fadeTransition.setInterpolator(Interpolator.EASE_IN); fadeTransition.setNode(notice); sendAnimation = new ParallelTransition(translateTransition, fadeTransition); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/qrcode/CameraWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.qrcode; import com.github.sarxos.webcam.Webcam; import com.github.sarxos.webcam.WebcamResolution; import com.google.zxing.*; import com.google.zxing.client.j2se.BufferedImageLuminanceSource; import com.google.zxing.common.HybridBinarizer; import io.xeres.ui.controller.WindowController; import io.xeres.ui.controller.id.AddRsIdWindowController; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.concurrent.Task; import javafx.embed.swing.SwingFXUtils; import javafx.fxml.FXML; import javafx.scene.control.Label; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import net.rgielen.fxweaver.core.FxmlView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; import java.util.ResourceBundle; @Component @FxmlView(value = "/view/qrcode/camera.fxml") public class CameraWindowController implements WindowController { private static final Logger log = LoggerFactory.getLogger(CameraWindowController.class); @FXML private ImageView capturedImage; @FXML private Label error; private boolean stopCamera; private AddRsIdWindowController parentController; private final ObjectProperty imageProperty = new SimpleObjectProperty<>(); private final ResourceBundle bundle; public CameraWindowController(ResourceBundle bundle) { this.bundle = bundle; } @Override public void initialize() { stopCamera = false; var camera = Webcam.getDefault(); if (camera != null) { initializeCamera(camera); } else { error.setText(bundle.getString("qr-code.camera.error")); error.setVisible(true); } } @Override public void onHidden() { stopCamera(); } @Override public void onShown() { parentController = (AddRsIdWindowController) UiUtils.getUserData(capturedImage); } private void initializeCamera(Webcam camera) { var cameraInitializer = new Task() { @Override protected Void call() { String rsId = null; var multiFormatReader = new MultiFormatReader(); multiFormatReader.setHints(Map.of( DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.QR_CODE))); // Built-in driver only supports 640x480 as maximum, but // this is usually enough for QR code scanning. camera.setViewSize(WebcamResolution.VGA.getSize()); camera.open(); while (!stopCamera) { try { var grabbedImage = camera.getImage(); if (grabbedImage != null) { Platform.runLater(() -> imageProperty.set(SwingFXUtils.toFXImage(grabbedImage, null))); var source = new BufferedImageLuminanceSource(grabbedImage); var bitmap = new BinaryBitmap(new HybridBinarizer(source)); grabbedImage.flush(); var result = multiFormatReader.decodeWithState(bitmap); rsId = result.getText(); log.debug("Found qr code: {}", rsId); stopCamera(); } else { log.warn("Empty image!?"); } } catch (NotFoundException _) { // No QR code was found on the image } } camera.close(); String finalRsId = rsId; Platform.runLater(() -> { imageProperty.set(null); if (finalRsId != null) { parentController.setRsId(finalRsId); UiUtils.closeWindow(capturedImage); } }); return null; } }; capturedImage.imageProperty().bind(imageProperty); Thread.ofVirtual().name("Camera Handler").start(cameraInitializer); } private void stopCamera() { stopCamera = true; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/qrcode/QrCodeWindowController.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.qrcode; import io.xeres.common.AppName; import io.xeres.common.rest.location.RSIdResponse; import io.xeres.common.util.OsUtils; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.ResizeableImageView; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.embed.swing.SwingFXUtils; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.print.PrinterJob; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.image.ImageView; import javafx.scene.layout.HBox; import javafx.scene.transform.Scale; import javafx.stage.FileChooser; import javafx.stage.FileChooser.ExtensionFilter; import javafx.stage.Window; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import javax.imageio.ImageIO; import java.io.IOException; import java.util.ResourceBundle; import static io.xeres.common.rest.PathConfig.LOCATIONS_PATH; @Component @FxmlView(value = "/view/qrcode/qrcode.fxml") public class QrCodeWindowController implements WindowController { public static final double PRINTER_DPI = 72.0; // JavaFX uses 72 DPI for all printers public static final double CREDIT_CARD_WIDTH = 3.37; public static final double CREDIT_CARD_HEIGHT = 2.125; @FXML private ResizeableImageView ownQrCode; @FXML private Button printButton; @FXML private Button saveButton; @FXML private Button closeButton; @FXML private Label status; private RSIdResponse rsIdResponse; private final GeneralClient generalClient; private final ResourceBundle bundle; public QrCodeWindowController(GeneralClient generalClient, ResourceBundle bundle) { this.generalClient = generalClient; this.bundle = bundle; } @Override public void initialize() { ownQrCode.setLoader(url -> generalClient.getImage(url).block()); printButton.setOnAction(event -> showPrintSetupThenPrint(UiUtils.getWindow(event))); saveButton.setOnAction(event -> saveAsPng(UiUtils.getWindow(event))); closeButton.setOnAction(UiUtils::closeWindow); Platform.runLater(() -> closeButton.requestFocus()); } @Override public void onShown() { var userData = UiUtils.getUserData(ownQrCode); if (userData == null) { throw new IllegalArgumentException("Missing RsIdResponse"); } rsIdResponse = (RSIdResponse) userData; ownQrCode.setUrl(LOCATIONS_PATH + "/" + 1L + "/rs-id/qr-code"); } private void showPrintSetupThenPrint(Window window) { var printerJob = PrinterJob.createPrinterJob(); if (printerJob.showPrintDialog(window)) { print(printerJob, ownQrCode); } } private void print(PrinterJob printerJob, ImageView qrCode) { status.textProperty().bind(printerJob.jobStatusProperty().asString()); var loader = new FXMLLoader(QrCodeWindowController.class.getResource("/view/qrcode/qrprint.fxml"), bundle); try { HBox view = loader.load(); var controller = (QrPrintController) loader.getController(); controller.setImage(qrCode.getImage()); controller.setProfileName(rsIdResponse.name()); controller.setLocationName(rsIdResponse.location()); var sizeX = CREDIT_CARD_WIDTH * PRINTER_DPI; var sizeY = CREDIT_CARD_HEIGHT * PRINTER_DPI; var pageLayout = printerJob.getPrinter().getDefaultPageLayout(); if (sizeX > pageLayout.getPrintableWidth() || sizeY > pageLayout.getPrintableHeight()) { throw new IllegalStateException("QR code card is too big for the printer paper size"); } var scale = new Scale(sizeX / view.getPrefWidth(), sizeY / view.getPrefHeight()); view.getTransforms().add(scale); // See https://bugs.openjdk.org/browse/JDK-8089053 about the "unexpected PG access" print out. // There's nothing that can be done about it and it's harmless. if (printerJob.printPage(view)) { printerJob.endJob(); } } catch (IOException e) { throw new RuntimeException(e); } } private void saveAsPng(Window window) { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("qr-code.save-as-png")); ChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir()); fileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString("file-requester.png"), "*.png")); fileChooser.setInitialFileName(AppName.NAME + "ID_" + rsIdResponse.name() + "@" + rsIdResponse.location() + ".png"); var selectedFile = fileChooser.showSaveDialog(window); if (selectedFile != null && (!selectedFile.exists() || selectedFile.canWrite())) { var bufferedImage = SwingFXUtils.fromFXImage(ownQrCode.snapshot(null, null), null); try { ImageIO.write(bufferedImage, "PNG", selectedFile); } catch (IOException e) { throw new RuntimeException(e); } } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/qrcode/QrPrintController.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.qrcode; import io.xeres.ui.controller.Controller; import javafx.fxml.FXML; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.text.Text; public class QrPrintController implements Controller { @FXML private ImageView qrCode; @FXML private Text profileText; @FXML private Text locationText; @Override public void initialize() { // Nothing to do } public void setImage(Image image) { qrCode.setImage(image); } public void setProfileName(String name) { profileText.setText(name); } public void setLocationName(String name) { locationText.setText(name); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsCell.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import javafx.scene.control.ListCell; class SettingsCell extends ListCell { SettingsCell() { super(); } @Override protected void updateItem(SettingsGroup item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : item.name()); setGraphic(empty ? null : item.graphic()); setGraphicTextGap(8.0); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsController.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.ui.controller.Controller; import io.xeres.ui.model.settings.Settings; public interface SettingsController extends Controller { void onLoad(Settings settings); Settings onSave(); } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsGeneralController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.common.rest.config.Capabilities; import io.xeres.ui.client.ConfigClient; import io.xeres.ui.model.settings.Settings; import io.xeres.ui.support.preference.PreferenceUtils; import io.xeres.ui.support.theme.AppTheme; import io.xeres.ui.support.theme.AppThemeManager; import io.xeres.ui.support.updater.UpdateService; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.Arrays; import static io.xeres.ui.support.preference.PreferenceUtils.UPDATE_CHECK; @Component @FxmlView(value = "/view/settings/settings_general.fxml") public class SettingsGeneralController implements SettingsController { @FXML private ComboBox themeSelector; @FXML private CheckBox autoStartEnabled; @FXML private CheckBox checkForUpdates; @FXML private Label autoStartNotAvailable; private Settings settings; private final ConfigClient configClient; private final AppThemeManager appThemeManager; private final UpdateService updateService; public SettingsGeneralController(ConfigClient configClient, AppThemeManager appThemeManager, UpdateService updateService) { this.configClient = configClient; this.appThemeManager = appThemeManager; this.updateService = updateService; } @Override public void initialize() { themeSelector.setButtonCell(new ThemeCell(themeSelector)); themeSelector.setCellFactory(_ -> new ThemeCell(themeSelector)); themeSelector.getItems().addAll(Arrays.stream(AppTheme.values()).toList()); var currentTheme = appThemeManager.getCurrentTheme(); themeSelector.getSelectionModel().select(currentTheme); themeSelector.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> appThemeManager.changeTheme(newValue)); configClient.getCapabilities() .doOnSuccess(capabilities -> Platform.runLater(() -> { assert capabilities != null; if (capabilities.contains(Capabilities.AUTOSTART)) { autoStartEnabled.setDisable(false); } else { autoStartNotAvailable.setVisible(true); } })) .subscribe(); } @Override public void onLoad(Settings settings) { this.settings = settings; autoStartEnabled.setSelected(settings.isAutoStartEnabled()); checkForUpdates.setSelected(updateService.isAutomaticallyCheckingForUpdates(PreferenceUtils.getPreferences().node(UPDATE_CHECK))); } @Override public Settings onSave() { settings.setAutoStartEnabled(autoStartEnabled.isSelected()); updateService.setAutomaticCheckForUpdates(PreferenceUtils.getPreferences().node(UPDATE_CHECK), checkForUpdates.isSelected()); return settings; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsGroup.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import javafx.scene.Node; record SettingsGroup(String name, Node graphic, Class controllerClass) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsNetworksController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.ui.client.ConfigClient; import io.xeres.ui.model.settings.Settings; import io.xeres.ui.support.util.TextFieldUtils; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.control.CheckBox; import javafx.scene.control.TextField; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; @Component @FxmlView(value = "/view/settings/settings_networks.fxml") public class SettingsNetworksController implements SettingsController { @FXML private TextField torSocksHost; @FXML private TextField torSocksPort; @FXML private TextField i2pSocksHost; @FXML private TextField i2pSocksPort; @FXML private CheckBox upnpEnabled; @FXML private TextField externalIp; @FXML private TextField externalPort; @FXML private CheckBox broadcastDiscoveryEnabled; @FXML private TextField internalIp; @FXML private TextField internalPort; @FXML private CheckBox dhtEnabled; private Settings settings; private final ConfigClient configClient; public SettingsNetworksController(ConfigClient configClient) { this.configClient = configClient; } @Override public void initialize() { TextFieldUtils.setHost(torSocksHost); TextFieldUtils.setNumeric(torSocksPort, 0, 6); TextFieldUtils.setHost(i2pSocksHost); TextFieldUtils.setNumeric(i2pSocksPort, 0, 6); configClient.getExternalIpAddress() .doOnSuccess(ipAddressResponse -> Platform.runLater(() -> { assert ipAddressResponse != null; externalIp.setText(ipAddressResponse.ip()); externalPort.setText(String.valueOf(ipAddressResponse.port())); })) .subscribe(); configClient.getInternalIpAddress() .doOnSuccess(ipAddressResponse -> Platform.runLater(() -> { assert ipAddressResponse != null; internalIp.setText(ipAddressResponse.ip()); internalPort.setText(String.valueOf(ipAddressResponse.port())); })) .subscribe(); } @Override public void onLoad(Settings settings) { this.settings = settings; torSocksHost.setText(settings.getTorSocksHost()); if (settings.getTorSocksPort() != 0) { torSocksPort.setText(String.valueOf(settings.getTorSocksPort())); } i2pSocksHost.setText(settings.getI2pSocksHost()); if (settings.getI2pSocksPort() != 0) { i2pSocksPort.setText(String.valueOf(settings.getI2pSocksPort())); } upnpEnabled.setSelected(settings.isUpnpEnabled()); broadcastDiscoveryEnabled.setSelected(settings.isBroadcastDiscoveryEnabled()); dhtEnabled.setSelected(settings.isDhtEnabled()); } @Override public Settings onSave() { settings.setTorSocksHost(TextFieldUtils.getString(torSocksHost)); settings.setTorSocksPort(limitPort(TextFieldUtils.getAsNumber(torSocksPort))); settings.setI2pSocksHost(TextFieldUtils.getString(i2pSocksHost)); settings.setI2pSocksPort(limitPort(TextFieldUtils.getAsNumber(i2pSocksPort))); settings.setUpnpEnabled(upnpEnabled.isSelected()); settings.setBroadcastDiscoveryEnabled(broadcastDiscoveryEnabled.isSelected()); settings.setDhtEnabled(dhtEnabled.isSelected()); return settings; } private int limitPort(int port) { if (port > 65535) { port = 65535; } return port; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsNotificationController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.ui.model.settings.Settings; import io.xeres.ui.support.notification.NotificationSettings; import javafx.fxml.FXML; import javafx.scene.control.CheckBox; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; @Component @FxmlView(value = "/view/settings/settings_notifications.fxml") public class SettingsNotificationController implements SettingsController { @FXML private CheckBox showConnections; @FXML private CheckBox showBroadcasts; @FXML private CheckBox showDiscovery; private final NotificationSettings notificationSettings; public SettingsNotificationController(NotificationSettings notificationSettings) { this.notificationSettings = notificationSettings; } @Override public void initialize() { } @Override public void onLoad(Settings settings) { showConnections.setSelected(notificationSettings.isConnectionEnabled()); showBroadcasts.setSelected(notificationSettings.isBroadcastsEnabled()); showDiscovery.setSelected(notificationSettings.isDiscoveryEnabled()); } @Override public Settings onSave() { notificationSettings.setConnectionEnabled(showConnections.isSelected()); notificationSettings.setBroadcastsEnabled(showBroadcasts.isSelected()); notificationSettings.setDiscoveryEnabled(showDiscovery.isSelected()); notificationSettings.save(); return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsRemoteController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import atlantafx.base.controls.PasswordTextField; import io.xeres.common.properties.StartupProperties; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.custom.DisclosedHyperlink; import io.xeres.ui.custom.ReadOnlyTextField; import io.xeres.ui.model.settings.Settings; import io.xeres.ui.support.tray.TrayService; import io.xeres.ui.support.util.TextFieldUtils; import io.xeres.ui.support.util.UiUtils; import jakarta.annotation.Nullable; import javafx.application.HostServices; import javafx.fxml.FXML; import javafx.scene.Cursor; import javafx.scene.control.CheckBox; import javafx.scene.control.TextField; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.springframework.stereotype.Component; import java.util.Objects; import java.util.ResourceBundle; import static io.xeres.common.properties.StartupProperties.Property.CONTROL_PORT; import static org.apache.commons.lang3.StringUtils.isBlank; @Component @FxmlView(value = "/view/settings/settings_remote.fxml") public class SettingsRemoteController implements SettingsController { @FXML private CheckBox remoteEnabled; @FXML private PasswordTextField password; @FXML private DisclosedHyperlink viewApi; @FXML private TextField port; @FXML private ReadOnlyTextField username; @FXML private CheckBox remoteUpnpEnabled; private boolean noUpnp; private Settings settings; private boolean originalRemoteEnabled; private String originalPassword; private final TrayService trayService; private final HostServices hostServices; private final ResourceBundle bundle; public SettingsRemoteController(TrayService trayService, @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @Nullable HostServices hostServices, ResourceBundle bundle) { this.trayService = trayService; this.hostServices = hostServices; this.bundle = bundle; } @Override public void initialize() { TextFieldUtils.setNumeric(port, 0, 6); var icon = new FontIcon("mdi2e-eye-off"); icon.setCursor(Cursor.HAND); UiUtils.setOnPrimaryMouseClicked(icon, event -> { icon.setIconLiteral(password.getRevealPassword() ? "mdi2e-eye-off" : "mdi2e-eye"); password.setRevealPassword(!password.getRevealPassword()); }); password.setRight(icon); remoteEnabled.setOnAction(actionEvent -> checkDisabled()); UiUtils.linkify(viewApi, hostServices); viewApi.setUri(RemoteUtils.getControlUrl() + "/swagger-ui/index.html"); } @Override public void onLoad(Settings settings) { this.settings = settings; noUpnp = !settings.isUpnpEnabled(); remoteEnabled.setSelected(settings.isRemoteEnabled()); remoteUpnpEnabled.setSelected(settings.isUpnpRemoteEnabled()); checkDisabled(); password.setText(settings.getRemotePassword()); port.setText(String.valueOf(StartupProperties.getInteger(CONTROL_PORT))); originalRemoteEnabled = settings.isRemoteEnabled(); originalPassword = settings.getRemotePassword(); } @Override public Settings onSave() { var portChanged = false; settings.setRemotePassword(isBlank(password.getPassword()) ? null : password.getPassword()); settings.setRemoteEnabled(remoteEnabled.isSelected()); settings.setUpnpRemoteEnabled(remoteUpnpEnabled.isSelected()); if (!port.getText().isEmpty()) { var portValue = Integer.parseInt(port.getText()); if (portValue >= 1025 && portValue <= 65535 && portValue != Objects.requireNonNull(StartupProperties.getInteger(CONTROL_PORT))) { settings.setRemotePort(portValue); portChanged = true; } } if (originalRemoteEnabled != settings.isRemoteEnabled() || !originalPassword.equals(settings.getRemotePassword()) || portChanged) { UiUtils.showAlertConfirm(bundle.getString("settings.remote.restart"), trayService::exitApplication); } return settings; } private void checkDisabled() { var selected = remoteEnabled.isSelected(); port.setDisable(!selected); username.setDisable(!selected); password.setDisable(!selected); remoteUpnpEnabled.setDisable(noUpnp || !selected); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsSoundController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.common.util.OsUtils; import io.xeres.ui.model.settings.Settings; import io.xeres.ui.support.sound.SoundPlayerService; import io.xeres.ui.support.sound.SoundSettings; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.TextField; import javafx.stage.FileChooser; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.nio.file.Path; import java.util.ResourceBundle; @Component @FxmlView(value = "/view/settings/settings_sound.fxml") public class SettingsSoundController implements SettingsController { @FXML private CheckBox messageEnabled; @FXML private CheckBox highlightEnabled; @FXML private CheckBox friendEnabled; @FXML private CheckBox downloadEnabled; @FXML private CheckBox ringingEnabled; @FXML private TextField messageFile; @FXML private TextField highlightFile; @FXML private TextField friendFile; @FXML private TextField downloadFile; @FXML private TextField ringingFile; @FXML private Button messageFileSelector; @FXML private Button highlightFileSelector; @FXML private Button friendFileSelector; @FXML private Button downloadFileSelector; @FXML private Button ringingFileSelector; @FXML private Button messagePlay; @FXML private Button highlightPlay; @FXML private Button friendPlay; @FXML private Button downloadPlay; @FXML private Button ringingPlay; private final ResourceBundle bundle; private final SoundSettings soundSettings; private final SoundPlayerService soundPlayerService; public SettingsSoundController(ResourceBundle bundle, SoundSettings soundSettings, SoundPlayerService soundPlayerService) { this.bundle = bundle; this.soundSettings = soundSettings; this.soundPlayerService = soundPlayerService; } @Override public void initialize() { initializeSoundPath(messageEnabled, messageFile, messageFileSelector, messagePlay); initializeSoundPath(highlightEnabled, highlightFile, highlightFileSelector, highlightPlay); initializeSoundPath(friendEnabled, friendFile, friendFileSelector, friendPlay); initializeSoundPath(downloadEnabled, downloadFile, downloadFileSelector, downloadPlay); initializeSoundPath(ringingEnabled, ringingFile, ringingFileSelector, ringingPlay); } private void initializeSoundPath(CheckBox checkbox, TextField path, Button pathSelector, Button playButton) { checkbox.selectedProperty().addListener((_, _, newValue) -> { path.setDisable(!newValue); pathSelector.setDisable(!newValue); playButton.setDisable(!newValue); }); pathSelector.setOnAction(event -> { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("file-requester.select-sound-title")); fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(bundle.getString("file-requester.sounds"), "*.aif", "*.aiff", "*.mp3", "*.mp4", "*.wav")); if (!path.getText().isEmpty()) { fileChooser.setInitialFileName(path.getText()); setInitialDirectoryIfExists(fileChooser, path.getText()); } var selectedFile = fileChooser.showOpenDialog(UiUtils.getWindow(event)); if (selectedFile != null && selectedFile.isFile()) { path.setText(selectedFile.getAbsolutePath()); } }); playButton.setOnAction(_ -> soundPlayerService.play(path.getText())); } private void setInitialDirectoryIfExists(FileChooser fileChooser, String filePath) { var path = Path.of(filePath); var parent = path.getParent(); if (parent == null && !path.isAbsolute()) { // Try to find the proper path (can happen on Windows when we're auto started) var home = OsUtils.getApplicationHome(); path = Path.of(home.toString(), filePath); parent = path.getParent(); } if (parent != null) { var file = parent.toFile(); ChooserUtils.setInitialDirectory(fileChooser, file); } } @Override public void onLoad(Settings settings) { messageEnabled.setSelected(soundSettings.isMessageEnabled()); highlightEnabled.setSelected(soundSettings.isHighlightEnabled()); friendEnabled.setSelected(soundSettings.isFriendEnabled()); downloadEnabled.setSelected(soundSettings.isDownloadEnabled()); ringingEnabled.setSelected(soundSettings.isRingingEnabled()); messageFile.setText(soundSettings.getMessageFile()); highlightFile.setText(soundSettings.getHighlightFile()); friendFile.setText(soundSettings.getFriendFile()); downloadFile.setText(soundSettings.getDownloadFile()); ringingFile.setText(soundSettings.getRingingFile()); } @Override public Settings onSave() { soundSettings.setMessageEnabled(messageEnabled.isSelected()); soundSettings.setHighlightEnabled(highlightEnabled.isSelected()); soundSettings.setFriendEnabled(friendEnabled.isSelected()); soundSettings.setDownloadEnabled(downloadEnabled.isSelected()); soundSettings.setRingingEnabled(ringingEnabled.isSelected()); soundSettings.setMessageFile(messageFile.getText()); soundSettings.setHighlightFile(highlightFile.getText()); soundSettings.setFriendFile(friendFile.getText()); soundSettings.setDownloadFile(downloadFile.getText()); soundSettings.setRingingFile(ringingFile.getText()); soundSettings.save(); return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsTransferController.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.model.settings.Settings; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.TextFieldUtils; import io.xeres.ui.support.util.UiUtils; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.TextField; import javafx.stage.DirectoryChooser; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.ResourceBundle; import static javafx.scene.control.Alert.AlertType.INFORMATION; @Component @FxmlView(value = "/view/settings/settings_transfer.fxml") public class SettingsTransferController implements SettingsController { @FXML private TextField incomingDirectory; @FXML private Button incomingDirectorySelector; private Settings settings; private final ResourceBundle bundle; public SettingsTransferController(ResourceBundle bundle) { this.bundle = bundle; } @Override public void initialize() { incomingDirectorySelector.setOnAction(event -> { if (RemoteUtils.isRemoteUiClient()) { UiUtils.showAlert(INFORMATION, bundle.getString("settings.directory.no-remote")); return; } var directoryChooser = new DirectoryChooser(); directoryChooser.setTitle(bundle.getString("settings.transfer.select-incoming")); if (settings.hasIncomingDirectory()) { ChooserUtils.setInitialDirectory(directoryChooser, settings.getIncomingDirectory()); } var selectedDirectory = directoryChooser.showDialog(UiUtils.getWindow(event)); if (selectedDirectory != null && selectedDirectory.isDirectory()) { incomingDirectory.setText(selectedDirectory.getAbsolutePath()); } }); } @Override public void onLoad(Settings settings) { this.settings = settings; incomingDirectory.setText(settings.getIncomingDirectory()); } @Override public Settings onSave() { settings.setIncomingDirectory(TextFieldUtils.getString(incomingDirectory)); return settings; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/SettingsWindowController.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.common.Features; import io.xeres.ui.client.SettingsClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.model.settings.Settings; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.ListView; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.StackPane; import net.rgielen.fxweaver.core.FxWeaver; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.springframework.stereotype.Component; import java.util.ResourceBundle; @Component @FxmlView(value = "/view/settings/settings.fxml") public class SettingsWindowController implements WindowController { private static final int PREFERENCE_ICON_SIZE = 24; private final SettingsClient settingsClient; @FXML private ListView listView; private Settings originalSettings; private Settings newSettings; @FXML private AnchorPane content; private final FxWeaver fxWeaver; private final ResourceBundle bundle; public SettingsWindowController(SettingsClient settingsClient, FxWeaver fxWeaver, ResourceBundle bundle) { this.settingsClient = settingsClient; this.fxWeaver = fxWeaver; this.bundle = bundle; } @Override public void initialize() { listView.setCellFactory(_ -> new SettingsCell()); listView.getItems().addAll( new SettingsGroup(bundle.getString("settings.general"), createPreferenceGraphic("mdi2c-cog"), SettingsGeneralController.class), new SettingsGroup(bundle.getString("settings.notifications"), createPreferenceGraphic("mdi2m-message-alert"), SettingsNotificationController.class), new SettingsGroup(bundle.getString("settings.network"), createPreferenceGraphic("mdi2s-server-network"), SettingsNetworksController.class), new SettingsGroup(bundle.getString("settings.transfer"), createPreferenceGraphic("mdi2b-briefcase-download"), SettingsTransferController.class), new SettingsGroup(bundle.getString("settings.sound"), createPreferenceGraphic("mdi2m-music"), SettingsSoundController.class), new SettingsGroup(bundle.getString("settings.remote"), createPreferenceGraphic("mdi2e-earth"), SettingsRemoteController.class) ); listView.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> { saveContent(); content.getChildren().clear(); if (newValue.controllerClass() != null) { var controllerAndView = fxWeaver.load(newValue.controllerClass(), bundle); controllerAndView.getController().onLoad(newSettings); var view = controllerAndView.getView().orElseThrow(); content.getChildren().add(view); AnchorPane.setTopAnchor(view, 0.0); AnchorPane.setBottomAnchor(view, 0.0); AnchorPane.setLeftAnchor(view, 0.0); AnchorPane.setRightAnchor(view, 0.0); view.setUserData(controllerAndView.getController()); } }); listView.setDisable(true); settingsClient.getSettings().doOnSuccess(settings -> Platform.runLater(() -> { assert settings != null; originalSettings = settings; newSettings = originalSettings.clone(); listView.setDisable(false); listView.getSelectionModel().selectFirst(); })) .subscribe(); } @Override public void onHidden() { saveContent(); if (newSettings != null) { if (Features.USE_PATCH_SETTINGS) { settingsClient.patchSettings(originalSettings, newSettings) .subscribe(); } else { settingsClient.putSettings(newSettings) .subscribe(); } } } private void saveContent() { if (!content.getChildren().isEmpty()) { var controller = (SettingsController) content.getChildren().getFirst().getUserData(); var controllerSettings = controller.onSave(); if (controllerSettings != null) { newSettings = controllerSettings; } } } private static Node createPreferenceGraphic(String iconCode) { var pane = new StackPane(new FontIcon(iconCode)); pane.setPrefWidth(PREFERENCE_ICON_SIZE); pane.setPrefHeight(PREFERENCE_ICON_SIZE); pane.setAlignment(Pos.CENTER); return pane; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/settings/ThemeCell.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.settings; import io.xeres.ui.support.theme.AppTheme; import io.xeres.ui.support.util.ImageViewUtils; import javafx.scene.Node; import javafx.scene.control.ListCell; import javafx.scene.image.ImageView; class ThemeCell extends ListCell { private final Node parent; public ThemeCell(Node parent) { this.parent = parent; } @Override protected void updateItem(AppTheme appTheme, boolean empty) { super.updateItem(appTheme, empty); if (!empty) { var imageView = new ImageView("/image/themes/" + appTheme.getName() + ".png"); ImageViewUtils.disableOutputScaling(imageView, parent); setText(appTheme.getName()); setGraphic(imageView); } else { setText(null); setGraphic(null); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/share/ShareWindowController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.share; import io.xeres.common.pgp.Trust; import io.xeres.common.util.OsUtils; import io.xeres.common.util.RemoteUtils; import io.xeres.ui.client.ShareClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.model.share.Share; import io.xeres.ui.support.contextmenu.XContextMenu; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.control.cell.CheckBoxTableCell; import javafx.scene.control.cell.ChoiceBoxTableCell; import javafx.scene.control.cell.TextFieldTableCell; import javafx.stage.DirectoryChooser; import net.rgielen.fxweaver.core.FxmlView; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignF; import org.springframework.stereotype.Component; import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashSet; import java.util.ResourceBundle; import java.util.Set; import static io.xeres.common.dto.share.ShareConstants.INCOMING_SHARE; import static javafx.scene.control.Alert.AlertType.INFORMATION; import static javafx.scene.control.TableColumn.SortType.ASCENDING; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isEmpty; @Component @FxmlView(value = "/view/file/share.fxml") public class ShareWindowController implements WindowController { private static final String REMOVE_MENU_ID = "remove"; private static final String SHOW_IN_FOLDER_MENU_ID = "showInFolder"; private final ShareClient shareClient; private final ResourceBundle bundle; @FXML private TableView shareTableView; @FXML private TableColumn tableDirectory; // XXX: or path? @FXML private TableColumn tableName; @FXML private TableColumn tableSearchable; @FXML private TableColumn tableBrowsable; @FXML private Button applyButton; @FXML private Button addButton; @FXML private Button cancelButton; private boolean refreshHack; public ShareWindowController(ShareClient shareClient, ResourceBundle bundle) { this.shareClient = shareClient; this.bundle = bundle; } @Override public void initialize() { createShareTableViewContextMenu(); tableDirectory.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().getPath())); tableDirectory.setOnEditStart(param -> { if (refreshHack) { refreshHack = false; return; } if (RemoteUtils.isRemoteUiClient()) { UiUtils.showAlert(INFORMATION, bundle.getString("settings.directory.no-remote")); return; } var directoryChooser = new DirectoryChooser(); directoryChooser.setTitle(bundle.getString("share.select-directory")); if (!isEmpty(param.getOldValue())) { var previousPath = Path.of(param.getOldValue()); ChooserUtils.setInitialDirectory(directoryChooser, previousPath); } var selectedDirectory = directoryChooser.showDialog(UiUtils.getWindow(shareTableView)); if (selectedDirectory != null && selectedDirectory.isDirectory()) { getCurrentItem(param).setPath(selectedDirectory.getPath()); refreshHack = true; // refresh() calls setOnEditStart() again so we need that workaround param.getTableView().refresh(); } // We clear the selection so that the directory selector can be triggered again. Go figure... Platform.runLater(param.getTableView().getSelectionModel()::clearSelection); }); tableDirectory.setOnEditCommit(param -> getCurrentItem(param).setPath(param.getNewValue())); tableName.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().getName())); tableName.setCellFactory(TextFieldTableCell.forTableColumn()); tableName.setOnEditCommit(param -> getCurrentItem(param).setName(param.getNewValue())); // XXX: when clicking outside tableName, the value isn't committed but the edited value stays on display anyway (which is wrong). but we get no event at all // setOnEditCommit() doesn't work for CheckBoxes, so we have to do that tableSearchable.setCellFactory(_ -> new CheckBoxTableCell<>()); tableSearchable.setCellValueFactory(param -> param.getValue().searchableProperty()); tableBrowsable.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getBrowsable())); tableBrowsable.setCellFactory(ChoiceBoxTableCell.forTableColumn(new TrustConverter(), Trust.values())); tableBrowsable.setOnEditCommit(param -> getCurrentItem(param).setBrowsable(param.getNewValue())); addButton.setOnAction(_ -> { var downloadPath = OsUtils.getDownloadDir(); var newShare = new Share(); newShare.setName(downloadPath.getName(downloadPath.getNameCount() - 1).toString()); newShare.setPath(downloadPath.toString()); newShare.setSearchable(true); newShare.setBrowsable(Trust.NEVER); shareTableView.getItems().add(newShare); shareTableView.getSelectionModel().select(newShare); shareTableView.edit(shareTableView.getSelectionModel().getSelectedIndex(), tableName); }); applyButton.setOnAction(event -> Platform.runLater(() -> { if (validateShares()) { shareClient.createAndUpdate(shareTableView.getItems()) .doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(event))) .doOnError(UiUtils::webAlertError) .subscribe(); } })); cancelButton.setOnAction(UiUtils::closeWindow); shareClient.findAll().collectList() .doOnSuccess(shares -> Platform.runLater(() -> { assert shares != null; // Add all shares shareTableView.getItems().addAll(shares); // Sort by visible name shareTableView.getSortOrder().add(tableName); tableName.setSortType(ASCENDING); tableName.setSortable(true); })) .doOnError(UiUtils::webAlertError) .subscribe(); } private void createShareTableViewContextMenu() { var removeItem = new MenuItem(bundle.getString("share.remove")); removeItem.setId(REMOVE_MENU_ID); removeItem.setGraphic(new FontIcon(MaterialDesignF.FOLDER_REMOVE)); removeItem.setOnAction(event -> { var share = (Share) event.getSource(); shareTableView.getItems().remove(share); }); var showInExplorerItem = new MenuItem(bundle.getString("download-view.show-in-folder")); showInExplorerItem.setId(SHOW_IN_FOLDER_MENU_ID); showInExplorerItem.setGraphic(new FontIcon(MaterialDesignF.FOLDER_OPEN)); showInExplorerItem.setOnAction(event -> { if (event.getSource() instanceof Share share) { OsUtils.showFolder(Paths.get(share.getPath()).toFile()); } }); var xContextMenu = new XContextMenu(showInExplorerItem, new SeparatorMenuItem(), removeItem); xContextMenu.addToNode(shareTableView); xContextMenu.setOnShowing((contextMenu, share) -> { contextMenu.getItems().stream() .filter(menuItem -> REMOVE_MENU_ID.equals(menuItem.getId())) .findFirst().ifPresent(menuItem -> menuItem.setDisable(share.getId() == INCOMING_SHARE)); return share != null; }); } private boolean validateShares() { Set shareNames = HashSet.newHashSet(shareTableView.getItems().size()); for (var share : shareTableView.getItems()) { try { if (isBlank(share.getName())) { throw new IllegalArgumentException(bundle.getString("share.error.empty-name")); } if (isBlank(share.getPath())) { throw new IllegalArgumentException(bundle.getString("share.error.empty-path")); } if (shareNames.contains(share.getName())) { throw new IllegalArgumentException(bundle.getString("share.error.not-unique")); } shareNames.add(share.getName()); } catch (IllegalArgumentException e) { shareTableView.getSelectionModel().select(share); UiUtils.webAlertError(e); return false; } } return true; } private static T getCurrentItem(TableColumn.CellEditEvent param) { return param.getTableView().getItems().get(param.getTablePosition().getRow()); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/share/TrustConverter.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.share; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.pgp.Trust; import javafx.util.StringConverter; import java.util.Arrays; import java.util.ResourceBundle; public class TrustConverter extends StringConverter { private static final ResourceBundle bundle = I18nUtils.getBundle(); private enum Permission { NOBODY(Trust.UNKNOWN, bundle.getString("trust-converter.nobody")), ANYBODY(Trust.NEVER, bundle.getString("trust-converter.everybody")), MARGINAL(Trust.MARGINAL, bundle.getString("trust-converter.marginal")), FULL(Trust.FULL, bundle.getString("trust-converter.full")), ULTIMATE(Trust.ULTIMATE, bundle.getString("trust-converter.ultimate")); private final Trust trust; private final String value; Permission(Trust trust, String value) { this.trust = trust; this.value = value; } public Trust getTrust() { return trust; } public String getValue() { return value; } } @Override public String toString(Trust trust) { return Arrays.stream(Permission.values()) .filter(permission -> permission.getTrust() == trust) .findFirst().orElseThrow() .getValue(); } @Override public Trust fromString(String s) { return Arrays.stream(Permission.values()) .filter(permission -> permission.getValue().equals(s)) .findFirst().orElseThrow() .getTrust(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsDataCounterController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.statistics; import io.xeres.common.util.ExecutorUtils; import io.xeres.ui.client.StatisticsClient; import io.xeres.ui.controller.Controller; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.chart.BarChart; import javafx.scene.chart.CategoryAxis; import javafx.scene.chart.XYChart; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.ResourceBundle; import java.util.concurrent.ScheduledExecutorService; @Component @FxmlView(value = "/view/statistics/datacounter.fxml") public class StatisticsDataCounterController implements Controller { public static final int UPDATE_IN_SECONDS = 10; @FXML private BarChart barChart; @FXML private CategoryAxis xAxis; private ScheduledExecutorService executorService; private final XYChart.Series in = new XYChart.Series<>(); private final XYChart.Series out = new XYChart.Series<>(); private final StatisticsClient statisticsClient; private final ResourceBundle bundle; public StatisticsDataCounterController(StatisticsClient statisticsClient, ResourceBundle bundle) { this.statisticsClient = statisticsClient; this.bundle = bundle; } @Override public void initialize() { in.setName(bundle.getString("statistics.turtle.data-in")); out.setName(bundle.getString("statistics.turtle.data-out")); //noinspection unchecked barChart.getData().addAll(in, out); } public void start() { executorService = ExecutorUtils.createFixedRateExecutor(() -> statisticsClient.getDataCounterStatistics() .doOnSuccess(dataCounterStatisticsResponse -> Platform.runLater(() -> { assert dataCounterStatisticsResponse != null; in.getData().clear(); out.getData().clear(); dataCounterStatisticsResponse.peers().forEach(dataPeer -> { in.getData().add(new XYChart.Data<>(dataPeer.name(), dataPeer.received() / 1024)); out.getData().add(new XYChart.Data<>(dataPeer.name(), dataPeer.sent() / 1024)); }); })) .subscribe(), 1, UPDATE_IN_SECONDS); } public void stop() { ExecutorUtils.cleanupExecutor(executorService); barChart.getData().clear(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsMainWindowController.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.statistics; import io.xeres.ui.controller.WindowController; import javafx.fxml.FXML; import javafx.scene.control.TabPane; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; @Component @FxmlView(value = "/view/statistics/main.fxml") public class StatisticsMainWindowController implements WindowController { @FXML private TabPane tabPane; // This field name to get the controller is some black magic, see last answer at https://stackoverflow.com/questions/40754454/get-controller-instance-from-node @FXML private StatisticsTurtleController statisticsTurtleController; @FXML private StatisticsRttController statisticsRttController; @FXML private StatisticsDataCounterController statisticsDataCounterController; @Override public void initialize() { } @Override public void onShown() { statisticsTurtleController.start(); statisticsRttController.start(); statisticsDataCounterController.start(); } @Override public void onHiding() { statisticsTurtleController.stop(); statisticsRttController.stop(); statisticsDataCounterController.stop(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsRttController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.statistics; import io.xeres.common.rest.statistics.RttPeer; import io.xeres.common.util.ExecutorUtils; import io.xeres.ui.client.StatisticsClient; import io.xeres.ui.controller.Controller; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.chart.LineChart; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.XYChart; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.Map; import java.util.ResourceBundle; import java.util.concurrent.ScheduledExecutorService; import java.util.stream.Collectors; @Component @FxmlView(value = "/view/statistics/rtt.fxml") public class StatisticsRttController implements Controller { private static final int UPDATE_IN_SECONDS = 10; private static final int DATA_WINDOW_SIZE = 12; // 2 minutes of data (one each 10 seconds) @FXML private LineChart lineChart; @FXML private NumberAxis xAxis; private final Map> peerSeries = new HashMap<>(); private ScheduledExecutorService executorService; private final StatisticsClient statisticsClient; private final ResourceBundle bundle; public StatisticsRttController(StatisticsClient statisticsClient, ResourceBundle bundle) { this.statisticsClient = statisticsClient; this.bundle = bundle; } @Override public void initialize() { xAxis.setTickLabelFormatter(new NumberAxis.DefaultFormatter(xAxis) { @Override public String toString(Number object) { return String.valueOf(-object.intValue()); } }); } public void start() { executorService = ExecutorUtils.createFixedRateExecutor(() -> statisticsClient.getRttStatistics() .doOnSuccess(rttStatisticsResponse -> Platform.runLater(() -> { assert rttStatisticsResponse != null; rttStatisticsResponse.peers().forEach(rttPeer -> { var series = peerSeries.computeIfAbsent(rttPeer.id(), _ -> createSeries(rttPeer)); updateData(series, rttPeer.mean()); }); var ids = rttStatisticsResponse.peers().stream() .map(RttPeer::id) .collect(Collectors.toSet()); peerSeries.entrySet().removeIf(entry -> { if (!ids.contains(entry.getKey())) { lineChart.getData().remove(entry.getValue()); return true; } return false; }); })) .subscribe(), 0, UPDATE_IN_SECONDS); // XXX: that period should be shared somewhere } public void stop() { ExecutorUtils.cleanupExecutor(executorService); peerSeries.clear(); } private XYChart.Series createSeries(RttPeer rttPeer) { var series = new XYChart.Series(); series.setName(rttPeer.name()); lineChart.getData().add(series); return series; } private static void updateData(XYChart.Series series, float value) { series.getData().forEach(numberNumberData -> numberNumberData.setXValue(numberNumberData.getXValue().intValue() - UPDATE_IN_SECONDS)); series.getData().addFirst(new XYChart.Data<>(0, value)); if (series.getData().size() > DATA_WINDOW_SIZE + 1) { series.getData().removeLast(); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsTurtleController.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.statistics; import io.xeres.common.util.ExecutorUtils; import io.xeres.ui.client.StatisticsClient; import io.xeres.ui.controller.Controller; import io.xeres.ui.support.util.TooltipUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.fxml.FXML; import javafx.scene.Cursor; import javafx.scene.chart.LineChart; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.XYChart; import javafx.scene.control.Label; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import java.util.Map; import java.util.ResourceBundle; import java.util.concurrent.ScheduledExecutorService; @Component @FxmlView(value = "/view/statistics/turtle.fxml") public class StatisticsTurtleController implements Controller { private static final int UPDATE_IN_SECONDS = 2; private static final int DATA_WINDOW_SIZE = 60; // 2 minutes of data (one data each 2 seconds) @FXML private LineChart lineChart; @FXML private NumberAxis xAxis; private ScheduledExecutorService executorService; private final StatisticsClient statisticsClient; private final ResourceBundle bundle; private final XYChart.Series dataDownload = new XYChart.Series<>(); private final XYChart.Series dataUpload = new XYChart.Series<>(); private final XYChart.Series forwardTotal = new XYChart.Series<>(); private final XYChart.Series tunnelRequestsDownload = new XYChart.Series<>(); private final XYChart.Series tunnelRequestsUpload = new XYChart.Series<>(); private final XYChart.Series searchRequestsDownload = new XYChart.Series<>(); private final XYChart.Series searchRequestsUpload = new XYChart.Series<>(); public StatisticsTurtleController(StatisticsClient statisticsClient, ResourceBundle bundle) { this.statisticsClient = statisticsClient; this.bundle = bundle; } @Override public void initialize() { xAxis.setTickLabelFormatter(new NumberAxis.DefaultFormatter(xAxis) { @Override public String toString(Number object) { return String.valueOf(-object.intValue()); } }); dataDownload.setName(bundle.getString("statistics.turtle.data-in")); dataUpload.setName(bundle.getString("statistics.turtle.data-out")); forwardTotal.setName(bundle.getString("statistics.turtle.data-forward")); tunnelRequestsDownload.setName(bundle.getString("statistics.turtle.tunnel-in")); tunnelRequestsUpload.setName(bundle.getString("statistics.turtle.tunnel-out")); searchRequestsDownload.setName(bundle.getString("statistics.turtle.search-in")); searchRequestsUpload.setName(bundle.getString("statistics.turtle.search-out")); lineChart.getData().add(dataDownload); lineChart.getData().add(dataUpload); lineChart.getData().add(forwardTotal); lineChart.getData().add(tunnelRequestsDownload); lineChart.getData().add(tunnelRequestsUpload); lineChart.getData().add(searchRequestsDownload); lineChart.getData().add(searchRequestsUpload); var legendTips = Map.of( dataDownload.getName(), bundle.getString("statistics.turtle.data-in.tip"), dataUpload.getName(), bundle.getString("statistics.turtle.data-out.tip"), forwardTotal.getName(), bundle.getString("statistics.turtle.data-forward.tip"), tunnelRequestsDownload.getName(), bundle.getString("statistics.turtle.tunnel-in.tip"), tunnelRequestsUpload.getName(), bundle.getString("statistics.turtle.tunnel-out.tip"), searchRequestsDownload.getName(), bundle.getString("statistics.turtle.search-in.tip"), searchRequestsUpload.getName(), bundle.getString("statistics.turtle.search-out.tip") ); lineChart.lookupAll("Label.chart-legend-item").forEach(node -> { if (node instanceof Label label) { label.setCursor(Cursor.HAND); TooltipUtils.install(label, legendTips.get(label.getText())); UiUtils.setOnPrimaryMouseClicked(label, _ -> { label.setOpacity(label.getOpacity() > 0.75 ? 0.5 : 1.0); lineChart.getData().forEach(series -> { if (series.getName().equals(label.getText())) { series.getNode().setVisible(!series.getNode().isVisible()); } }); }); } }); } public void start() { executorService = ExecutorUtils.createFixedRateExecutor(() -> statisticsClient.getTurtleStatistics() .doOnSuccess(turtleStatisticsResponse -> Platform.runLater(() -> { assert turtleStatisticsResponse != null; updateData(dataDownload, turtleStatisticsResponse.dataDownload() / 1024f); updateData(dataUpload, turtleStatisticsResponse.dataUpload() / 1024f); updateData(forwardTotal, turtleStatisticsResponse.forwardTotal() / 1024f); updateData(tunnelRequestsDownload, turtleStatisticsResponse.tunnelRequestsDownload() / 1024f); updateData(tunnelRequestsUpload, turtleStatisticsResponse.tunnelRequestsUpload() / 1024f); updateData(searchRequestsDownload, turtleStatisticsResponse.searchRequestsDownload() / 1024f); updateData(searchRequestsUpload, turtleStatisticsResponse.searchRequestsUpload() / 1024f); })) .subscribe(), 0, UPDATE_IN_SECONDS); // XXX: that period should be shared somewhere } public void stop() { ExecutorUtils.cleanupExecutor(executorService); dataDownload.getData().clear(); dataUpload.getData().clear(); forwardTotal.getData().clear(); tunnelRequestsDownload.getData().clear(); tunnelRequestsUpload.getData().clear(); searchRequestsDownload.getData().clear(); searchRequestsUpload.getData().clear(); } private static void updateData(XYChart.Series series, float value) { series.getData().forEach(numberNumberData -> numberNumberData.setXValue(numberNumberData.getXValue().intValue() - UPDATE_IN_SECONDS)); series.getData().addFirst(new XYChart.Data<>(0, value)); if (series.getData().size() > DATA_WINDOW_SIZE + 1) { series.getData().removeLast(); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/voip/TimeCounter.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.voip; import javafx.animation.Animation; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.util.Duration; import java.time.Instant; import java.util.function.Consumer; public class TimeCounter { private Instant startTime; private Timeline timeline; private final Consumer consumer; public TimeCounter(Consumer consumer) { this.consumer = consumer; } public void start() { stop(); startTime = Instant.now(); timeline = new Timeline( new KeyFrame(Duration.ZERO, _ -> update()), new KeyFrame(Duration.seconds(1)) ); timeline.setCycleCount(Animation.INDEFINITE); timeline.play(); } private void update() { var now = Instant.now(); var duration = java.time.Duration.between(startTime, now); consumer.accept(duration); } public void stop() { if (timeline != null) { timeline.stop(); update(); timeline = null; } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/controller/voip/VoipWindowController.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.controller.voip; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.message.voip.VoipAction; import io.xeres.common.message.voip.VoipMessage; import io.xeres.ui.client.GeneralClient; import io.xeres.ui.client.ProfileClient; import io.xeres.ui.client.message.MessageClient; import io.xeres.ui.controller.WindowController; import io.xeres.ui.custom.asyncimage.AsyncImageView; import io.xeres.ui.support.contact.ContactUtils; import io.xeres.ui.support.sound.SoundPlayerService; import io.xeres.ui.support.util.DateUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; import javafx.beans.binding.BooleanBinding; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.media.AudioClip; import net.rgielen.fxweaver.core.FxmlView; import org.springframework.stereotype.Component; import reactor.core.scheduler.Schedulers; import java.time.LocalTime; import java.util.Objects; import java.util.ResourceBundle; import static io.xeres.ui.support.sound.SoundPlayerService.SoundType; @Component @FxmlView(value = "/view/voip/voip.fxml") public class VoipWindowController implements WindowController { public record Parameters(String destination, VoipMessage message) { } private enum Status { INCOMING_CALL, OUTGOING_CALL, IN_CALL, ENDED } @FXML private AsyncImageView imageView; @FXML private Label nameLabel; @FXML private Label statusLabel; @FXML private Label timerLabel; @FXML private Button messageButton; @FXML private Button recallButton; @FXML private Button closeButton; @FXML private Button answerButton; @FXML private Button rejectButton; private final MessageClient messageClient; private final GeneralClient generalClient; private final ProfileClient profileClient; private final WindowManager windowManager; private final ResourceBundle bundle; private final SoundPlayerService soundPlayerService; private LocationIdentifier destinationIdentifier; private Status status; private final TimeCounter timeCounter; private AudioClip audioClip; public VoipWindowController(MessageClient messageClient, GeneralClient generalClient, ProfileClient profileClient, WindowManager windowManager, ResourceBundle bundle, SoundPlayerService soundPlayerService) { this.messageClient = messageClient; this.generalClient = generalClient; this.profileClient = profileClient; this.windowManager = windowManager; this.bundle = bundle; this.soundPlayerService = soundPlayerService; timeCounter = new TimeCounter(duration -> timerLabel.setText(DateUtils.TIME_PRECISE_FORMAT.format(LocalTime.ofSecondOfDay(duration.getSeconds() % (24 * 3600))))); } @Override public void initialize() { imageView.setLoader(url -> generalClient.getImage(url).block()); answerButton.setOnAction(_ -> { messageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.ACKNOWLEDGE)); status = Status.IN_CALL; updateState(); }); rejectButton.setOnAction(_ -> { messageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.CLOSE)); status = Status.ENDED; updateState(); }); messageButton.setOnAction(_ -> windowManager.openMessaging(destinationIdentifier)); recallButton.setOnAction(_ -> { messageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.RING)); status = Status.OUTGOING_CALL; updateState(); }); closeButton.setOnAction(_ -> UiUtils.getWindow(nameLabel).hide()); } @Override public void onShown() { var userData = (Parameters) Objects.requireNonNull(UiUtils.getUserData(answerButton), "missing Parameters userdata"); destinationIdentifier = LocationIdentifier.fromString(userData.destination); profileClient.findByLocationIdentifier(destinationIdentifier, false).collectList() .publishOn(Schedulers.boundedElastic()) .doOnSuccess(profiles -> { assert profiles != null; profileClient.findContactsForProfile(profiles.getFirst().getId()).collectList() .doOnSuccess(contacts -> Platform.runLater(() -> { assert contacts != null; if (contacts.isEmpty()) { nameLabel.setText(profiles.getFirst().getName()); } else { var contact = contacts.getFirst(); nameLabel.setText(contact.name()); imageView.setUrl(ContactUtils.getIdentityImageUrl(contact)); } })) .subscribe(); }) .subscribe(); if (userData.message == null) { messageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.RING)); status = Status.OUTGOING_CALL; } else { status = Status.INCOMING_CALL; } updateState(); setupImagePresence(); UiUtils.getWindow(nameLabel).setOnCloseRequest(event -> { if (status != Status.ENDED) { UiUtils.showAlertConfirm(bundle.getString("voip.action.window-quit"), () -> { stopRingingSound(); messageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.CLOSE)); UiUtils.getWindow(nameLabel).hide(); }); event.consume(); } }); } public void doAction(String identifier, VoipMessage voipMessage) { if (voipMessage == null) { // We ignore outgoing calls if the window is already open UiUtils.getWindow(nameLabel).requestFocus(); return; } destinationIdentifier = LocationIdentifier.fromString(identifier); switch (voipMessage.getAction()) { case RING -> status = Status.INCOMING_CALL; case ACKNOWLEDGE -> status = Status.IN_CALL; case CLOSE -> status = Status.ENDED; } updateState(); } private void updateState() { switch (status) { case INCOMING_CALL -> { statusLabel.setText(bundle.getString("voip.status.incoming")); rejectButton.setText(bundle.getString("voip.action.reject")); timerLabel.setVisible(false); UiUtils.setAbsent(messageButton); UiUtils.setAbsent(recallButton); UiUtils.setAbsent(closeButton); UiUtils.setPresent(answerButton); UiUtils.setPresent(rejectButton); playRingingSound(); } case OUTGOING_CALL -> { statusLabel.setText(bundle.getString("voip.status.calling")); timerLabel.setVisible(false); UiUtils.setAbsent(messageButton); UiUtils.setAbsent(recallButton); UiUtils.setAbsent(closeButton); UiUtils.setAbsent(answerButton); UiUtils.setPresent(rejectButton); playRingingSound(); } case IN_CALL -> { statusLabel.setText(bundle.getString("voip.status.ongoing")); timeCounter.start(); rejectButton.setText(bundle.getString("voip.action.hangup")); timerLabel.setVisible(true); UiUtils.setAbsent(answerButton); UiUtils.setPresent(rejectButton); stopRingingSound(); } case ENDED -> { statusLabel.setText(bundle.getString("voip.status.ended")); timeCounter.stop(); UiUtils.setPresent(messageButton); UiUtils.setPresent(recallButton); UiUtils.setPresent(closeButton); UiUtils.setAbsent(answerButton); UiUtils.setAbsent(rejectButton); stopRingingSound(); } } } // Remove the contact image when the window is resized small // so that we can save up space. private void setupImagePresence() { var scene = imageView.getScene(); BooleanBinding showImage = scene.widthProperty().greaterThan(300) .and(scene.heightProperty().greaterThan(280)); imageView.managedProperty().bind(showImage); imageView.visibleProperty().bind(showImage); } private void playRingingSound() { stopRingingSound(); audioClip = soundPlayerService.playRepeated(SoundType.RINGING); } private void stopRingingSound() { if (audioClip != null) { audioClip.stop(); audioClip = null; } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/DelayedAction.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.application.Platform; import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; /** * Class to run a delayed action. Set a start runnable, a stop runnable and delay to run the start runnable. */ public class DelayedAction { private final AtomicBoolean shouldRun = new AtomicBoolean(); private Timeline timeline; private final javafx.util.Duration delay; private final Runnable start; private final Runnable stop; public DelayedAction(Runnable start, Runnable stop, Duration delay) { this.start = start; this.stop = stop; this.delay = javafx.util.Duration.millis(delay.toMillis()); } /** * Runs the start runnable after a certain delay. If called more than once, the following calls are ignored unless abort() is called first. */ public void run() { if (shouldRun.compareAndSet(false, true)) { cleanup(); var newTimeline = new Timeline(new KeyFrame(delay, event -> { if (start != null && shouldRun.get()) { start.run(); } })); timeline = newTimeline; Platform.runLater(newTimeline::play); } } /** * Aborts the start runnable and runs the stop runnable. */ public void abort() { if (shouldRun.compareAndSet(true, false)) { cleanup(); if (stop != null) { stop.run(); } } } private void cleanup() { if (timeline != null) { timeline.stop(); timeline = null; } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/DelayedTooltip.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import javafx.scene.Node; import javafx.scene.control.Tooltip; import java.util.function.Consumer; /** * A tooltip subclass that allows to generate the string on demand (for example * for a network call). */ public class DelayedTooltip extends Tooltip { private Consumer consumer; /** * Creates a DelayedTooltip that will call the consumer when it's about to show. * The consumer has to call {@link #show(String)} to make the tooltip visible. * * @param consumer the consumer */ public DelayedTooltip(Consumer consumer) { super(); this.consumer = consumer; } @Override protected void show() { super.show(); if (consumer != null) { consumer.accept(this); } } public void show(String text) { consumer = null; // Only fetch once setText(text); } public void show(String text, Node graphic) { show(text); setGraphic(graphic); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/DisclosedHyperlink.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.support.util.TooltipUtils; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.util.UriUtils; import javafx.application.HostServices; import javafx.beans.NamedArg; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectPropertyBase; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.text.Text; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.text.MessageFormat; import java.util.ResourceBundle; /** * Special Hyperlink-like class that offers the following benefits: *

    *
  • detects malicious links and warns about them (for example, a link that has a description of https://foo.bar but really goes to https://bar.com *
  • can be reflowed when put on a TextFlow *
* On the other hand, it doesn't support the "visited" feature of normal hyperlinks. *

* Note: you most certainly want to use {@link UiUtils#linkify(Node, HostServices)} to have the link's action perform something. */ public class DisclosedHyperlink extends Text { private static final Logger log = LoggerFactory.getLogger(DisclosedHyperlink.class); private String uri; private final boolean alwaysSafe; private boolean malicious; private boolean unsafe; private static final ResourceBundle bundle = I18nUtils.getBundle(); /** * Creates a new DisclosedHyperlink * * @param text the text to show in the link * @param uri the URL * @param alwaysSafe disables safe/malicious detection (useful when linking to localhost APIs) */ public DisclosedHyperlink(@NamedArg(value = "text") String text, @NamedArg(value = "url") String uri, @NamedArg(value = "alwaysSafe") boolean alwaysSafe) { super(text); this.alwaysSafe = alwaysSafe; setUri(uri); setUnderline(true); setOnMouseEntered(_ -> setCursor(Cursor.HAND)); setOnMouseExited(_ -> setCursor(Cursor.DEFAULT)); UiUtils.setOnPrimaryMouseClicked(this, _ -> { var action = onAction.get(); if (action != null) { onAction.get().handle(new ActionEvent()); } else { log.warn("No action defined for hyperlink"); } }); } public final ObjectProperty> onActionProperty() { return onAction; } public final void setOnAction(EventHandler value) { onActionProperty().set(value); } public final EventHandler getOnAction() { return onActionProperty().get(); } private final ObjectProperty> onAction = new ObjectPropertyBase<>() { @Override protected void invalidated() { setEventHandler(ActionEvent.ACTION, get()); } @Override public Object getBean() { return DisclosedHyperlink.this; } @Override public String getName() { return "onAction"; } }; public String getUri() { return uri; } public void setUri(String uri) { this.uri = uri; unsafe = uri != null && !alwaysSafe && !UriUtils.isSafeEnough(uri); malicious = uri != null && !alwaysSafe && getText().contains("://") && !getText().equals(uri); if (unsafe || malicious) { setStyle("-fx-fill: -color-danger-fg;"); TooltipUtils.install(this, MessageFormat.format(bundle.getString(unsafe ? "uri.unsafe-link" : "uri.malicious-link"), uri)); } else { setStyle("-fx-fill: -color-accent-fg"); TooltipUtils.install(this, uri); } } public boolean isMalicious() { return malicious; } public boolean isUnsafe() { return unsafe; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/EditorView.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.common.i18n.I18nUtils; import io.xeres.common.util.image.ImageUtils; import io.xeres.ui.client.LocationClient; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.contentline.Content; import io.xeres.ui.support.contentline.ContentText; import io.xeres.ui.support.markdown.MarkdownService; import io.xeres.ui.support.markdown.MarkdownService.Rendering; import io.xeres.ui.support.markdown.UriAction; import io.xeres.ui.support.util.ImageViewUtils; import io.xeres.ui.support.util.TextInputControlUtils; import io.xeres.ui.support.util.UiUtils; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyIntegerWrapper; import javafx.beans.property.SimpleBooleanProperty; import javafx.embed.swing.SwingFXUtils; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyEvent; import javafx.scene.layout.VBox; import javafx.scene.text.TextFlow; import javafx.stage.Window; import org.apache.commons.lang3.SystemUtils; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignL; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.EnumSet; import java.util.List; import java.util.ResourceBundle; import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.isNotBlank; public class EditorView extends VBox { private static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination ENTER_INSERT_KEY = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN); private static final KeyCodeCombination MAKE_BOLD = new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_ITALIC = new KeyCodeCombination(KeyCode.I, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_CODE = new KeyCodeCombination(KeyCode.K, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_LINK = new KeyCodeCombination(KeyCode.L, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_UNORDERED_LIST = new KeyCodeCombination(KeyCode.U, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_ORDERED_LIST = new KeyCodeCombination(KeyCode.U, KeyCombination.SHIFT_DOWN, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_QUOTE = new KeyCodeCombination(KeyCode.Q, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_HEADER_1 = new KeyCodeCombination(KeyCode.DIGIT1, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_HEADER_2 = new KeyCodeCombination(KeyCode.DIGIT2, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_HEADER_3 = new KeyCodeCombination(KeyCode.DIGIT3, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_HEADER_4 = new KeyCodeCombination(KeyCode.DIGIT4, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_HEADER_5 = new KeyCodeCombination(KeyCode.DIGIT5, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination MAKE_HEADER_6 = new KeyCodeCombination(KeyCode.DIGIT6, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination PREVIEW = new KeyCodeCombination(KeyCode.F12); private static final KeyCodeCombination REDO = new KeyCodeCombination(KeyCode.Z, KeyCombination.SHIFT_DOWN, KeyCombination.SHORTCUT_DOWN); private static final int IMAGE_WIDTH_MAX = 640; private static final int IMAGE_HEIGHT_MAX = 480; private static final int IMAGE_MAXIMUM_SIZE = 31000; // Same as the one in chat private static final Pattern URL_DETECTOR = Pattern.compile("(^mailto:.*$|^\\p{Ll}.{1,30}://.*$)"); @FXML private ToolBar toolBar; @FXML private Button undo; @FXML private Button redo; @FXML private Button bold; @FXML private Button italic; @FXML private Button hyperlink; @FXML private Button quote; @FXML private Button code; @FXML private Button unorderedList; @FXML private Button orderedList; @FXML private MenuButton heading; @FXML private MenuItem header1; @FXML private MenuItem header2; @FXML private MenuItem header3; @FXML private MenuItem header4; @FXML private MenuItem header5; @FXML private MenuItem header6; @FXML private ToggleButton preview; @FXML private TextArea editor; @FXML private ScrollPane previewPane; @FXML private TextFlow previewContent; private int typingCount; private MarkdownService markdownService; private final ResourceBundle bundle; public final ReadOnlyIntegerWrapper lengthProperty = new ReadOnlyIntegerWrapper(); private final BooleanProperty previewOnly = new SimpleBooleanProperty(false); private UriAction uriAction; public EditorView() { bundle = I18nUtils.getBundle(); var loader = new FXMLLoader(EditorView.class.getResource("/view/custom/editor_view.fxml"), bundle); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } @FXML private void initialize() { undo.disableProperty().bind(editor.undoableProperty().not()); undo.setOnAction(_ -> editor.undo()); redo.disableProperty().bind(editor.redoableProperty().not()); redo.setOnAction(_ -> editor.redo()); bold.setOnAction(_ -> surround("**")); italic.setOnAction(_ -> surround("_")); code.setOnAction(_ -> makeCode()); quote.setOnAction(_ -> prefixLines(">")); unorderedList.setOnAction(_ -> insertNextLine("-")); orderedList.setOnAction(_ -> insertNextLine("1.")); header1.setOnAction(_ -> makeHeader(1)); header2.setOnAction(_ -> makeHeader(2)); header3.setOnAction(_ -> makeHeader(3)); header4.setOnAction(_ -> makeHeader(4)); header5.setOnAction(_ -> makeHeader(5)); header6.setOnAction(_ -> makeHeader(6)); hyperlink.setOnAction(event -> insertUrl(UiUtils.getWindow(event))); editor.addEventFilter(KeyEvent.KEY_PRESSED, this::handleInputKeys); previewPane.addEventFilter(KeyEvent.KEY_PRESSED, event -> { if (PREVIEW.match(event)) { preview.fire(); } }); preview.setOnAction(_ -> { var selected = preview.isSelected(); if (selected) { undo.disableProperty().unbind(); redo.disableProperty().unbind(); undo.setDisable(true); redo.setDisable(true); editor.setVisible(false); var contents = markdownService.parse(editor.getText(), EnumSet.noneOf(Rendering.class), null); previewContent.getChildren().addAll(contents.stream() .map(Content::getNode).toList()); previewPane.setVisible(true); previewPane.requestFocus(); } else { undo.setDisable(false); redo.setDisable(false); undo.disableProperty().bind(editor.undoableProperty().not()); redo.disableProperty().bind(editor.redoableProperty().not()); editor.setVisible(true); previewPane.setVisible(false); previewContent.getChildren().clear(); editor.requestFocus(); } bold.setDisable(selected); italic.setDisable(selected); hyperlink.setDisable(selected); quote.setDisable(selected); code.setDisable(selected); unorderedList.setDisable(selected); orderedList.setDisable(selected); heading.setDisable(selected); }); lengthProperty.bind(editor.lengthProperty()); previewOnly.addListener((_, _, newValue) -> { if (Boolean.TRUE.equals(newValue)) { UiUtils.setAbsent(toolBar); editor.setVisible(false); previewPane.setVisible(true); } else { UiUtils.setPresent(toolBar); editor.setVisible(true); previewPane.setVisible(false); } }); } private void makeCode() { var selection = editor.getSelection(); if (selection.getLength() <= 0) { if (isBeginningOfLine(editor.getCaretPosition())) { surround("```\n", "\n```"); } else { surround("`"); } } else { if (isParagraphBoundaries()) { surround("```\n", "\n```"); } else { surround("`"); } } editor.requestFocus(); } private void makeHeader(int level) { insertNextLine("#".repeat(Math.max(0, level))); } private void handleInputKeys(KeyEvent event) { typingCount++; if (PASTE_KEY.match(event)) { if (handlePaste(editor)) { event.consume(); } } else if (ENTER_INSERT_KEY.match(event)) { completeStatement(); } else if (MAKE_BOLD.match(event)) { bold.fire(); } else if (MAKE_ITALIC.match(event)) { italic.fire(); } else if (MAKE_CODE.match(event)) { makeCode(); } else if (MAKE_LINK.match(event)) { insertUrl(UiUtils.getWindow(event)); } else if (MAKE_UNORDERED_LIST.match(event)) { unorderedList.fire(); } else if (MAKE_ORDERED_LIST.match(event)) { orderedList.fire(); } else if (MAKE_QUOTE.match(event)) { quote.fire(); } else if (MAKE_HEADER_1.match(event)) { makeHeader(1); } else if (MAKE_HEADER_2.match(event)) { makeHeader(2); } else if (MAKE_HEADER_3.match(event)) { makeHeader(3); } else if (MAKE_HEADER_4.match(event)) { makeHeader(4); } else if (MAKE_HEADER_5.match(event)) { makeHeader(5); } else if (MAKE_HEADER_6.match(event)) { makeHeader(6); } else if (PREVIEW.match(event)) { preview.fire(); } else if (SystemUtils.IS_OS_WINDOWS && REDO.match(event)) { editor.redo(); } } public void setUriAction(UriAction uriAction) { this.uriAction = uriAction; } public void setMarkdown(InputStream input) { if (!previewOnly.get()) { throw new IllegalStateException("Markdown file can only be set to an EditorView in previewOnly mode"); } if (markdownService == null) { throw new IllegalStateException("Use setMarkdownService() to set a markdown service before"); } List contents; try { contents = markdownService.parse(new String(input.readAllBytes(), StandardCharsets.UTF_8), EnumSet.noneOf(Rendering.class), uriAction); } catch (IOException e) { contents = List.of(new ContentText("Couldn't open markdown file " + input + " (" + e.getMessage() + ")")); } previewContent.getChildren().clear(); previewContent.getChildren().addAll(contents.stream() .map(Content::getNode).toList()); previewPane.setVvalue(0.0); // Move to the top of the new page } /** * Sets the markdown service. If it is set, then the EditorView automatically gets a preview button. * * @param markdownService the markdown service */ public void setMarkdownService(MarkdownService markdownService) { this.markdownService = markdownService; UiUtils.setPresent(preview); } public String getText() { return editor.getText(); } public void setText(String text) { editor.setText(text); } public void setReply(String reply) { if (!reply.isBlank()) { reply = "\n\n> " + reply.replace("\n", "\n> "); if (reply.endsWith("\n> ")) { reply = reply.substring(0, reply.length() - 3); } } editor.setText(reply); editor.positionCaret(0); editor.requestFocus(); } public void setInputContextMenu(LocationClient locationClient) { TextInputControlUtils.addEnhancedInputContextMenu(editor, locationClient, this::handlePaste); } public boolean isModified() { return typingCount >= 2; } public boolean isPreviewOnly() { return previewOnly.get(); } public void setPreviewOnly(boolean previewOnly) { this.previewOnly.set(previewOnly); } public void setPrompt(String text) { editor.setPromptText(text); } public String getPrompt() { return editor.getPromptText(); } // XXX: remove! private void surround(String text) { var selection = editor.getSelection(); if (selection.getLength() <= 0) { var pos = editor.getCaretPosition(); editor.insertText(pos, text + text); editor.positionCaret(pos + text.length()); } else { var trailingSpace = editor.getText(selection.getEnd() - 1, selection.getEnd()).equals(" "); editor.insertText(selection.getStart(), text); var end = selection.getEnd() + text.length(); if (trailingSpace) { end--; } editor.insertText(end, text); editor.positionCaret(end + text.length() + 1); } editor.requestFocus(); } private void surround(String before, String after) { var selection = editor.getSelection(); if (selection.getLength() <= 0) { var pos = editor.getCaretPosition(); editor.insertText(pos, before + after); editor.positionCaret(pos + before.length()); } else { var trailingSpace = editor.getText(selection.getEnd() - 1, selection.getEnd()).equals(" "); editor.insertText(selection.getStart(), before); var end = selection.getEnd() + before.length(); if (trailingSpace) { end--; } editor.insertText(end, after); editor.positionCaret(end + after.length() + 1); } editor.requestFocus(); } private void prefixLines(String text) { var selection = editor.getSelection(); if (selection.getLength() <= 0) { prefixSingleLine(text); } else { prefixParagraph(text, selection); editor.insertText(editor.getCaretPosition(), "\n\n"); } editor.requestFocus(); } private void prefixSingleLine(String text) { var pos = editor.getCaretPosition(); if (isBeginningOfLine(pos)) { editor.insertText(pos, text + " "); } } private void prefixParagraph(String text, IndexRange selection) { if (isParagraphBoundaries()) { var start = selection.getStart(); int end; var selectionEnd = selection.getEnd(); var textToInsert = text + (text.isBlank() ? "" : " "); // spacing is not needed for indentation or so while (start <= selectionEnd) { end = editor.getText(start, selectionEnd).indexOf("\n"); if (end == -1) { end = selectionEnd; } else { end += start; } editor.insertText(start, textToInsert); editor.positionCaret(end + 1 + textToInsert.length()); selectionEnd += textToInsert.length(); start = end + 1 + textToInsert.length(); } } } private void insertNextLine(String text) { var selection = editor.getSelection(); if (selection.getLength() <= 0) { var pos = editor.getCaretPosition(); if (isBeginningOfLine(pos)) { editor.insertText(pos, text + " "); } else { editor.insertText(pos, "\n" + text + " "); } } else { var selectedText = editor.getText(selection.getStart(), selection.getEnd()); if (!selectedText.contains("\n") && selection.getEnd() == editor.getLength()) { editor.insertText(selection.getStart(), "\n" + text + " "); } } editor.requestFocus(); } private boolean isBeginningOfLine(int pos) { return pos == 0 || editor.getText(pos - 1, pos).equals("\n"); } private void insertUrl(Window parent) { var selection = editor.getSelection(); var dialog = new TextInputDialog(); dialog.setTitle(bundle.getString("editor.hyperlink.enter")); dialog.setHeaderText(null); dialog.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT)); dialog.initOwner(parent); dialog.showAndWait().ifPresent(link -> { if (isNotBlank(link)) { if (!URL_DETECTOR.matcher(link).matches()) { link = "https://" + link; } if (selection.getLength() <= 0) { var pos = editor.getCaretPosition(); editor.insertText(pos, "[](" + link + ")"); editor.positionCaret(pos + 1); } else { editor.insertText(selection.getStart(), "["); editor.insertText(editor.getText(selection.getEnd(), selection.getEnd() + 1).equals(" ") ? selection.getEnd() : (selection.getEnd() + 1), "](" + link + ")"); } } editor.requestFocus(); }); } private boolean isParagraphBoundaries() { var selection = editor.getSelection(); var start = selection.getStart(); var end = selection.getEnd(); return (start == 0 || editor.getText(start - 1, start).equals("\n")) && (editor.getText(end - 1, end).equals("\n") || end == editor.getLength() || editor.getText(end, end + 1).equals("\n")); } private boolean handlePaste(TextInputControl textInputControl) { var object = ClipboardUtils.getSupportedObjectFromClipboard(); return switch (object) { case Image image -> { var imageView = new ImageView(image); ImageViewUtils.limitMaximumImageSize(imageView, IMAGE_WIDTH_MAX * IMAGE_HEIGHT_MAX); var imgData = ImageUtils.writeImage(SwingFXUtils.fromFXImage(imageView.getImage(), null), IMAGE_MAXIMUM_SIZE); textInputControl.insertText(textInputControl.getCaretPosition(), "![](" + imgData + ")"); yield true; } case String string -> { textInputControl.insertText(textInputControl.getCaretPosition(), string); yield true; } case null, default -> false; }; } /** * Inserts a new line without cutting the current line. */ private void completeStatement() { var s = editor.getText(editor.getCaretPosition(), editor.getLength()); var eol = s.indexOf('\n'); if (eol == -1) { eol = s.length(); } editor.insertText(editor.getCaretPosition() + eol, "\n"); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/ImageSelectorView.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.micrometer.common.util.StringUtils; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.custom.asyncimage.PlaceholderImageView; import javafx.beans.NamedArg; import javafx.beans.property.ObjectProperty; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.control.Button; import javafx.scene.image.Image; import javafx.scene.layout.StackPane; import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.ResourceBundle; import java.util.function.Function; /** * A class that allows to select/remove an image. It can be supplied with a placeholder to show when there's * no image selected yet. The placeholder is an iconLiteral from FontIcon (for example mdi2i-image-plus). */ public class ImageSelectorView extends StackPane { private static final double BUTTON_OPACITY = 0.8; @FXML private PlaceholderImageView placeholderImageView; @FXML private Button selectButton; @FXML private Button deleteButton; private boolean deletable = true; private final Double fitWidth; private final Double fitHeight; private final String placeholder; private final Boolean autoResize; private final ResourceBundle bundle; private String url; public ImageSelectorView(@NamedArg(value = "fitWidth", defaultValue = "64.0") Double fitWidth, @NamedArg(value = "fitHeight", defaultValue = "64.0") Double fitHeight, @NamedArg(value = "placeholder") String placeholder, @NamedArg(value = "autoResize", defaultValue = "false") Boolean autoResize) { super(); bundle = I18nUtils.getBundle(); this.fitWidth = fitWidth; this.fitHeight = fitHeight; this.placeholder = placeholder; this.autoResize = autoResize; var loader = new FXMLLoader(ImageSelectorView.class.getResource("/view/custom/image_selector_view.fxml"), bundle); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } @FXML private void initialize() { if (fitWidth != null && fitWidth != 0) { placeholderImageView.setFitWidth(fitWidth); } if (fitHeight != null && fitHeight != 0) { placeholderImageView.setFitHeight(fitHeight); } if (autoResize != null) { placeholderImageView.setPreserveRatio(autoResize); } if (StringUtils.isNotBlank(placeholder)) { placeholderImageView.setIconLiteral(placeholder); placeholderImageView.showDefault(); } computeActionText(); placeholderImageView.setOnMouseEntered(_ -> setImageOpacity(BUTTON_OPACITY)); placeholderImageView.setOnMouseExited(_ -> setImageOpacity(0.0)); selectButton.setOnMouseEntered(_ -> setImageOpacity(BUTTON_OPACITY)); selectButton.setOnMouseExited(_ -> setImageOpacity(0.0)); deleteButton.setOnMouseEntered(_ -> setImageOpacity(BUTTON_OPACITY)); deleteButton.setOnMouseExited(_ -> setImageOpacity(0.0)); placeholderImageView.imageProperty().addListener((_, _, newValue) -> { if (newValue != null && !newValue.isError()) { if (deletable) { deleteButton.setVisible(true); } } else { if (deletable) { deleteButton.setVisible(false); } } computeActionText(); }); } public ObjectProperty imageProperty() { return placeholderImageView.imageProperty(); } /** * Sets the image loader. Only needed when loading from a URL is needed. * * @param loader the loader */ public void setImageLoader(Function loader) { placeholderImageView.setLoader(loader); } /** * Sets the image cache. Only needed when images are frequently loaded. * @param imageCache the image cache */ public void setImageCache(ImageCache imageCache) { placeholderImageView.setImageCache(imageCache); } /** * Sets the image URL. It will be loaded asynchronously. * @param url the url */ public void setImageUrl(String url) { this.url = url; placeholderImageView.setUrl(url); } /// Sets the file. It will be loaded asynchronously. Very useful when using a requester, for example: /// /// ```java /// File selectedFile = fileChooser.showOpenDialog(getWindow(event)); /// imageSelectorView.setFile(selectedFile); /// ``` /// /// @param file the file to load public void setFile(File file) { if (file != null && file.canRead()) { url = file.toURI().toASCIIString(); placeholderImageView.setUrl(url); } } /** * Gets the URL that this SelectorView was set to. Also returns file URLs. * * @return the URL, null if not URL was set */ public String getUrl() { return url; } /** * Gets the file that was set to this SelectorView, if any. * * @return the file, null if no file was set or the source image didn't come from any */ public File getFile() { if (url == null) { return null; } File file; try { file = new File(new URI(url)); } catch (URISyntaxException | IllegalArgumentException _) { return null; } if (file.canRead()) { return file; } return null; } /** * Sets the image that will be shown. * @param image the image */ public void setImage(Image image) { url = null; placeholderImageView.updateImage(image); } /** * The action to execute when the image selector button is pressed. * @param value the action event */ public void setOnSelectAction(EventHandler value) { selectButton.setOnAction(value); } /** * The action to execute when the image removal button is pressed. * @param value the action event */ public void setOnDeleteAction(EventHandler value) { deleteButton.setOnAction(value); } /** * Checks if there's an image set at all. * @return true if there's no image */ public boolean isEmpty() { return placeholderImageView.getImage() == null; } /** * Shows the edit buttons. * * @param editable true if the image can be added and removed */ public void setEditable(boolean editable) { setEditable(editable, editable); } /** * Shows the edit buttons. *

This version is needed in case an image is not a real image (for example autogenerated) * and hence the delete button would make no sense and has to be set to false. * * @param editable true if an image can be added * @param deletable true if the image can also be removed */ public void setEditable(boolean editable, boolean deletable) { this.deletable = deletable; selectButton.setVisible(editable); if (deletable) { deleteButton.setVisible(placeholderImageView.getImage() != null && !placeholderImageView.getImage().isError()); } else { deleteButton.setVisible(false); } } private void setImageOpacity(double opacity) { selectButton.setOpacity(opacity); if (placeholderImageView.getImage() != null) { deleteButton.setOpacity(opacity); } } private void computeActionText() { if (placeholderImageView.getImage() == null) { if (fitWidth <= 64) { selectButton.setText(bundle.getString("add")); } else { selectButton.setText(bundle.getString("image-selector-view.add-image")); } } else { if (fitWidth <= 64) { selectButton.setText(bundle.getString("image-selector-view.change-image-short")); } else { selectButton.setText(bundle.getString("image-selector-view.change-image")); } } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/InfoView.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.ui.custom.asyncimage.AsyncImageView; import io.xeres.ui.support.util.TextFlowDragSelection; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Node; import javafx.scene.control.ScrollPane; import javafx.scene.text.TextFlow; import org.apache.commons.collections4.CollectionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.List; import java.util.function.Function; public class InfoView extends ScrollPane { private static final Logger log = LoggerFactory.getLogger(InfoView.class); @FXML private AsyncImageView image; @FXML private TextFlow header; @FXML private TextFlow body; public InfoView() { var loader = new FXMLLoader(InfoView.class.getResource("/view/custom/info_view.fxml")); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } @FXML private void initialize() { TextFlowDragSelection.enableSelection(header, this); TextFlowDragSelection.enableSelection(body, this); } public void setLoader(Function loader) { image.setLoader(loader); } public void setInfo(List header, List body) { setInfo(header, body, null, 0, 0); } public void setInfo(List header, List body, String imageUrl, int imageWidth, int imageHeight) { if (imageUrl != null && imageWidth > 0 && imageHeight > 0) { if (image.hasLoader()) { image.setFitWidth(imageWidth); image.setFitHeight(imageHeight); image.setUrl(imageUrl); } else { log.warn("InfoView has no loader set, url {} cannot be loaded. use setLoader() first", imageUrl); } } else { if (imageUrl != null) { log.warn("image width and height not supplied for url {}, not loading image", imageUrl); } image.setUrl(null); image.setFitWidth(0); image.setFitHeight(0); } if (CollectionUtils.isNotEmpty(header)) { this.header.getChildren().setAll(header); } else { this.header.getChildren().clear(); } if (CollectionUtils.isNotEmpty(body)) { this.body.getChildren().setAll(body); } else { this.body.getChildren().clear(); } setVvalue(getVmin()); // Needed when the content is smaller than the height, I think } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/InputArea.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.common.util.OsUtils; import io.xeres.ui.custom.alias.PopupAlias; import io.xeres.ui.custom.event.StickerSelectedEvent; import io.xeres.ui.support.util.UiUtils; import javafx.beans.binding.Bindings; import javafx.beans.value.ChangeListener; import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.control.ScrollPane; import javafx.scene.control.TextArea; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyEvent; import javafx.scene.layout.Region; import javafx.scene.text.Text; import javafx.stage.Popup; import javafx.stage.PopupWindow; import org.apache.commons.lang3.StringUtils; import java.nio.file.Path; import java.util.Set; /** * Input area widget. *

Autogrow system by Dirk Lemmermann, see * GemsFX */ public class InputArea extends TextArea { private static final String STICKERS_DIRECTORY = "Stickers"; private static final KeyCodeCombination CTRL_S = new KeyCodeCombination(KeyCode.S, KeyCombination.CONTROL_DOWN); private Text text; private double offsetTop; private double offsetBottom; private PopupAlias popupAlias; public InputArea() { this(""); } public InputArea(String text) { super(text); setWrapText(true); sceneProperty().addListener(observable -> { if (getScene() != null) { performBinding(); } }); skinProperty().addListener(observable -> { if (getSkin() != null) { performBinding(); } }); addEventFilter(KeyEvent.KEY_PRESSED, this::handleInputKeys); addEventFilter(KeyEvent.KEY_TYPED, this::handleTypedKeys); } public void openStickerSelector() { handleStickers(); } private void handleInputKeys(KeyEvent event) { if (CTRL_S.match(event)) { if (handleStickers()) { event.consume(); } } else if ((event.getCode() == KeyCode.BACK_SPACE && StringUtils.defaultString(getText()).length() == 1) && popupAlias != null) { popupAlias.hide(); } } private void handleTypedKeys(KeyEvent event) { if (event.getCharacter().equals("/") && StringUtils.defaultString(getText()).isEmpty()) // Only open if we type '/' alone { handleAliases(); } } private boolean handleStickers() { var bounds = localToScreen(getBoundsInLocal()); var popup = new Popup(); var stickerView = new StickerView(); popup.getContent().add(stickerView); popup.setAnchorX(bounds.getMinX()); popup.setAnchorY(bounds.getMinY()); popup.setAnchorLocation(PopupWindow.AnchorLocation.CONTENT_BOTTOM_LEFT); // Proxy the event to the InputArea stickerView.addEventHandler(StickerSelectedEvent.STICKER_SELECTED, event -> { event.consume(); fireEvent(new StickerSelectedEvent(event.getPath())); popup.hide(); }); popup.show(UiUtils.getWindow(this)); stickerView.loadStickers( Path.of(OsUtils.getApplicationHome().toString(), STICKERS_DIRECTORY), Path.of(OsUtils.getDataDir().toString(), STICKERS_DIRECTORY)); popup.setAutoHide(true); return true; } private void handleAliases() { var bounds = localToScreen(getBoundsInLocal()); popupAlias = new PopupAlias(bounds, alias -> { if (StringUtils.isNotEmpty(alias)) { if (getText().length() < alias.length()) // Only complete if we're not ahead already by entering arguments and so on (they would get removed) { setText(alias); positionCaret(StringUtils.defaultString(getText()).length()); } } }); ChangeListener changeListener = (observable, oldValue, newValue) -> popupAlias.setFilter(newValue); textProperty().addListener(changeListener); popupAlias.show(UiUtils.getWindow(this)); popupAlias.setOnHidden(windowEvent -> { textProperty().removeListener(changeListener); popupAlias = null; }); } private double computeHeight() { computeOffsets(); var bounds = localToScreen(text.getLayoutBounds()); if (bounds != null) { var minY = bounds.getMinY(); var maxY = bounds.getMaxY(); return maxY - minY + offsetTop + offsetBottom; } return 0.0; } private void computeOffsets() { offsetTop = getInsets().getTop(); offsetBottom = getInsets().getBottom(); var scrollPane = (ScrollPane) lookup(".scroll-pane"); if (scrollPane != null) { var viewport = (Region) scrollPane.lookup(".viewport"); var content = (Region) scrollPane.lookup(".content"); offsetTop += viewport.getInsets().getTop(); offsetTop += content.getInsets().getTop(); offsetBottom += viewport.getInsets().getBottom(); offsetBottom += content.getInsets().getBottom(); } } private void performBinding() { var scrollPane = (ScrollPane) lookup(".scroll-pane"); if (scrollPane != null) { scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); scrollPane.skinProperty().addListener(it -> { if (scrollPane.getSkin() != null) { if (text == null) { text = findTextNode(); if (text != null) { prefHeightProperty().bind(Bindings.createDoubleBinding(this::computeHeight, text.layoutBoundsProperty())); } } } }); } } private Text findTextNode() { final Set nodes = lookupAll(".text"); for (Node node : nodes) { if (node.getParent() instanceof Group) { return (Text) node; } } return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/InputAreaGroup.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.client.LocationClient; import io.xeres.ui.custom.event.FileSelectedEvent; import io.xeres.ui.custom.event.ImageSelectedEvent; import io.xeres.ui.support.util.ChooserUtils; import io.xeres.ui.support.util.TextInputControlUtils; import io.xeres.ui.support.util.UiUtils; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.control.Button; import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; import javafx.scene.control.TextInputControl; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; import javafx.stage.FileChooser; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignA; import org.kordamp.ikonli.materialdesign2.MaterialDesignF; import java.io.IOException; import java.util.ResourceBundle; import java.util.function.Consumer; import static io.xeres.ui.support.util.UiUtils.getWindow; public class InputAreaGroup extends HBox { @FXML private InputArea inputArea; @FXML private Button addMedia; @FXML private Button addSticker; @FXML private Button callButton; private final ResourceBundle bundle; public InputAreaGroup() { bundle = I18nUtils.getBundle(); var loader = new FXMLLoader(InputAreaGroup.class.getResource("/view/custom/input_area_group.fxml"), bundle); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } public ReadOnlyBooleanProperty callPressedProperty() { return callButton.pressedProperty(); } @FXML private void initialize() { disabledProperty().addListener((_, _, newValue) -> { addMedia.setDisable(newValue); addSticker.setDisable(newValue); }); addSticker.setOnAction(_ -> inputArea.openStickerSelector()); createAddMediaContextMenu(); } public void clear() { inputArea.clear(); } public void addKeyFilter(EventHandler eventFilter) { inputArea.addEventFilter(KeyEvent.KEY_PRESSED, eventFilter); } public void addEnhancedContextMenu(Consumer pasteAction) { addEnhancedContextMenu(pasteAction, null); } public void addEnhancedContextMenu(Consumer pasteAction, LocationClient locationClient) { TextInputControlUtils.addEnhancedInputContextMenu(inputArea, locationClient, pasteAction); } public TextInputControl getTextInputControl() { return inputArea; } @Override public void requestFocus() { inputArea.requestFocus(); } /** * Sets the input area to offline mode. Sending images, files and stickers will be disabled, but * text can still be entered. * * @param offline true if offline */ public void setOffline(boolean offline) { addMedia.setDisable(offline); addSticker.setDisable(offline); } public void setVoipCapable(boolean voipCapable) { UiUtils.setPresent(callButton, voipCapable); } private void createAddMediaContextMenu() { var addImageItem = new MenuItem(bundle.getString("messaging.action.send-inline")); addImageItem.setGraphic(new FontIcon(MaterialDesignF.FILE_IMAGE_OUTLINE)); addImageItem.setOnAction(event -> { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("messaging.file-requester.send-picture")); ChooserUtils.setSupportedLoadImageFormats(fileChooser); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); if (selectedFile != null) { fireEvent(new ImageSelectedEvent(selectedFile)); } }); var addFileItem = new MenuItem(bundle.getString("messaging.action.send-file")); addFileItem.setGraphic(new FontIcon(MaterialDesignA.ATTACHMENT)); addFileItem.setOnAction(event -> { var fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("messaging.file-requester.send-file")); var selectedFile = fileChooser.showOpenDialog(getWindow(event)); if (selectedFile != null) { fireEvent(new FileSelectedEvent(selectedFile)); } }); var contextMenu = new ContextMenu(addImageItem, addFileItem); addMedia.setOnContextMenuRequested(event -> { contextMenu.show(addMedia, event.getScreenX(), event.getScreenY()); event.consume(); }); UiUtils.setOnPrimaryMouseClicked(addMedia, event -> contextMenu.show(addMedia, event.getScreenX(), event.getScreenY())); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/NullSelectionModel.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.control.MultipleSelectionModel; /** * Allows to disable the selection; for example, in listviews. */ public class NullSelectionModel extends MultipleSelectionModel { @Override public ObservableList getSelectedIndices() { return FXCollections.emptyObservableList(); } @Override public ObservableList getSelectedItems() { return FXCollections.emptyObservableList(); } @Override public void selectIndices(int index, int... indices) { // Disabled } @Override public void selectAll() { // Disabled } @Override public void clearAndSelect(int index) { // Disabled } @Override public void select(int index) { // Disabled } @Override public void select(T obj) { // Disabled } @Override public void clearSelection(int index) { // Disabled } @Override public void clearSelection() { // Disabled } @Override public boolean isSelected(int index) { return false; } @Override public boolean isEmpty() { return false; } @Override public void selectPrevious() { // Disabled } @Override public void selectNext() { // Disabled } @Override public void selectFirst() { // Disabled } @Override public void selectLast() { // Disabled } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/ProgressPane.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import javafx.scene.control.ProgressIndicator; import javafx.scene.layout.StackPane; import java.time.Duration; /** * Pane showing an intelligent undetermined progress. */ public class ProgressPane extends StackPane { private static final Duration PROGRESS_SHOW_DELAY = Duration.ofMillis(250); private ProgressIndicator progressIndicator; private DelayedAction delayedAction; /** * Shows the progress, but only after a certain delay, to avoid UI flickering in case the progress is quick. * * @param show {@code true} to show the progress, {@code false} to remove it. */ public void showProgress(boolean show) { setupProgressIndicatorIfNeeded(); if (show) { delayedAction.run(); } else { delayedAction.abort(); } } private void showProgressIndicator(boolean show) { getChildrenUnmodifiable().getFirst().setVisible(!show); progressIndicator.setVisible(show); } private void setupProgressIndicatorIfNeeded() { if (progressIndicator != null) { return; } if (getChildrenUnmodifiable().size() == 1) { progressIndicator = new ProgressIndicator(); progressIndicator.setVisible(false); getChildren().add(progressIndicator); } else { throw new IllegalStateException("Progress indicator is only supported if there's 1 children"); } delayedAction = new DelayedAction( () -> showProgressIndicator(true), () -> showProgressIndicator(false), PROGRESS_SHOW_DELAY); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/ReadOnlyTextField.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.support.util.UiUtils; import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.TextField; import java.util.List; import java.util.ResourceBundle; /** * A TextField that is used for read-only fields (like displaying some informative, yet important value). It features: *

*

    *
  • explanatory look *
  • automatic selection when clicking for easy cut & pasting *
  • context menu to disable the selection *
*/ public class ReadOnlyTextField extends TextField { private static final ResourceBundle bundle = I18nUtils.getBundle(); @SuppressWarnings("unused") public ReadOnlyTextField() { super(); init(); } @SuppressWarnings("unused") public ReadOnlyTextField(String text) { super(text); init(); } private void init() { UiUtils.setOnPrimaryMouseClicked(this, event -> selectAll()); setEditable(false); setContextMenu(createContextMenu()); } private ContextMenu createContextMenu() { var contextMenu = new ContextMenu(); contextMenu.getItems().addAll(createDefaultMenuItems()); var deselect = new MenuItem(bundle.getString("deselect-all")); deselect.setOnAction(event -> deselect()); contextMenu.getItems().addAll(new SeparatorMenuItem(), deselect); return contextMenu; } private List createDefaultMenuItems() { var copy = new MenuItem(bundle.getString("copy")); copy.setOnAction(event -> copy()); return List.of(copy); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/ResizeableImageView.java ================================================ package io.xeres.ui.custom; import io.xeres.ui.custom.asyncimage.AsyncImageView; /** * Modified image class that allows unlimited up-scaling. */ public class ResizeableImageView extends AsyncImageView { private static final double MINIMUM_SIZE = 32.0; public ResizeableImageView() { super(); setPreserveRatio(false); } @Override public double minWidth(double height) { return MINIMUM_SIZE; } @Override public double prefWidth(double height) { var image = getImage(); if (image == null) { return minWidth(height); } return image.getWidth(); } @Override public double maxWidth(double height) { return Double.MAX_VALUE; } @Override public double minHeight(double width) { return MINIMUM_SIZE; } @Override public double prefHeight(double width) { var image = getImage(); if (image == null) { return minHeight(width); } return image.getHeight(); } @Override public double maxHeight(double width) { return Double.MAX_VALUE; } @Override public boolean isResizable() { return true; } @Override public void resize(double width, double height) { setFitWidth(width); setFitHeight(height); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/StickerView.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.common.i18n.I18nUtils; import io.xeres.ui.custom.event.StickerSelectedEvent; import io.xeres.ui.support.util.ImageViewUtils; import io.xeres.ui.support.util.TooltipUtils; import io.xeres.ui.support.util.UiUtils; import javafx.application.Platform; import javafx.concurrent.Task; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.geometry.Insets; import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.Pane; import javafx.scene.layout.VBox; import javafx.scene.text.TextFlow; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.text.MessageFormat; import java.util.*; import java.util.regex.Pattern; import java.util.stream.Stream; public class StickerView extends VBox { private static final Logger log = LoggerFactory.getLogger(StickerView.class); private static final int IMAGE_COLLECTION_WIDTH = 48; private static final int IMAGE_COLLECTION_HEIGHT = 48; private static final int IMAGE_WIDTH = 192; private static final int IMAGE_HEIGHT = 192; private static final Pattern PATTERN_ORDERED_NAME = Pattern.compile("^(\\d{1,3}\\.)(.*?)(\\.\\w{1,10})?$"); @FXML private TabPane tabPane; private final ResourceBundle bundle; public StickerView() { bundle = I18nUtils.getBundle(); var loader = new FXMLLoader(StickerView.class.getResource("/view/custom/sticker_view.fxml")); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } public void loadStickers(Path localPath, Path userPath) { Task> task = new Task<>() { @Override protected List call() throws Exception { List stickerCollections = new ArrayList<>(); if (Files.isDirectory(localPath)) { try (var stream = Files.find(localPath, 1, (dirPath, bfa) -> bfa.isDirectory() && !dirPath.equals(localPath))) { stickerCollections.addAll(processStickers(stream)); } } if (Files.isDirectory(userPath)) { log.debug("Found sticker collections directory in {}", userPath); try (var stream = Files.find(userPath, 1, (dirPath, bfa) -> bfa.isDirectory() && !dirPath.equals(userPath))) { stickerCollections.addAll(processStickers(stream)); } } return stickerCollections.stream() .sorted(Comparator.comparing(StickerCollectionEntry::name)) .toList(); } }; task.setOnSucceeded(event -> { @SuppressWarnings("unchecked") var stickers = (List) event.getSource().getValue(); if (stickers.isEmpty()) { tabPane.getTabs().add(new Tab("", new Label(MessageFormat.format(bundle.getString("stickers.instructions"), userPath)))); } tabPane.getTabs().addAll(stickers.stream() .map(sticker -> { Tab tab = null; if (sticker.image() != null && !sticker.image().isError()) { tab = new Tab(); tab.setTooltip(new Tooltip(buildStickerName(sticker.name()))); var imageView = new ImageView(sticker.image()); imageView.setPickOnBounds(true); // make transparent areas clickable ImageViewUtils.limitMaximumImageSize(imageView, IMAGE_COLLECTION_WIDTH, IMAGE_COLLECTION_HEIGHT); ImageViewUtils.disableOutputScaling(imageView, tabPane); tab.setGraphic(imageView); tab.setUserData(sticker.path()); } return tab; }) .filter(Objects::nonNull) .toList()); setupTabSelection(); }); Thread.ofVirtual().name("Stickers Collection Directory Loader").start(task); } private List processStickers(Stream stream) { return stream .map(filePath -> new StickerCollectionEntry(filePath.getFileName().toString(), filePath, getStickerMainImage(filePath))) .toList(); } private String buildStickerName(String name) { var matcher = PATTERN_ORDERED_NAME.matcher(name); if (matcher.matches()) { return matcher.group(2); } return name; } private void setupTabSelection() { if (!tabPane.getTabs().isEmpty()) { loadTab(tabPane.getSelectionModel().getSelectedIndex()); } tabPane.getSelectionModel().selectedIndexProperty().addListener((_, _, newValue) -> loadTab(newValue.intValue())); } private void loadTab(int index) { var tab = tabPane.getTabs().get(index); if (tab.getContent() == null) { var path = (Path) tab.getUserData(); var textFlow = new TextFlow(); textFlow.setPrefWidth(600.0); textFlow.setPadding(new Insets(8.0)); UiUtils.setOnPrimaryMouseClicked(textFlow, event -> { if (event.getTarget() instanceof ImageView imageView) { fireEvent(new StickerSelectedEvent((Path) imageView.getUserData())); } }); var scrollPane = new ScrollPane(textFlow); scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); tab.setContent(scrollPane); Task task = new Task<>() { @Override protected Void call() throws Exception { if (Files.isDirectory(path)) { try (var stream = Files.find(path, 1, (_, bfa) -> bfa.isRegularFile())) { stream .sorted(Comparator.comparing(filePath -> filePath.getFileName().toString())) .forEach(filePath -> { var image = openImage(filePath); if (image != null && !image.isError()) { Platform.runLater(() -> { var imageView = new ImageView(image); imageView.setPickOnBounds(true); // make transparent areas clickable ImageViewUtils.limitMaximumImageSize(imageView, IMAGE_WIDTH, IMAGE_HEIGHT); ImageViewUtils.disableOutputScaling(imageView, tabPane); imageView.setUserData(filePath); imageView.getStyleClass().add("sticker-image"); TooltipUtils.install(imageView, buildStickerName(filePath.getFileName().toString())); var pane = new Pane(imageView); pane.setPadding(new Insets(8.0)); textFlow.getChildren().add(pane); }); } }); } } return null; } }; Thread.ofVirtual().name("Stickers Collection Content Loader").start(task); } } private static Image getStickerMainImage(Path directory) { try (var stream = Files.find(directory, 1, (_, bfa) -> bfa.isRegularFile())) { return stream .findFirst() //.map(path -> openImage(path, IMAGE_COLLECTION_WIDTH, IMAGE_COLLECTION_HEIGHT)) .map(StickerView::openImage) // XXX: workaround for JavaFX's bug not handling scaling for images loaded with ImageIO (eg. WebP stickers) .orElse(null); } catch (IOException e) { log.error("Couldn't get sticker main image from {}: {}", directory, e.getMessage()); return null; } } @SuppressWarnings("SameParameterValue") private static Image openImage(Path path, int width, int height) { try (var inputStream = new FileInputStream(path.toFile())) { return new Image(inputStream, width, height, true, true); } catch (IOException e) { log.debug("Couldn't open image with specific size {}: {}", path, e.getMessage()); return null; } } private static Image openImage(Path path) { try (var inputStream = new FileInputStream(path.toFile())) { return new Image(inputStream); } catch (IOException e) { log.debug("Couldn't open image {}: {}", path, e.getMessage()); return null; } } private record StickerCollectionEntry(String name, Path path, Image image) { } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/TypingNotificationView.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import io.xeres.ui.support.util.UiUtils; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.control.Label; import javafx.scene.control.ProgressIndicator; import javafx.scene.layout.HBox; import java.io.IOException; import static org.apache.commons.lang3.StringUtils.isEmpty; public class TypingNotificationView extends HBox { @FXML private ProgressIndicator progressIndicator; @FXML private Label text; @FXML private WaveDotsView waveDotsView; public TypingNotificationView() { var loader = new FXMLLoader(TypingNotificationView.class.getResource("/view/custom/typing_notification_view.fxml")); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } public void setText(String text) { waveDotsView.setVisible(!isEmpty(text)); this.text.setText(text); } public void setProgress(String text) { waveDotsView.setVisible(false); this.text.setText(text); UiUtils.setPresent(progressIndicator); } public void stopProgress() { if (progressIndicator.isManaged()) { UiUtils.setAbsent(progressIndicator); text.setText(null); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/WaveDotsView.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom; import javafx.animation.Animation; import javafx.animation.PauseTransition; import javafx.animation.SequentialTransition; import javafx.animation.TranslateTransition; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.layout.HBox; import javafx.scene.shape.Circle; import javafx.util.Duration; import java.io.IOException; public class WaveDotsView extends HBox { @FXML private Circle circle1; @FXML private Circle circle2; @FXML private Circle circle3; public WaveDotsView() { var loader = new FXMLLoader(WaveDotsView.class.getResource("/view/custom/wave_dots_view.fxml")); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } } @FXML private void initialize() { var t1 = createAnimation(circle1, Duration.millis(0)); t1.play(); var t2 = createAnimation(circle2, Duration.millis(200)); t2.play(); var t3 = createAnimation(circle3, Duration.millis(400)); t3.play(); } private static Animation createAnimation(Circle circle, Duration initialDelay) { var translate = new TranslateTransition(Duration.millis(300), circle); translate.setToY(5.0f); var pause = new PauseTransition(Duration.millis(300)); var sequence = new SequentialTransition(translate, pause); sequence.setAutoReverse(true); sequence.setCycleCount(Animation.INDEFINITE); sequence.setDelay(initialDelay); return sequence; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/alias/AliasCell.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.alias; import atlantafx.base.theme.Styles; import io.xeres.ui.support.chat.AliasEntry; import io.xeres.ui.support.util.TooltipUtils; import io.xeres.ui.support.util.UiUtils; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import org.apache.commons.lang3.StringUtils; public class AliasCell extends ListCell { private VBox vbox; private Label name; private Label required; private Label optional; private Label description; @Override protected void updateItem(AliasEntry item, boolean empty) { super.updateItem(item, empty); setGraphic(empty ? null : updateAlias(item)); } private VBox updateAlias(AliasEntry entry) { if (vbox == null) { name = new Label(); required = new Label(); TooltipUtils.install(required, "Required"); required.getStyleClass().add(Styles.ACCENT); optional = new Label(); TooltipUtils.install(optional, "Optional"); optional.getStyleClass().add(Styles.TEXT_SUBTLE); description = new Label(entry.description()); description.getStyleClass().add(Styles.TEXT_SMALL); description.getStyleClass().add(Styles.TEXT_MUTED); var hbox = new HBox(name, required, optional); hbox.setSpacing(4); vbox = new VBox(hbox, description); } name.setText("/" + entry.name()); required.setText(entry.required()); UiUtils.setAbsent(required, StringUtils.isEmpty(entry.required())); optional.setText(entry.optional()); UiUtils.setAbsent(optional, StringUtils.isEmpty(entry.optional())); description.setText(entry.description()); return vbox; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/alias/AliasView.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.alias; import io.xeres.ui.support.chat.AliasEntry; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.transformation.FilteredList; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.control.ListView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.VBox; import org.apache.commons.lang3.StringUtils; import java.io.IOException; import java.util.List; import java.util.Locale; class AliasView extends VBox { interface OnActionListener { void complete(String action); void cancel(); } @FXML private ListView aliasList; private OnActionListener onActionListener; private FilteredList filteredList; public AliasView() { var loader = new FXMLLoader(AliasView.class.getResource("/view/custom/alias_view.fxml")); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (IOException e) { throw new RuntimeException(e); } aliasList.setCellFactory(_ -> new AliasCell()); addEventFilter(KeyEvent.KEY_PRESSED, event -> { if (event.getCode() == KeyCode.ENTER || event.getCode() == KeyCode.TAB) { action(); } else if (event.getCode() == KeyCode.ESCAPE) { if (onActionListener != null) { onActionListener.cancel(); } } }); addEventFilter(MouseEvent.MOUSE_RELEASED, event -> { if (event.getButton() == MouseButton.PRIMARY) { action(); } }); } private void action() { if (onActionListener != null) { var alias = aliasList.getSelectionModel().getSelectedItem(); if (alias != null) { onActionListener.complete(getAliasString(alias)); } else { if (filteredList.size() == 1) { alias = filteredList.getFirst(); onActionListener.complete(getAliasString(alias)); } else { onActionListener.complete(null); } } } } private static String getAliasString(AliasEntry alias) { return "/" + alias.name() + ((alias.required() != null || alias.optional() != null) ? " " : ""); } public void setListener(OnActionListener onActionListener) { this.onActionListener = onActionListener; } public void setAliasList(List entries) { filteredList = new FilteredList<>(FXCollections.observableArrayList(entries), _ -> true); aliasList.setItems(filteredList); if (!entries.isEmpty()) { aliasList.getSelectionModel().selectFirst(); } aliasList.getSelectionModel().selectedItemProperty().addListener((_, oldValue, newValue) -> { if (newValue == null && !aliasList.getItems().isEmpty()) { Platform.runLater(() -> { // Try to reselect the old value if it still exists if (oldValue != null && aliasList.getItems().contains(oldValue)) { aliasList.getSelectionModel().select(oldValue); } else { // Otherwise, select the first aliasList.getSelectionModel().selectFirst(); } }); } }); } public void setFilter(String text) { filteredList.setPredicate(aliasEntry -> { if (StringUtils.isEmpty(text)) { return true; } var textLw = text.toLowerCase(Locale.ENGLISH); var aliasLw = ("/" + aliasEntry.name()).toLowerCase(Locale.ROOT); return aliasLw.contains(textLw) || textLw.contains(aliasLw); }); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/alias/PopupAlias.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.alias; import io.xeres.ui.support.chat.ChatCommand; import javafx.geometry.Bounds; import javafx.stage.Popup; import javafx.stage.PopupWindow; import java.util.function.Consumer; public class PopupAlias extends Popup { private final AliasView aliasView; public PopupAlias(Bounds bounds, Consumer complete) { super(); aliasView = new AliasView(); setAnchorX(bounds.getMinX()); setAnchorY(bounds.getMinY()); setAnchorLocation(PopupWindow.AnchorLocation.CONTENT_BOTTOM_LEFT); getContent().add(aliasView); setAutoHide(true); aliasView.setAliasList(ChatCommand.ALIASES); aliasView.setListener(new AliasView.OnActionListener() { @Override public void complete(String action) { if (complete != null) { complete.accept(action); hide(); } } @Override public void cancel() { hide(); } }); } public void setFilter(String text) { aliasView.setFilter(text); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/asyncimage/AsyncImageView.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.asyncimage; import javafx.application.Platform; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.IOException; import java.lang.ref.WeakReference; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.LinkedList; import java.util.Objects; import java.util.Queue; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.FutureTask; import java.util.function.Function; /** * An {@link ImageView} subclass that can load images asynchronously like {@link Image} does with * its argument constructor. The difference is that this class can use any function for doing so * and not just load from a public URL. *

* Important: always use {@link #updateImage} instead of {@link #setImage} (which is final). */ public class AsyncImageView extends ImageView { private static final Logger log = LoggerFactory.getLogger(AsyncImageView.class); private static final int MAX_RUNNING_TASKS = 4; // same default values as Image's background task loader private static int runningTasks; private static final Queue pendingTasks = new LinkedList<>(); private WeakReference taskReference; private String url; private Function loader; private Runnable onSuccess; private ImageCache imageCache; private boolean canCallSetImage; public AsyncImageView() { this(null, null); } public AsyncImageView(Function loader) { this(loader, null); } public AsyncImageView(Function loader, ImageCache imageCache) { super(); setLoader(loader); setImageCache(imageCache); // setImage() is final and the listener is called *after* the // property is set (and acted upon by ImageView) so this is the // next best thing we can do to "override" it. imageProperty().addListener((_, _, _) -> { if (!canCallSetImage) { var sb = new StringBuilder("setImage() has been called on AsyncImageView! This can cause problems like images being empty or wrong. Use updateImage() instead!\n"); var trace = Thread.currentThread().getStackTrace(); for (var stackTraceElement : trace) { sb.append("\tat ").append(stackTraceElement).append("\n"); } log.error(sb.toString()); } }); } /** * Sets the url to load. Also accepts file: urls (in that case the loader is bypassed). * * @param url the url to load */ public void setUrl(String url) { if (StringUtils.isBlank(url)) { cancel(); this.url = null; updateImage(null); } else { if (getImage() != null) { if (Objects.equals(url, this.url)) { // Do not load again, if it's already loaded/being loaded. if (onSuccess != null) { onSuccess.run(); } return; } updateImage(null); } this.url = url; LoaderTask.loadImage(this, url, loader, onSuccess, imageCache); } } /** * Sets the image. HAS to be used instead of {@link #setImage} otherwise there * might be side effects like missing images or wrong image. * * @param image the image, can be null */ public void updateImage(Image image) { setLoaderTask(null); canCallSetImage = true; setImage(image); canCallSetImage = false; } /** * Sets the loader. This is needed to load an url asynchronously. * * @param loader the loader */ public void setLoader(Function loader) { this.loader = loader; } /** * Checks if a loader has been set. This is useful to reporting missing API usage. * * @return true if a loader has been set */ public boolean hasLoader() { return loader != null; } public void setOnSuccess(Runnable onSuccess) { this.onSuccess = onSuccess; } public void setImageCache(ImageCache imageCache) { this.imageCache = imageCache; } public void cancel() { var task = getLoaderTask(); if (task != null) { task.cancel(); } } private void setLoaderTask(LoaderTask task) { if (task == null) { if (taskReference != null) { taskReference.clear(); } } else { taskReference = new WeakReference<>(task); } } private LoaderTask getLoaderTask() { if (taskReference != null) { return taskReference.get(); } return null; } private static final class LoaderTask { private static final ExecutorService BACKGROUND_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); private final WeakReference imageViewReference; private final String url; private final Runnable onSuccess; private final ImageCache imageCache; private final FutureTask future; private static void loadImage(AsyncImageView imageView, String url, Function loader, Runnable onSuccess, ImageCache imageCache) { if (useFromCache(url, imageView, imageCache)) { if (onSuccess != null) { onSuccess.run(); } return; } if (canDoWork(url, imageView)) { if (loader != null) { var task = new LoaderTask(imageView, url, loader, onSuccess, imageCache); imageView.setLoaderTask(task); runTask(task); } else { log.warn("No loader has been set for image url {}, cannot load image", url); } } } private static boolean canDoWork(String url, AsyncImageView imageView) { var task = getLoaderTask(imageView); if (task != null) { if (url.equals(task.url)) { // Same work already in progress return false; } else { task.cancel(); } } return true; } private static boolean useFromCache(String url, AsyncImageView imageView, ImageCache imageCache) { if (imageCache != null) { var image = imageCache.getImage(url); if (image != null) { imageView.updateImage(image); return true; } } return false; } private LoaderTask(AsyncImageView asyncImageView, String url, Function loader, Runnable onSuccess, ImageCache imageCache) { imageViewReference = new WeakReference<>(asyncImageView); this.url = url; this.onSuccess = onSuccess; this.imageCache = imageCache; future = new FutureTask<>(() -> isFileUri(url) ? loadFile(url) : loader.apply(url)) { @Override protected void done() { if (future.isCancelled()) { onCancel(); } else { try { var data = future.get(); if (ArrayUtils.isEmpty(data)) { onFailure(); } else { // Image can apparently decode outside the main thread, which is exactly what we need. onCompletion(decodeImage(data)); } } catch (InterruptedException _) { onCancel(); Thread.currentThread().interrupt(); } catch (ExecutionException e) { onException(e); } } } }; } private static boolean isFileUri(String url) { try { var uri = new URI(url); return "file".equalsIgnoreCase(uri.getScheme()); } catch (URISyntaxException _) { return false; } } private static byte[] loadFile(String url) { try { var path = Paths.get(new URI(url)); return Files.readAllBytes(path); } catch (IOException | URISyntaxException e) { throw new RuntimeException(e); } } private Image decodeImage(byte[] data) { return new Image(new ByteArrayInputStream(data)); } private void onCompletion(Image image) { if (!future.isCancelled()) { Platform.runLater(() -> { if (imageCache != null) { imageCache.putImage(url, image); } var imageView = imageViewReference.get(); runIfSameTask(imageView, () -> { assert imageView != null; imageView.updateImage(image); if (onSuccess != null) { onSuccess.run(); } }); }); cycleTasks(); } } private void onFailure() { cycleTasks(); } private void onCancel() { cycleTasks(); } private void onException(Exception e) { log.error("Couldn't load image: {}", e.getMessage()); cycleTasks(); } private void runIfSameTask(AsyncImageView imageView, Runnable runnable) { var task = getLoaderTask(imageView); if (this == task) { runnable.run(); } } private void start() { BACKGROUND_EXECUTOR.execute(future); } private void cancel() { future.cancel(true); } private static LoaderTask getLoaderTask(AsyncImageView imageView) { if (imageView != null) { return imageView.getLoaderTask(); } return null; } private static void runTask(LoaderTask task) { synchronized (pendingTasks) { if (runningTasks >= MAX_RUNNING_TASKS) { pendingTasks.offer(task); } else { runningTasks++; task.start(); } } } private static void cycleTasks() { synchronized (pendingTasks) { runningTasks--; var nextTask = pendingTasks.poll(); if (nextTask != null) { runningTasks++; nextTask.start(); } } } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/asyncimage/ContactImageView.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.asyncimage; import javafx.application.ConditionalFeature; import javafx.application.Platform; import javafx.scene.effect.DropShadow; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.ImagePattern; import javafx.scene.shape.Circle; import java.util.function.Function; /** * A round image with subtle shadows. */ public class ContactImageView extends StackPane { private final Circle circle; private final AsyncImageView asyncImageView; public ContactImageView(Function loader, ImageCache imageCache, int size) { super(); circle = new Circle((double) size / 2); circle.setVisible(false); asyncImageView = new AsyncImageView(loader, imageCache); asyncImageView.setFitWidth(size); asyncImageView.setFitHeight(size); asyncImageView.setVisible(false); asyncImageView.setOnSuccess(() -> { circle.setFill(new ImagePattern(asyncImageView.getImage())); circle.setVisible(true); }); if (Platform.isSupported(ConditionalFeature.EFFECT)) { circle.setEffect(new DropShadow((double) size / 8, Color.rgb(0, 0, 0, 0.7))); } getChildren().addAll(circle, asyncImageView); } public void setUrl(String url) { circle.setVisible(false); asyncImageView.setUrl(url); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/asyncimage/ImageCache.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.asyncimage; import javafx.scene.image.Image; public interface ImageCache { Image getImage(String url); void putImage(String url, Image image); void evictImage(String url); void evictAllImages(); } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/asyncimage/PlaceholderImageView.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.asyncimage; import io.micrometer.common.util.StringUtils; import io.xeres.ui.support.util.UiUtils; import javafx.beans.NamedArg; import javafx.beans.property.ObjectProperty; import javafx.scene.image.Image; import javafx.scene.layout.StackPane; import org.kordamp.ikonli.javafx.FontIcon; import java.util.function.Function; /** * An AsyncImageView subclass that provides a default image when there's * nothing to show. */ public class PlaceholderImageView extends StackPane { private final AsyncImageView asyncImageView; private String iconLiteral; private FontIcon defaultIcon; private Double fitWidth; private Double fitHeight; private Boolean autoResize; public PlaceholderImageView(Function loader, String iconLiteral, ImageCache imageCache) { super(); this.iconLiteral = iconLiteral; asyncImageView = new AsyncImageView(loader, imageCache) { @Override public void updateImage(Image image) { setImageOrDefault(image); super.updateImage(image); } }; getChildren().add(asyncImageView); } // For FXML @SuppressWarnings("unused") public PlaceholderImageView(@NamedArg(value = "fitWidth") Double fitWidth, @NamedArg(value = "fitHeight") Double fitHeight, @NamedArg(value = "autoResize") Boolean autoResize) { super(); this.fitWidth = fitWidth; this.fitHeight = fitHeight; this.autoResize = autoResize; asyncImageView = new AsyncImageView() { @Override public void updateImage(Image image) { setImageOrDefault(image); super.updateImage(image); } }; getChildren().add(asyncImageView); initialize(); } private void initialize() { if (fitWidth != null && fitWidth != 0) { setFitWidth(fitWidth); } if (fitHeight != null && fitHeight != 0) { setFitHeight(fitHeight); } if (autoResize != null) { setPreserveRatio(autoResize); } updateDimensions(); } public void setIconLiteral(String iconLiteral) { this.iconLiteral = iconLiteral; } public void setLoader(Function loader) { asyncImageView.setLoader(loader); } public void setImageCache(ImageCache imageCache) { asyncImageView.setImageCache(imageCache); } public ObjectProperty imageProperty() { return asyncImageView.imageProperty(); } public void updateImage(Image image) { asyncImageView.updateImage(image); } public void setFitWidth(double value) { fitWidth = value; asyncImageView.setFitWidth(value); } public void setFitHeight(double value) { fitHeight = value; asyncImageView.setFitHeight(value); } public void setPreserveRatio(boolean value) { autoResize = value; asyncImageView.setPreserveRatio(value); } public void setUrl(String url) { updateDimensions(); asyncImageView.setUrl(url); } private void updateDimensions() { if (autoResize == null || !autoResize) { var sizeSet = false; if (fitWidth != null && fitWidth != 0) { setMinWidth(fitWidth); sizeSet = true; } if (fitHeight != null && fitHeight != 0) { setMinHeight(fitHeight); sizeSet = true; } if (sizeSet) { asyncImageView.setPreserveRatio(true); } } } private void setImageOrDefault(Image image) { if (image == null) { showDefault(); } else { hideDefault(); } } public void showDefault() { updateDimensions(); if (StringUtils.isBlank(iconLiteral)) { return; // No default to show } if (defaultIcon == null) { defaultIcon = new FontIcon(iconLiteral); getChildren().add(defaultIcon); } var minSize = (int) Math.min(asyncImageView.getFitWidth(), asyncImageView.getFitHeight()); if (minSize > 0) { UiUtils.setIconSize(defaultIcon, minSize); } UiUtils.setPresent(defaultIcon); } public void hideDefault() { updateDimensions(); if (defaultIcon != null) { UiUtils.setAbsent(defaultIcon); } } public Image getImage() { return asyncImageView.getImage(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/event/FileSelectedEvent.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.event; import javafx.event.Event; import javafx.event.EventType; import java.io.File; import java.io.Serial; public class FileSelectedEvent extends Event { @Serial private static final long serialVersionUID = -3716226621770176324L; public static final EventType FILE_SELECTED = new EventType<>(ANY, "FILE_SELECTED"); private final File file; public FileSelectedEvent(File file) { super(FILE_SELECTED); this.file = file; } public File getFile() { return file; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/event/ImageSelectedEvent.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.event; import javafx.event.Event; import javafx.event.EventType; import java.io.File; import java.io.Serial; public class ImageSelectedEvent extends Event { @Serial private static final long serialVersionUID = 3786821529525777622L; public static final EventType IMAGE_SELECTED = new EventType<>(ANY, "IMAGE_SELECTED"); private final File file; public ImageSelectedEvent(File file) { super(IMAGE_SELECTED); this.file = file; } public File getFile() { return file; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/event/StickerSelectedEvent.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.event; import javafx.event.Event; import javafx.event.EventType; import java.io.Serial; import java.nio.file.Path; public class StickerSelectedEvent extends Event { @Serial private static final long serialVersionUID = -1377318297476370274L; public static final EventType STICKER_SELECTED = new EventType<>(ANY, "STICKER_SELECTED"); private final transient Path path; public StickerSelectedEvent(Path path) { super(STICKER_SELECTED); this.path = path; } public Path getPath() { return path; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/led/LedControl.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.led; import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanPropertyBase; import javafx.beans.property.ObjectProperty; import javafx.css.*; import javafx.scene.control.Control; import javafx.scene.control.Skin; import javafx.scene.paint.Color; import java.util.List; /** * A LED class. Strongly inspired from Gerrit Grunwald's JavaFXCustomControls. */ public class LedControl extends Control { private static final StyleablePropertyFactory FACTORY = new StyleablePropertyFactory<>(Control.getClassCssMetaData()); // CSS pseudo class private static final PseudoClass ON_PSEUDO_CLASS = PseudoClass.getPseudoClass("on"); private final BooleanProperty state; // CSS styleable property private static final CssMetaData COLOR = FACTORY.createColorCssMetaData("-color", ledControl -> ledControl.color, Color.GREEN, false); private final StyleableProperty color; public LedControl() { getStyleClass().add("led-control"); state = new BooleanPropertyBase(false) { @Override protected void invalidated() { pseudoClassStateChanged(ON_PSEUDO_CLASS, get()); } @Override public Object getBean() { return this; } @Override public String getName() { return "state"; } }; color = new SimpleStyleableObjectProperty<>(COLOR, this, "color"); } public boolean hasState() { return state.get(); } public void setState(boolean state) { this.state.set(state); } public BooleanProperty stateProperty() { return state; } public Color getColor() { return color.getValue(); } public void setStatus(LedStatus ledStatus) { switch (ledStatus) { case OK -> setStatusClass("led-status-ok"); case WARNING -> setStatusClass("led-status-warning"); case ERROR -> setStatusClass("led-status-error"); } } private void setStatusClass(String className) { getStyleClass().removeAll("led-status-ok", "led-status-warning", "led-status-error"); getStyleClass().add(className); } @SuppressWarnings("unchecked") public ObjectProperty colorProperty() { return (ObjectProperty) color; } @Override protected Skin createDefaultSkin() { return new LedSkin(this); } @Override protected List> getControlCssMetaData() { return FACTORY.getCssMetaData(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/led/LedSkin.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.led; import javafx.beans.InvalidationListener; import javafx.scene.control.Skin; import javafx.scene.control.SkinBase; import javafx.scene.effect.BlurType; import javafx.scene.effect.DropShadow; import javafx.scene.effect.InnerShadow; import javafx.scene.layout.Region; import javafx.scene.paint.Color; /** * A LED class. Strongly inspired from Gerrit Grunwald's JavaFXCustomControls. */ public class LedSkin extends SkinBase implements Skin { private static final double PREFERRED_WIDTH = 16; private static final double PREFERRED_HEIGHT = 16; private static final double MINIMUM_WIDTH = 8; private static final double MINIMUM_HEIGHT = 8; private static final double MAXIMUM_WIDTH = 1024; private static final double MAXIMUM_HEIGHT = 1024; public static final String RESIZE_PROPERTY = "RESIZE"; public static final String COLOR_PROPERTY = "COLOR"; public static final String STATE_PROPERTY = "STATE"; private Region frame; private Region main; private Region highlight; private InnerShadow innerShadow; private DropShadow glow; private LedControl control; private final InvalidationListener sizeListener; private final InvalidationListener colorListener; private final InvalidationListener stateListener; public LedSkin(LedControl control) { super(control); this.control = control; sizeListener = observable -> handleControlPropertyChanged(RESIZE_PROPERTY); colorListener = observable -> handleControlPropertyChanged(COLOR_PROPERTY); stateListener = observable -> handleControlPropertyChanged(STATE_PROPERTY); initGraphics(); registerListeners(); } private void initGraphics() { if (Double.compare(control.getPrefWidth(), 0.0) <= 0 || Double.compare(control.getPrefHeight(), 0.0) <= 0 || Double.compare(control.getWidth(), 0.0) <= 0 || Double.compare(control.getHeight(), 0.0) <= 0) { if (control.getPrefWidth() > 0 && control.getPrefHeight() > 0) { control.setPrefSize(control.getPrefWidth(), control.getPrefHeight()); } else { control.setPrefSize(PREFERRED_WIDTH, PREFERRED_HEIGHT); } } frame = new Region(); frame.getStyleClass().setAll("frame"); main = new Region(); main.getStyleClass().setAll("main"); innerShadow = new InnerShadow(BlurType.TWO_PASS_BOX, Color.rgb(0, 0, 0, 0.65), 8, 0, 0, 0); glow = new DropShadow(BlurType.TWO_PASS_BOX, control.getColor(), 20, 0, 0, 0); glow.setInput(innerShadow); highlight = new Region(); highlight.getStyleClass().setAll("highlight"); getChildren().addAll(frame, main, highlight); } private void registerListeners() { control.widthProperty().addListener(sizeListener); control.heightProperty().addListener(sizeListener); control.colorProperty().addListener(colorListener); control.stateProperty().addListener(stateListener); } @Override protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return MINIMUM_WIDTH; } @Override protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { return MINIMUM_HEIGHT; } @Override protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return MAXIMUM_WIDTH; } @Override protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { return MAXIMUM_HEIGHT; } protected void handleControlPropertyChanged(String property) { if (RESIZE_PROPERTY.equals(property)) { resize(); } else if (COLOR_PROPERTY.equals(property)) { resize(); } else if (STATE_PROPERTY.equals(property)) { main.setEffect(control.hasState() ? glow : innerShadow); } } @Override public void dispose() { control.widthProperty().removeListener(sizeListener); control.heightProperty().removeListener(sizeListener); control.colorProperty().removeListener(colorListener); control.stateProperty().removeListener(stateListener); control = null; } private void resize() { var width = control.getWidth() - control.getInsets().getLeft() - control.getInsets().getRight(); var height = control.getHeight() - control.getInsets().getTop() - control.getInsets().getBottom(); var size = Math.min(width, height); if (size > 0) { innerShadow.setRadius(0.07 * size); glow.setRadius(0.36 * size); glow.setColor(control.getColor()); frame.setMaxSize(size, size); main.setMaxSize(0.72 * size, 0.72 * size); main.relocate(0.14 * size, 0.14 * size); main.setEffect(control.hasState() ? glow : innerShadow); highlight.setMaxSize(0.58 * size, 0.58 * size); highlight.relocate(0.21 * size, 0.21 * size); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/custom/led/LedStatus.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.custom.led; public enum LedStatus { OK, WARNING, ERROR } ================================================ FILE: ui/src/main/java/io/xeres/ui/event/OpenUriEvent.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.event; import io.xeres.common.events.SynchronousEvent; import io.xeres.ui.support.uri.Uri; public record OpenUriEvent(Uri uri) implements SynchronousEvent { } ================================================ FILE: ui/src/main/java/io/xeres/ui/event/StageReadyEvent.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.event; import javafx.stage.Stage; import org.springframework.context.ApplicationEvent; import java.io.Serial; public class StageReadyEvent extends ApplicationEvent { @Serial private static final long serialVersionUID = 346107776084028526L; private final transient Stage stage; public StageReadyEvent(Stage primaryStage) { super(primaryStage); stage = primaryStage; } public Stage getStage() { return stage; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/event/UnreadEvent.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.event; import io.xeres.common.events.SynchronousEvent; public record UnreadEvent(Element element, boolean unread) implements SynchronousEvent { public enum Element { HOME, CONTACT, CHAT_ROOM, FORUM, FILE, CHAT, BOARD, CHANNEL } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/board/BoardGroup.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.board; import io.xeres.common.id.GxsId; import io.xeres.ui.controller.common.GxsGroup; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import java.time.Instant; public class BoardGroup implements GxsGroup { private long id; private String name; private GxsId gxsId; private String description; private boolean hasImage; private boolean subscribed; private boolean external; private int visibleMessageCount; private Instant lastActivity; private final IntegerProperty unreadCount = new SimpleIntegerProperty(0); public BoardGroup() { } public BoardGroup(String name) { this.name = name; } @Override public long getId() { return id; } public void setId(long id) { this.id = id; } @Override public boolean isReal() { return id != 0L; } @Override public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } @Override public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public boolean hasImage() { return hasImage; } public void setHasImage(boolean hasImage) { this.hasImage = hasImage; } @Override public boolean isSubscribed() { return subscribed; } @Override public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } @Override public boolean isExternal() { return external; } public void setExternal(boolean external) { this.external = external; } @Override public int getVisibleMessageCount() { return visibleMessageCount; } public void setVisibleMessageCount(int visibleMessageCount) { this.visibleMessageCount = visibleMessageCount; } @Override public Instant getLastActivity() { return lastActivity; } public void setLastActivity(Instant lastActivity) { this.lastActivity = lastActivity; } @Override public boolean hasNewMessages() { return unreadCount.get() > 0 && gxsId != null; } public int getUnreadCount() { return unreadCount.get(); } @Override public void setUnreadCount(int unreadCount) { this.unreadCount.set(unreadCount); } @Override public void addUnreadCount(int value) { unreadCount.set(unreadCount.get() + value); } @Override public void subtractUnreadCount(int value) { unreadCount.set(unreadCount.get() - value); } public IntegerProperty unreadCountProperty() { return unreadCount; } @Override public String toString() { return getName(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/board/BoardMapper.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.board; import io.xeres.common.dto.board.BoardGroupDTO; import io.xeres.common.dto.board.BoardMessageDTO; import io.xeres.ui.client.PaginatedResponse; public final class BoardMapper { private BoardMapper() { throw new UnsupportedOperationException("Utility class"); } public static BoardGroup fromDTO(BoardGroupDTO dto) { if (dto == null) { return null; } var boardGroup = new BoardGroup(); boardGroup.setId(dto.id()); boardGroup.setName(dto.name()); boardGroup.setGxsId(dto.gxsId()); boardGroup.setDescription(dto.description()); boardGroup.setHasImage(dto.hasImage()); boardGroup.setSubscribed(dto.subscribed()); boardGroup.setExternal(dto.external()); boardGroup.setVisibleMessageCount(dto.visibleMessageCount()); boardGroup.setLastActivity(dto.lastActivity()); return boardGroup; } public static BoardMessage fromDTO(BoardMessageDTO dto) { if (dto == null) { return null; } var boardMessage = new BoardMessage(); boardMessage.setId(dto.id()); boardMessage.setGxsId(dto.gxsId()); boardMessage.setMsgId(dto.msgId()); boardMessage.setOriginalId(dto.originalId()); boardMessage.setParentId(dto.parentId()); boardMessage.setAuthorGxsId(dto.authorGxsId()); boardMessage.setAuthorName(dto.authorName()); boardMessage.setName(dto.name()); boardMessage.setPublished(dto.published()); boardMessage.setContent(dto.content()); boardMessage.setLink(dto.link()); boardMessage.setHasImage(dto.hasImage()); boardMessage.setRead(dto.read()); boardMessage.setImageWidth(dto.imageWidth()); boardMessage.setImageHeight(dto.imageHeight()); return boardMessage; } public static PaginatedResponse fromDTO(PaginatedResponse dto) { return new PaginatedResponse<>( dto.content().stream() .map(BoardMapper::fromDTO) .toList(), dto.page() ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/board/BoardMessage.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.board; import io.micrometer.common.util.StringUtils; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.ui.controller.common.GxsMessage; import java.time.Instant; public class BoardMessage implements GxsMessage { private long id; private GxsId gxsId; private MsgId msgId; private long originalId; private long parentId; private GxsId authorGxsId; private String authorName; private String name; private Instant published; private String content; private String link; private boolean hasImage; private int imageWidth; private int imageHeight; private boolean read; public BoardMessage() { // Needed } @Override public long getId() { return id; } public void setId(long id) { this.id = id; } @Override public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public MsgId getMsgId() { return msgId; } public void setMsgId(MsgId msgId) { this.msgId = msgId; } @Override public long getOriginalId() { return originalId; } public void setOriginalId(long originalId) { this.originalId = originalId; } public long getParentId() { return parentId; } public void setParentId(long parentId) { this.parentId = parentId; } public GxsId getAuthorGxsId() { return authorGxsId; } public void setAuthorGxsId(GxsId authorGxsId) { this.authorGxsId = authorGxsId; } public String getAuthorName() { return authorName; } public void setAuthorName(String authorName) { this.authorName = authorName; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public Instant getPublished() { return published; } public void setPublished(Instant published) { this.published = published; } public boolean hasContent() { return StringUtils.isNotBlank(content); } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public boolean hasLink() { return StringUtils.isNotBlank(link); } public String getLink() { return link; } public void setLink(String link) { this.link = link; } public boolean hasImage() { return hasImage; } public void setHasImage(boolean hasImage) { this.hasImage = hasImage; } public int getImageWidth() { return imageWidth; } public void setImageWidth(int imageWidth) { this.imageWidth = imageWidth; } public int getImageHeight() { return imageHeight; } public void setImageHeight(int imageHeight) { this.imageHeight = imageHeight; } @Override public boolean isRead() { return read; } @Override public void setRead(boolean read) { this.read = read; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/channel/ChannelFile.java ================================================ /* * Copyright (c) 2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.channel; import io.xeres.common.i18n.I18nEnum; import io.xeres.common.i18n.I18nUtils; import javafx.beans.property.SimpleLongProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import java.util.Objects; import java.util.ResourceBundle; public class ChannelFile { public enum State implements I18nEnum { HASHING, DONE; private final ResourceBundle bundle = I18nUtils.getBundle(); @Override public String toString() { return bundle.getString(getMessageKey(this)); } } private final SimpleStringProperty name; private final SimpleStringProperty path; private final SimpleObjectProperty state; private final SimpleLongProperty size; private final SimpleStringProperty hash; public ChannelFile(String name, String path, State state, long size, String hash) { this.name = new SimpleStringProperty(name); this.path = new SimpleStringProperty(path); this.state = new SimpleObjectProperty<>(state); this.size = new SimpleLongProperty(size); this.hash = new SimpleStringProperty(hash); } public String getName() { return name.get(); } @SuppressWarnings("unused") public SimpleStringProperty nameProperty() { return name; } public void setName(String name) { this.name.set(name); } public String getPath() { return path.get(); } @SuppressWarnings("unused") public SimpleStringProperty pathProperty() { return path; } public void setPath(String path) { this.path.set(path); } public State getState() { return state.get(); } @SuppressWarnings("unused") public SimpleObjectProperty stateProperty() { return state; } public void setState(State state) { this.state.set(state); } public long getSize() { return size.get(); } @SuppressWarnings("unused") public SimpleLongProperty sizeProperty() { return size; } public void setSize(long size) { this.size.set(size); } public String getHash() { return hash.get(); } @SuppressWarnings("unused") public SimpleStringProperty hashProperty() { return hash; } public void setHash(String hash) { this.hash.set(hash); } @Override public boolean equals(Object o) { if (!(o instanceof ChannelFile that)) { return false; } if (getHash() != null) { return Objects.equals(getHash(), that.getHash()); } else { return Objects.equals(getName(), that.getName()) && Objects.equals(getPath(), that.getPath()); } } @Override public int hashCode() { if (getHash() != null) { return Objects.hash(getHash()); } else { return Objects.hash(getName(), getPath()); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/channel/ChannelGroup.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.channel; import io.xeres.common.id.GxsId; import io.xeres.ui.controller.common.GxsGroup; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import java.time.Instant; public class ChannelGroup implements GxsGroup { private long id; private String name; private GxsId gxsId; private String description; private boolean hasImage; private boolean subscribed; private boolean external; private int visibleMessageCount; private Instant lastActivity; private final IntegerProperty unreadCount = new SimpleIntegerProperty(0); public ChannelGroup() { } public ChannelGroup(String name) { this.name = name; } @Override public long getId() { return id; } public void setId(long id) { this.id = id; } @Override public boolean isReal() { return id != 0L; } @Override public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } @Override public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public boolean hasImage() { return hasImage; } public void setHasImage(boolean hasImage) { this.hasImage = hasImage; } @Override public boolean isSubscribed() { return subscribed; } @Override public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } @Override public boolean isExternal() { return external; } public void setExternal(boolean external) { this.external = external; } @Override public int getVisibleMessageCount() { return visibleMessageCount; } public void setVisibleMessageCount(int visibleMessageCount) { this.visibleMessageCount = visibleMessageCount; } @Override public Instant getLastActivity() { return lastActivity; } public void setLastActivity(Instant lastActivity) { this.lastActivity = lastActivity; } @Override public boolean hasNewMessages() { return unreadCount.get() > 0 && gxsId != null; } public int getUnreadCount() { return unreadCount.get(); } @Override public void setUnreadCount(int unreadCount) { this.unreadCount.set(unreadCount); } @Override public void addUnreadCount(int value) { unreadCount.set(unreadCount.get() + value); } @Override public void subtractUnreadCount(int value) { unreadCount.set(unreadCount.get() - value); } public IntegerProperty unreadCountProperty() { return unreadCount; } @Override public String toString() { return getName(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/channel/ChannelMapper.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.channel; import io.xeres.common.dto.channel.ChannelFileDTO; import io.xeres.common.dto.channel.ChannelGroupDTO; import io.xeres.common.dto.channel.ChannelMessageDTO; import io.xeres.common.id.Sha1Sum; import io.xeres.ui.client.PaginatedResponse; import java.util.List; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class ChannelMapper { private ChannelMapper() { throw new UnsupportedOperationException("Utility class"); } public static ChannelGroup fromDTO(ChannelGroupDTO dto) { if (dto == null) { return null; } var channelGroup = new ChannelGroup(); channelGroup.setId(dto.id()); channelGroup.setName(dto.name()); channelGroup.setGxsId(dto.gxsId()); channelGroup.setDescription(dto.description()); channelGroup.setHasImage(dto.hasImage()); channelGroup.setSubscribed(dto.subscribed()); channelGroup.setExternal(dto.external()); channelGroup.setVisibleMessageCount(dto.visibleMessageCount()); channelGroup.setLastActivity(dto.lastActivity()); return channelGroup; } public static ChannelMessage fromDTO(ChannelMessageDTO dto) { if (dto == null) { return null; } var channelMessage = new ChannelMessage(); channelMessage.setId(dto.id()); channelMessage.setGxsId(dto.gxsId()); channelMessage.setMsgId(dto.msgId()); channelMessage.setOriginalId(dto.originalId()); channelMessage.setParentId(dto.parentId()); channelMessage.setAuthorGxsId(dto.authorGxsId()); channelMessage.setAuthorName(dto.authorName()); channelMessage.setName(dto.name()); channelMessage.setPublished(dto.published()); channelMessage.setContent(dto.content()); channelMessage.setHasImage(dto.hasImage()); channelMessage.setImageWidth(dto.imageWidth()); channelMessage.setImageHeight(dto.imageHeight()); channelMessage.setHasFiles(dto.hasFiles()); channelMessage.addFiles(fromFileDTOs(dto.files())); channelMessage.setRead(dto.read()); return channelMessage; } private static List fromFileDTOs(List dtos) { return emptyIfNull(dtos).stream() .map(ChannelMapper::fromFileDTO) .toList(); } private static ChannelFile fromFileDTO(ChannelFileDTO dto) { if (dto == null) { return null; } return new ChannelFile(dto.name(), dto.path(), ChannelFile.State.DONE, dto.size(), dto.hash().toString()); } public static PaginatedResponse fromDTO(PaginatedResponse dto) { return new PaginatedResponse<>( dto.content().stream() .map(ChannelMapper::fromDTO) .toList(), dto.page() ); } public static List toChannelFileDTOs(List files) { return emptyIfNull(files).stream() .map(ChannelMapper::toDTO) .toList(); } public static ChannelFileDTO toDTO(ChannelFile channelFile) { if (channelFile == null) { return null; } return new ChannelFileDTO(channelFile.getSize(), Sha1Sum.fromString(channelFile.getHash()), channelFile.getName(), channelFile.getPath(), 0); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/channel/ChannelMessage.java ================================================ /* * Copyright (c) 2025-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.channel; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.ui.controller.common.GxsMessage; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; public class ChannelMessage implements GxsMessage { private long id; private GxsId gxsId; private MsgId msgId; private long originalId; private long parentId; private GxsId authorGxsId; private String authorName; private String name; private Instant published; private String content; private boolean hasImage; private int imageWidth; private int imageHeight; private boolean hasFiles; private final List files = new ArrayList<>(); private boolean read; private boolean selected; // For UI purposes only public ChannelMessage() { // Needed } @Override public long getId() { return id; } public void setId(long id) { this.id = id; } @Override public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public MsgId getMsgId() { return msgId; } public void setMsgId(MsgId msgId) { this.msgId = msgId; } @Override public long getOriginalId() { return originalId; } public void setOriginalId(long originalId) { this.originalId = originalId; } public long getParentId() { return parentId; } public void setParentId(long parentId) { this.parentId = parentId; } public GxsId getAuthorGxsId() { return authorGxsId; } public void setAuthorGxsId(GxsId authorGxsId) { this.authorGxsId = authorGxsId; } public String getAuthorName() { return authorName; } public void setAuthorName(String authorName) { this.authorName = authorName; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public Instant getPublished() { return published; } public void setPublished(Instant published) { this.published = published; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public boolean hasImage() { return hasImage; } public void setHasImage(boolean hasImage) { this.hasImage = hasImage; } public int getImageWidth() { return imageWidth; } public void setImageWidth(int imageWidth) { this.imageWidth = imageWidth; } public int getImageHeight() { return imageHeight; } public void setImageHeight(int imageHeight) { this.imageHeight = imageHeight; } public boolean hasFiles() { return hasFiles; } public void setHasFiles(boolean hasFiles) { this.hasFiles = hasFiles; } public List getFiles() { return Collections.unmodifiableList(files); } public void addFiles(List files) { this.files.addAll(files); } @Override public boolean isRead() { return read; } @Override public void setRead(boolean read) { this.read = read; } public boolean isSelected() { return selected; } public void setSelected(boolean selected) { this.selected = selected; } @Override public boolean equals(Object o) { if (!(o instanceof ChannelMessage that)) { return false; } return id == that.id; } @Override public int hashCode() { return Objects.hashCode(id); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/chat/ChatMapper.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.chat; import io.xeres.common.dto.chat.*; import io.xeres.common.message.chat.*; public final class ChatMapper { private ChatMapper() { throw new UnsupportedOperationException("Utility class"); } public static ChatRoomContext fromDTO(ChatRoomContextDTO dto) { if (dto == null) { return null; } return new ChatRoomContext(fromDTO(dto.chatRooms()), fromDTO(dto.identity())); } private static ChatRoomLists fromDTO(ChatRoomsDTO dto) { if (dto == null) { return null; } var chatRoomLists = new ChatRoomLists(); dto.available().forEach(chatRoomDTO -> chatRoomLists.addAvailable(fromDTO(chatRoomDTO))); dto.subscribed().forEach(chatRoomDTO -> chatRoomLists.addSubscribed(fromDTO(chatRoomDTO))); return chatRoomLists; } private static ChatRoomUser fromDTO(ChatIdentityDTO dto) { if (dto == null) { return null; } return new ChatRoomUser(dto.nickname(), dto.gxsId(), dto.identityId()); } public static ChatRoomInfo fromDTO(ChatRoomDTO dto) { if (dto == null) { return null; } return new ChatRoomInfo( dto.id(), dto.name(), dto.roomType(), dto.topic(), dto.count(), dto.isSigned()); } public static ChatRoomBacklog fromDTO(ChatRoomBacklogDTO dto) { if (dto == null) { return null; } return new ChatRoomBacklog( dto.created(), dto.gxsId(), dto.nickname(), dto.message() ); } public static ChatBacklog fromDTO(ChatBacklogDTO dto) { if (dto == null) { return null; } return new ChatBacklog( dto.created(), dto.own(), dto.message() ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/connection/Connection.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.connection; import java.time.Instant; public class Connection { private long id; private String address; private Instant lastConnected; private boolean external; public long getId() { return id; } public void setId(long id) { this.id = id; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public Instant getLastConnected() { return lastConnected; } public void setLastConnected(Instant lastConnected) { this.lastConnected = lastConnected; } public boolean isExternal() { return external; } public void setExternal(boolean external) { this.external = external; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/connection/ConnectionMapper.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.connection; import io.xeres.common.dto.connection.ConnectionDTO; @SuppressWarnings("DuplicatedCode") public final class ConnectionMapper { private ConnectionMapper() { throw new UnsupportedOperationException("Utility class"); } public static Connection fromDTO(ConnectionDTO dto) { if (dto == null) { return null; } var connection = new Connection(); connection.setId(dto.id()); connection.setAddress(dto.address()); connection.setExternal(dto.external()); connection.setLastConnected(dto.lastConnected()); return connection; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/forum/ForumGroup.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.forum; import io.xeres.common.id.GxsId; import io.xeres.ui.controller.common.GxsGroup; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import java.time.Instant; public class ForumGroup implements GxsGroup { private long id; private String name; private GxsId gxsId; private String description; private boolean subscribed; private boolean external; private int visibleMessageCount; private Instant lastActivity; private final IntegerProperty unreadCount = new SimpleIntegerProperty(0); public ForumGroup() { } public ForumGroup(String name) { this.name = name; } @Override public long getId() { return id; } public void setId(long id) { this.id = id; } @Override public boolean isReal() { return id != 0L; } @Override public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } @Override public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } @Override public boolean isSubscribed() { return subscribed; } @Override public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } @Override public boolean isExternal() { return external; } public void setExternal(boolean external) { this.external = external; } @Override public int getVisibleMessageCount() { return visibleMessageCount; } public void setVisibleMessageCount(int visibleMessageCount) { this.visibleMessageCount = visibleMessageCount; } @Override public Instant getLastActivity() { return lastActivity; } public void setLastActivity(Instant lastActivity) { this.lastActivity = lastActivity; } @Override public boolean hasNewMessages() { return unreadCount.get() > 0 && gxsId != null; } public int getUnreadCount() { return unreadCount.get(); } @Override public void setUnreadCount(int unreadCount) { this.unreadCount.set(unreadCount); } @Override public void addUnreadCount(int value) { unreadCount.set(unreadCount.get() + value); } @Override public void subtractUnreadCount(int value) { unreadCount.set(unreadCount.get() - value); } public IntegerProperty unreadCountProperty() { return unreadCount; } @Override public String toString() { return getName(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/forum/ForumMapper.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.forum; import io.xeres.common.dto.forum.ForumGroupDTO; import io.xeres.common.dto.forum.ForumMessageDTO; import io.xeres.ui.client.PaginatedResponse; public final class ForumMapper { private ForumMapper() { throw new UnsupportedOperationException("Utility class"); } public static ForumGroup fromDTO(ForumGroupDTO dto) { if (dto == null) { return null; } var forumGroup = new ForumGroup(); forumGroup.setId(dto.id()); forumGroup.setName(dto.name()); forumGroup.setGxsId(dto.gxsId()); forumGroup.setDescription(dto.description()); forumGroup.setSubscribed(dto.subscribed()); forumGroup.setExternal(dto.external()); forumGroup.setVisibleMessageCount(dto.visibleMessageCount()); forumGroup.setLastActivity(dto.lastActivity()); return forumGroup; } public static ForumMessage fromDTO(ForumMessageDTO dto) { if (dto == null) { return null; } var forumMessage = new ForumMessage(); forumMessage.setId(dto.id()); forumMessage.setGxsId(dto.gxsId()); forumMessage.setMsgId(dto.msgId()); forumMessage.setOriginalId(dto.originalId()); forumMessage.setParentId(dto.parentId()); forumMessage.setAuthorGxsId(dto.authorGxsId()); forumMessage.setAuthorName(dto.authorName()); forumMessage.setName(dto.name()); forumMessage.setPublished(dto.published()); forumMessage.setContent(dto.content()); forumMessage.setRead(dto.read()); return forumMessage; } public static PaginatedResponse fromDTO(PaginatedResponse dto) { return new PaginatedResponse<>( dto.content().stream() .map(ForumMapper::fromDTO) .toList(), dto.page() ); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/forum/ForumMessage.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.forum; import io.xeres.common.id.GxsId; import io.xeres.common.id.MsgId; import io.xeres.ui.controller.common.GxsMessage; import java.time.Instant; public class ForumMessage implements GxsMessage { private long id; private GxsId gxsId; private MsgId msgId; private long originalId; private long parentId; private GxsId authorGxsId; private String authorName; private String name; private Instant published; private String content; private boolean read; public ForumMessage() { // Needed } @Override public long getId() { return id; } public void setId(long id) { this.id = id; } @Override public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public MsgId getMsgId() { return msgId; } public void setMsgId(MsgId msgId) { this.msgId = msgId; } @Override public long getOriginalId() { return originalId; } public void setOriginalId(long originalId) { this.originalId = originalId; } public long getParentId() { return parentId; } public void setParentId(long parentId) { this.parentId = parentId; } public GxsId getAuthorGxsId() { return authorGxsId; } public void setAuthorGxsId(GxsId authorGxsId) { this.authorGxsId = authorGxsId; } public String getAuthorName() { return authorName; } public void setAuthorName(String authorName) { this.authorName = authorName; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public Instant getPublished() { return published; } public void setPublished(Instant published) { this.published = published; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } @Override public boolean isRead() { return read; } @Override public void setRead(boolean read) { this.read = read; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/identity/Identity.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.identity; import io.xeres.common.id.GxsId; import io.xeres.common.identity.Type; import java.time.Instant; public class Identity { private long id; private String name; private GxsId gxsId; private Instant updated; private Type type; private boolean hasImage; public long getId() { return id; } public void setId(long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public GxsId getGxsId() { return gxsId; } public void setGxsId(GxsId gxsId) { this.gxsId = gxsId; } public Instant getUpdated() { return updated; } public void setUpdated(Instant updated) { this.updated = updated; } public Type getType() { return type; } public void setType(Type type) { this.type = type; } public boolean hasImage() { return hasImage; } public void setHasImage(boolean hasImage) { this.hasImage = hasImage; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/identity/IdentityMapper.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.identity; import io.xeres.common.dto.identity.IdentityDTO; public final class IdentityMapper { private IdentityMapper() { throw new UnsupportedOperationException("Utility class"); } public static Identity fromDTO(IdentityDTO dto) { if (dto == null) { return null; } var identity = new Identity(); identity.setId(dto.id()); identity.setName(dto.name()); identity.setGxsId(dto.gxsId()); identity.setUpdated(dto.updated()); identity.setType(dto.type()); identity.setHasImage(dto.hasImage()); return identity; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/location/Location.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.location; import io.xeres.common.id.LocationIdentifier; import io.xeres.common.location.Availability; import io.xeres.ui.model.connection.Connection; import org.apache.commons.lang3.StringUtils; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class Location { private long id; private String name; private LocationIdentifier locationIdentifier; private String hostname; private final List connections = new ArrayList<>(); private boolean connected; private Instant lastConnected; private Availability availability; private String version; public long getId() { return id; } public void setId(long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public LocationIdentifier getLocationIdentifier() { return locationIdentifier; } public void setLocationIdentifier(LocationIdentifier locationIdentifier) { this.locationIdentifier = locationIdentifier; } public String getHostname() { return hostname; } public void setHostname(String hostname) { this.hostname = hostname; } public List getConnections() { return Collections.unmodifiableList(connections); } public void addConnections(List connections) { this.connections.addAll(connections); } public boolean isConnected() { return connected; } public void setConnected(boolean connected) { this.connected = connected; } public Instant getLastConnected() { return lastConnected; } public void setLastConnected(Instant lastConnected) { this.lastConnected = lastConnected; } /** * Returns the availability state. Always make sure to check {@link #isConnected()} first because * this location has no concept of offline presence. * * @return the availability */ public Availability getAvailability() { return availability; } public void setAvailability(Availability availability) { this.availability = availability; } public String getVersion() { return version; } public void setVersion(String version) { this.version = version; } public boolean hasVersion() { return StringUtils.isNotBlank(version); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/location/LocationMapper.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.location; import io.xeres.common.dto.location.LocationDTO; import io.xeres.common.id.LocationIdentifier; import io.xeres.ui.model.connection.ConnectionMapper; @SuppressWarnings("DuplicatedCode") public final class LocationMapper { private LocationMapper() { throw new UnsupportedOperationException("Utility class"); } public static Location fromDTO(LocationDTO dto) { if (dto == null) { return null; } var location = new Location(); location.setId(dto.id()); location.setName(dto.name()); location.setLocationIdentifier(new LocationIdentifier(dto.locationIdentifier())); location.setConnected(dto.connected()); location.setLastConnected(dto.lastConnected()); location.setAvailability(dto.availability()); location.setVersion(dto.version()); return location; } public static Location fromDeepDTO(LocationDTO dto) { if (dto == null) { return null; } var location = fromDTO(dto); location.addConnections(dto.connections().stream() .map(ConnectionMapper::fromDTO) .toList()); return location; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/profile/Profile.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.profile; import io.xeres.common.id.ProfileFingerprint; import io.xeres.common.pgp.Trust; import io.xeres.ui.model.location.Location; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID; public class Profile { private long id; private String name; private long pgpIdentifier; private Instant created; private ProfileFingerprint profileFingerprint; private byte[] pgpPublicKeyData; private boolean accepted; private Trust trust; private final List locations = new ArrayList<>(); public long getId() { return id; } public void setId(long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public long getPgpIdentifier() { return pgpIdentifier; } public void setPgpIdentifier(long pgpIdentifier) { this.pgpIdentifier = pgpIdentifier; } public Instant getCreated() { return created; } public void setCreated(Instant created) { this.created = created; } public ProfileFingerprint getProfileFingerprint() { return profileFingerprint; } public void setProfileFingerprint(ProfileFingerprint profileFingerprint) { this.profileFingerprint = profileFingerprint; } public byte[] getPgpPublicKeyData() { return pgpPublicKeyData; } public void setPgpPublicKeyData(byte[] pgpPublicKeyData) { this.pgpPublicKeyData = pgpPublicKeyData; } public boolean isAccepted() { return accepted; } public void setAccepted(boolean accepted) { this.accepted = accepted; } public Trust getTrust() { return trust; } public void setTrust(Trust trust) { this.trust = trust; } public List getLocations() { return Collections.unmodifiableList(locations); } public void addLocations(List locations) { this.locations.addAll(locations); } public boolean isPartial() { return pgpPublicKeyData == null; } public boolean isOwn() { return id == OWN_PROFILE_ID; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/profile/ProfileMapper.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.profile; import io.xeres.common.dto.profile.ProfileDTO; import io.xeres.common.id.ProfileFingerprint; import io.xeres.ui.model.location.LocationMapper; @SuppressWarnings("DuplicatedCode") public final class ProfileMapper { private ProfileMapper() { throw new UnsupportedOperationException("Utility class"); } public static Profile fromDTO(ProfileDTO dto) { if (dto == null) { return null; } var profile = new Profile(); profile.setId(dto.id()); profile.setName(dto.name()); profile.setPgpIdentifier(Long.parseLong(dto.pgpIdentifier())); profile.setCreated(dto.created()); profile.setProfileFingerprint(new ProfileFingerprint(dto.pgpFingerprint())); profile.setPgpPublicKeyData(dto.pgpPublicKeyData()); profile.setAccepted(dto.accepted()); profile.setTrust(dto.trust()); return profile; } public static Profile fromDeepDTO(ProfileDTO dto) { if (dto == null) { return null; } var profile = fromDTO(dto); profile.addLocations(dto.locations().stream() .map(LocationMapper::fromDeepDTO) .toList()); return profile; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/settings/Settings.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.settings; import io.micrometer.common.util.StringUtils; public class Settings implements Cloneable { private String torSocksHost; private int torSocksPort; private String i2pSocksHost; private int i2pSocksPort; private boolean upnpEnabled; private boolean broadcastDiscoveryEnabled; private boolean dhtEnabled; private boolean autoStartEnabled; private String incomingDirectory; private String remotePassword; private boolean remoteEnabled; private boolean isUpnpRemoteEnabled; private int remotePort; public String getTorSocksHost() { return torSocksHost; } public void setTorSocksHost(String torSocksHost) { this.torSocksHost = torSocksHost; } public int getTorSocksPort() { return torSocksPort; } public void setTorSocksPort(int torSocksPort) { this.torSocksPort = torSocksPort; } public String getI2pSocksHost() { return i2pSocksHost; } public void setI2pSocksHost(String i2pSocksHost) { this.i2pSocksHost = i2pSocksHost; } public int getI2pSocksPort() { return i2pSocksPort; } public void setI2pSocksPort(int i2pSocksPort) { this.i2pSocksPort = i2pSocksPort; } public boolean isUpnpEnabled() { return upnpEnabled; } public void setUpnpEnabled(boolean upnpEnabled) { this.upnpEnabled = upnpEnabled; } public boolean isBroadcastDiscoveryEnabled() { return broadcastDiscoveryEnabled; } public void setBroadcastDiscoveryEnabled(boolean broadcastDiscoveryEnabled) { this.broadcastDiscoveryEnabled = broadcastDiscoveryEnabled; } public boolean isDhtEnabled() { return dhtEnabled; } public void setDhtEnabled(boolean dhtEnabled) { this.dhtEnabled = dhtEnabled; } public boolean isAutoStartEnabled() { return autoStartEnabled; } public void setAutoStartEnabled(boolean autoStartEnabled) { this.autoStartEnabled = autoStartEnabled; } public boolean hasIncomingDirectory() { return StringUtils.isNotEmpty(incomingDirectory); } public String getIncomingDirectory() { return incomingDirectory; } public void setIncomingDirectory(String incomingDirectory) { this.incomingDirectory = incomingDirectory; } public String getRemotePassword() { return remotePassword; } public void setRemotePassword(String remotePassword) { this.remotePassword = remotePassword; } public boolean isRemoteEnabled() { return remoteEnabled; } public void setRemoteEnabled(boolean enabled) { remoteEnabled = enabled; } public boolean isUpnpRemoteEnabled() { return isUpnpRemoteEnabled; } public void setUpnpRemoteEnabled(boolean upnpRemoteEnabled) { isUpnpRemoteEnabled = upnpRemoteEnabled; } public int getRemotePort() { return remotePort; } public void setRemotePort(int remotePort) { this.remotePort = remotePort; } @Override public Settings clone() { try { return (Settings) super.clone(); } catch (CloneNotSupportedException _) { throw new AssertionError(); } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/settings/SettingsMapper.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.settings; import io.xeres.common.dto.settings.SettingsDTO; public final class SettingsMapper { private SettingsMapper() { throw new UnsupportedOperationException("Utility class"); } public static Settings fromDTO(SettingsDTO dto) { if (dto == null) { return null; } var settings = new Settings(); settings.setTorSocksHost(dto.torSocksHost()); settings.setTorSocksPort(dto.torSocksPort()); settings.setI2pSocksHost(dto.i2pSocksHost()); settings.setI2pSocksPort(dto.i2pSocksPort()); settings.setUpnpEnabled(dto.upnpEnabled()); settings.setBroadcastDiscoveryEnabled(dto.broadcastDiscoveryEnabled()); settings.setDhtEnabled(dto.dhtEnabled()); settings.setAutoStartEnabled(dto.autoStartEnabled()); settings.setIncomingDirectory(dto.incomingDirectory()); settings.setRemotePassword(dto.remotePassword()); settings.setRemoteEnabled(dto.remoteEnabled()); settings.setUpnpRemoteEnabled(dto.upnpRemoteEnabled()); settings.setRemotePort(dto.remotePort()); return settings; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/share/Share.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.share; import io.xeres.common.pgp.Trust; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import java.time.Instant; public class Share { private long id; private String name; private String path; private Trust browsable; private Instant lastScanned; private final BooleanProperty searchable = new SimpleBooleanProperty(); public long getId() { return id; } public void setId(long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getPath() { return path; } public void setPath(String path) { this.path = path; } public BooleanProperty searchableProperty() { return searchable; } public boolean isSearchable() { return searchable.get(); } public void setSearchable(boolean searchable) { this.searchable.set(searchable); } public Trust getBrowsable() { return browsable; } public void setBrowsable(Trust browsable) { this.browsable = browsable; } public Instant getLastScanned() { return lastScanned; } public void setLastScanned(Instant lastScanned) { this.lastScanned = lastScanned; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/model/share/ShareMapper.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.model.share; import io.xeres.common.dto.share.ShareDTO; import java.util.List; import static org.apache.commons.collections4.ListUtils.emptyIfNull; public final class ShareMapper { private ShareMapper() { throw new UnsupportedOperationException("Utility class"); } public static Share fromDTO(ShareDTO dto) { if (dto == null) { return null; } var share = new Share(); share.setId(dto.id()); share.setName(dto.name()); share.setPath(dto.path()); share.setSearchable(dto.searchable()); share.setBrowsable(dto.browsable()); share.setLastScanned(dto.lastScanned()); return share; } public static ShareDTO toDTO(Share share) { if (share == null) { return null; } return new ShareDTO(share.getId(), share.getName(), share.getPath(), share.isSearchable(), share.getBrowsable(), share.getLastScanned()); } public static List toDTOs(List shares) { return emptyIfNull(shares).stream() .map(ShareMapper::toDTO) .toList(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/properties/UiClientProperties.java ================================================ /* * Copyright (c) 2023-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.properties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; @Configuration @ConfigurationProperties(prefix = "xrs.ui.client") public class UiClientProperties { private boolean coloredEmojis = true; private boolean smileyToUnicode = true; private boolean rsEmojisAliases = true; private boolean OEmbed = true; private int imageCacheSize; public boolean isColoredEmojis() { return coloredEmojis; } public void setColoredEmojis(boolean coloredEmojis) { this.coloredEmojis = coloredEmojis; } public boolean isSmileyToUnicode() { return smileyToUnicode; } public void setSmileyToUnicode(boolean smileyToUnicode) { this.smileyToUnicode = smileyToUnicode; } public boolean isRsEmojisAliases() { return rsEmojisAliases; } public void setRsEmojisAliases(boolean rsEmojisAliases) { this.rsEmojisAliases = rsEmojisAliases; } public int getImageCacheSize() { return imageCacheSize; } public void setImageCacheSize(int imageCacheSize) { this.imageCacheSize = imageCacheSize; } public boolean isOEmbed() { return OEmbed; } public void setOEmbed(boolean OEmbed) { this.OEmbed = OEmbed; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/ImageCacheService.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.properties.UiClientProperties; import javafx.scene.image.Image; import org.springframework.stereotype.Service; import java.lang.ref.ReferenceQueue; import java.lang.ref.SoftReference; import java.util.LinkedHashMap; /** * Image cache service. Can only be used on one thread (normally the JavaFX thread). */ @Service public class ImageCacheService implements ImageCache { /** * The maximum size for one image to be allowed in the cache. */ private static final int MAX_IMAGE_SIZE = 300 * 300 * 4; private final LinkedHashMap images = new LinkedHashMap<>(16, 0.75f, true); private final int maxSize; private int currentSize; private final ReferenceQueue referenceQueue = new ReferenceQueue<>(); public ImageCacheService(UiClientProperties uiClientProperties) { maxSize = uiClientProperties.getImageCacheSize() * 1024; } @Override public Image getImage(String url) { var ref = images.get(url); if (ref != null) { return ref.get(); } return null; } @Override public void putImage(String url, Image image) { if (!isUrlCacheable(url) || !isImageCacheable(image)) { return; } // Already in there var ref = images.get(url); if (ref != null && ref.get() == image) { return; } // Old entry, remove it if (ref != null) { removeRef(ref); } int newSize = (int) image.getWidth() * (int) image.getHeight() * 4; currentSize += newSize; cleanupOldReferencesIfNeeded(); cleanupOldItemsIfNeeded(); images.put(url, new ImageSizeSoftReference(image, referenceQueue, url, newSize)); } @Override public void evictImage(String url) { var ref = images.get(url); if (ref != null) { removeRef(ref); } } @Override public void evictAllImages() { images.clear(); currentSize = 0; } private void removeRef(ImageSizeSoftReference ref) { currentSize -= ref.size; images.remove(ref.url); } private void cleanupOldReferencesIfNeeded() { ImageSizeSoftReference ref; if (currentSize > maxSize) { while ((ref = (ImageSizeSoftReference) referenceQueue.poll()) != null) { images.remove(ref.url); currentSize -= ref.size; } } } private void cleanupOldItemsIfNeeded() { if (currentSize > maxSize) { var it = images.entrySet().iterator(); while ((currentSize > maxSize) && (it.hasNext())) { var entry = it.next(); it.remove(); currentSize -= entry.getValue().size; } } } private boolean isImageCacheable(Image image) { return maxSize > 0 && image.getWidth() * image.getHeight() * 4 < MAX_IMAGE_SIZE; } private boolean isUrlCacheable(String url) { return !url.startsWith("data:"); } private static class ImageSizeSoftReference extends SoftReference { private final String url; private final int size; public ImageSizeSoftReference(Image referent, ReferenceQueue q, String url, int size) { super(referent, q); this.url = url; this.size = size; } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/chat/AliasEntry.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.chat; public record AliasEntry(String name, String required, String optional, String description) { } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/chat/ChatAction.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.chat; import io.xeres.common.id.GxsId; import java.util.Objects; import java.util.stream.Stream; import static io.xeres.ui.support.chat.ChatAction.Type.*; public class ChatAction { public enum Type { JOIN, LEAVE, SAY, SAY_OWN, ACTION, TIMEOUT } private Type type; private final String nickname; private final String gxsId; public ChatAction(Type type, String nickname, GxsId gxsId) { Objects.requireNonNull(type); Objects.requireNonNull(nickname); this.type = type; this.nickname = nickname; this.gxsId = gxsId != null ? gxsId.toString() : null; // XXX: fix to always require gxsId... } public String getAction() { return switch (type) { case JOIN -> "–>"; case LEAVE, TIMEOUT -> "<–"; case SAY, SAY_OWN -> "<" + nickname + ">"; case ACTION -> "*"; }; } public Type getType() { return type; } public void setType(Type type) { this.type = type; } public String getNickname() { return nickname; } public String getGxsId() { return gxsId; } /** * Checks if it's a presence event. Those events don't have any user content (the user cannot say anything in them). * @return true if it's a presence event (join, leave or timeout). */ public boolean isPresenceEvent() { return Stream.of(JOIN, LEAVE, TIMEOUT).anyMatch(v -> type == v); } /** * Gets a presence content, to put in a line. * @return the presence content */ public String getPresenceLine() { if (!isPresenceEvent()) { throw new IllegalStateException("no presence line available, type: " + type); } var reason = ""; if (type == TIMEOUT) { reason = " [Ping timeout]"; } return nickname + " (" + gxsId + ")" + reason; } @Override public String toString() { return "ChatAction{" + "type=" + type + ", nickname='" + nickname + '\'' + ", gxsId='" + gxsId + '\'' + '}'; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/chat/ChatCommand.java ================================================ /* * Copyright (c) 2024-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.chat; import io.xeres.common.i18n.I18nUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.text.MessageFormat; import java.util.List; import java.util.concurrent.ThreadLocalRandom; import java.util.regex.Pattern; /** * A utility class to parse outgoing commands. */ public final class ChatCommand { private static final Logger log = LoggerFactory.getLogger(ChatCommand.class); private static final Pattern SPACE_PATTERN = Pattern.compile("\\s"); public static final List ALIASES = List.of( new AliasEntry("code", "text", null, I18nUtils.getBundle().getString("chat-command.code")), new AliasEntry("flip", null, null, I18nUtils.getBundle().getString("chat-command.coin")), new AliasEntry("me", "message", null, I18nUtils.getBundle().getString("chat-command.me")), new AliasEntry("pre", "text", null, I18nUtils.getBundle().getString("chat-command.pre")), new AliasEntry("quote", "text", null, I18nUtils.getBundle().getString("chat-command.quote")), new AliasEntry("random", null, "max | min-max", I18nUtils.getBundle().getString("chat-command.random")), new AliasEntry("shrug", null, "target", MessageFormat.format(I18nUtils.getBundle().getString("chat-command-send"), "¯\\_(ツ)_/¯")), new AliasEntry("table", null, "target", MessageFormat.format(I18nUtils.getBundle().getString("chat-command-send"), "(╯°□°)╯︵ ┻━┻")) ); private static final String COMMAND_CODE = "/code "; private static final String COMMAND_FLIP = "/flip"; private static final String COMMAND_PRE = "/pre "; private static final String COMMAND_QUOTE = "/quote "; private static final String COMMAND_RANDOM = "/random"; private static final String COMMAND_SHRUG = "/shrug"; private static final String COMMAND_TABLE = "/table"; private ChatCommand() { throw new UnsupportedOperationException("Utility class"); } /** * This parses outgoing commands so that they're formatted properly. * * @param s the string to be processed * @return the string with correct formatting */ public static String parseCommands(String s) { if (StringUtils.isEmpty(s)) { return s; } var pre = false; if (s.startsWith(COMMAND_CODE)) { pre = true; s = s.substring(COMMAND_CODE.length()); } else if (s.startsWith(COMMAND_PRE)) { pre = true; s = s.substring(COMMAND_PRE.length()); } else if (s.startsWith(COMMAND_QUOTE)) { s = "\n> " + s.substring(COMMAND_QUOTE.length()); } else if (s.startsWith(COMMAND_FLIP)) { return "🪙 (" + (ThreadLocalRandom.current().nextBoolean() ? "heads" : "tails") + ")"; } else if (s.startsWith(COMMAND_RANDOM)) { var min = 1; var max = 11; if (s.length() > COMMAND_RANDOM.length() + 1) { s = s.substring(COMMAND_RANDOM.length() + 1); try { s = SPACE_PATTERN.matcher(s).replaceAll(""); if (s.contains("-")) { min = Integer.parseInt(s.substring(0, s.indexOf("-"))); max = Integer.parseInt(s.substring(s.indexOf("-") + 1)); } else { max = Integer.parseInt(s); } } catch (NumberFormatException | IndexOutOfBoundsException exception) { log.error("Couldn't parse /random input: [{}], {}", s, exception.getMessage()); } } return "🎲 " + ThreadLocalRandom.current().nextInt(min, max); } else if (s.startsWith(COMMAND_SHRUG)) { return suffixWithSpaceIfNeeded(s.substring(COMMAND_SHRUG.length())) + "¯\\\\\\_(ツ)\\_/¯"; } else if (s.startsWith(COMMAND_TABLE)) { return "(╯°□°)╯︵ ┻━┻" + prefixWithSpaceIfNeeded(s.substring(COMMAND_TABLE.length())); } if (pre) { return "\n" + s.indent(4); } return s; } private static String prefixWithSpaceIfNeeded(String s) { if (!StringUtils.isBlank(s)) { return " " + s; } return ""; } private static String suffixWithSpaceIfNeeded(String s) { if (!StringUtils.isBlank(s)) { return s + " "; } return ""; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/chat/ChatLine.java ================================================ /* * Copyright (c) 2019-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.chat; import io.xeres.common.id.GxsId; import io.xeres.ui.support.contentline.Content; import io.xeres.ui.support.contentline.ContentText; import javafx.scene.text.Text; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; import java.util.List; public class ChatLine { private static final Logger log = LoggerFactory.getLogger(ChatLine.class); private final Instant instant; private final ChatAction action; private final List contents; public ChatLine(Instant instant, ChatAction action, List contents) { this.instant = instant; this.action = action; if (action.isPresenceEvent()) { if (log.isDebugEnabled() && !contents.isEmpty()) { log.debug("Chat content for action {} is not needed", action); } this.contents = List.of(new ContentText(action.getPresenceLine())); } else { this.contents = contents; } } public ChatLine withContent(List contents) { return new ChatLine(instant, action, contents); } public Instant getInstant() { return instant; } public String getAction() { return action.getAction(); } public boolean hasSaid(GxsId gxsId) { return action.getType() == ChatAction.Type.SAY && gxsId.toString().equals(action.getGxsId()); } public String getNicknameColor() { return switch (action.getType()) { case SAY -> ColorGenerator.generateColor(action.getGxsId() != null ? action.getGxsId() : action.getNickname()); default -> null; }; } public boolean isActiveAction() { return switch (action.getType()) { case JOIN, LEAVE, TIMEOUT -> false; case SAY, SAY_OWN, ACTION -> true; }; } public List getChatContents() { return contents; } /** * Tells if a ChatLine contains "rich" content, that is, anything else than a line of text. * * @return true if the content is rich content */ public boolean isRich() { return contents.size() > 1 || (contents.size() == 1 && !(contents.getFirst() instanceof ContentText)); } public boolean isQuote() { return !contents.isEmpty() && contents.getFirst() instanceof ContentText text && ((Text) text.getNode()).getText().startsWith("> "); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/chat/ChatParser.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.chat; public final class ChatParser { private ChatParser() { throw new UnsupportedOperationException("Utility class"); } public static boolean isActionMe(String s) { return s.startsWith("/me "); } public static String parseActionMe(String s, String nickname) { return nickname + " " + s.substring(4); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/chat/ColorGenerator.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.chat; import java.util.Arrays; import java.util.List; import java.util.Objects; public final class ColorGenerator { private ColorGenerator() { throw new UnsupportedOperationException("Utility class"); } /** * Colors nicked from Quassel * because they are great against a white background. */ private enum ColorSpec { COLOR_00("color-00"), COLOR_01("color-01"), COLOR_02("color-02"), COLOR_03("color-03"), COLOR_04("color-04"), COLOR_05("color-05"), COLOR_06("color-06"), COLOR_07("color-07"), COLOR_08("color-08"), COLOR_09("color-09"), COLOR_10("color-10"), COLOR_11("color-11"), COLOR_12("color-12"), COLOR_13("color-13"), COLOR_14("color-14"), COLOR_15("color-15"); private final String color; ColorSpec(String color) { this.color = color; } public String getColor() { return color; } } public static String generateColor(String s) { Objects.requireNonNull(s); return ColorSpec.values()[Math.floorMod(s.hashCode(), ColorSpec.values().length)].getColor(); } public static List getAllColors() { return Arrays.stream(ColorSpec.values()).map(ColorSpec::getColor).toList(); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/chat/NicknameCompleter.java ================================================ /* * Copyright (c) 2019-2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.chat; import java.util.function.Consumer; public class NicknameCompleter { public interface UsernameFinder { String getUsername(String prefix, int index); } private UsernameFinder usernameFinder; private int completionIndex; private boolean atStart; private String prefix; private boolean hasContext; private String lastSuggestedNickname; public void setUsernameFinder(UsernameFinder usernameFinder) { this.usernameFinder = usernameFinder; } public void complete(String line, int caretPosition, Consumer action) { if (usernameFinder == null) { return; } if (!hasContext) { if (!line.contains(" ")) { atStart = true; } prefix = findPrefix(line, caretPosition, atStart); hasContext = true; } var suggestedNickname = usernameFinder.getUsername(prefix, completionIndex); if (suggestedNickname != null) { if (atStart) { action.accept(suggestedNickname + ": "); } else { action.accept((lastSuggestedNickname != null ? line.substring(0, line.length() - lastSuggestedNickname.length()) : line.substring(0, line.length() - prefix.length())) + suggestedNickname); } lastSuggestedNickname = suggestedNickname; } completionIndex++; } public void reset() { completionIndex = 0; atStart = false; hasContext = false; lastSuggestedNickname = null; } private static String findPrefix(String line, int caretPosition, boolean atStart) { var start = atStart ? 0 : (line.lastIndexOf(" ", caretPosition) + 1); return line.substring(start, caretPosition); } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java ================================================ /* * Copyright (c) 2024-2026 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.clipboard; import javafx.embed.swing.SwingFXUtils; import javafx.scene.image.Image; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.awt.*; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.StringSelection; import java.awt.datatransfer.Transferable; import java.awt.datatransfer.UnsupportedFlavorException; import java.awt.image.BufferedImage; import java.io.IOException; /** * Utility class to use the clipboard. This implementation uses AWT because the clipboard support of JavaFX is, quite frankly, a * royal piece of shit: *

    *
  • it fails to work with some bitmaps (for example from Telegram, Windows 10 and print screen, Chrome, ...). *
  • it fails with data URIs because it tries to find out if the image is a supported format and even though it is, the URL is "wrong" for it. *
*

* This one just works. Note that there still might be some warnings printed out because of the DataFlavor system that isn't compatible * with everything. It's harmless though. */ public final class ClipboardUtils { private static final Logger log = LoggerFactory.getLogger(ClipboardUtils.class); private ClipboardUtils() { throw new UnsupportedOperationException("Utility class"); } /** * Gets whatever is in the clipboard and supported, currently: string and JavaFX images. * * @return a string or an image. Null if there's nothing in the clipboard, or it's not supported */ public static Object getSupportedObjectFromClipboard() { Object object = getImageFromClipboard(); if (object == null) { object = getStringFromClipboard(); } return object; } /** * Gets an image from the clipboard * * @return the image, or null if the clipboard is empty, or it doesn't contain an image */ public static Image getImageFromClipboard() { var transferable = getTransferable(); if (transferable != null && transferable.isDataFlavorSupported(DataFlavor.imageFlavor)) { BufferedImage image; try { image = (BufferedImage) transferable.getTransferData(DataFlavor.imageFlavor); } catch (UnsupportedFlavorException | IOException e) { throw new RuntimeException(e); } return SwingFXUtils.toFXImage(image, null); } return null; } /** * Copies an image to the clipboard. * * @param image the image to copy to the clipboard */ public static void copyImageToClipboard(Image image) { try { Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageSelection(SwingFXUtils.fromFXImage(image, null)), null); } catch (HeadlessException | IllegalStateException e) { log.warn("Clipboard not available to copy image: {}", e.getMessage()); } } /** * Gets a string from the clipboard. * * @return a string, or null if the clipboard is empty, or it doesn't contain a string */ public static String getStringFromClipboard() { var transferable = getTransferable(); if (transferable != null && transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) { String string; try { string = (String) transferable.getTransferData(DataFlavor.stringFlavor); } catch (UnsupportedFlavorException | IOException e) { throw new RuntimeException(e); } return string; } return null; } /** * Copies a string to the clipboard. * * @param text the string to copy to the clipboard */ public static void copyTextToClipboard(String text) { try { Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null); } catch (HeadlessException | IllegalStateException e) { log.warn("Clipboard not available to copy text: {}", e.getMessage()); } } private static Transferable getTransferable() { try { return Toolkit.getDefaultToolkit().getSystemClipboard().getContents(null); } catch (HeadlessException | IllegalStateException e) { log.warn("Clipboard not available to get transferable: {}", e.getMessage()); return null; } } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/clipboard/ImageSelection.java ================================================ /* * Copyright (c) 2024 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.clipboard; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.Transferable; import java.awt.datatransfer.UnsupportedFlavorException; import java.awt.image.BufferedImage; /** * This class is needed to save images to the clipboard using AWT. */ class ImageSelection implements Transferable { private final BufferedImage image; public ImageSelection(BufferedImage image) { this.image = image; } @Override public DataFlavor[] getTransferDataFlavors() { return new DataFlavor[]{DataFlavor.imageFlavor}; } @Override public boolean isDataFlavorSupported(DataFlavor flavor) { return DataFlavor.imageFlavor.equals(flavor); } @Override public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException { if (!DataFlavor.imageFlavor.equals(flavor)) { throw new UnsupportedFlavorException(flavor); } return image; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/contact/ContactUtils.java ================================================ /* * Copyright (c) 2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.contact; import io.xeres.common.rest.contact.Contact; import io.xeres.common.util.RemoteUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.xeres.common.dto.identity.IdentityConstants.NO_IDENTITY_ID; import static io.xeres.common.dto.profile.ProfileConstants.NO_PROFILE_ID; import static io.xeres.common.rest.PathConfig.IDENTITIES_PATH; import static io.xeres.common.rest.PathConfig.PROFILES_PATH; public final class ContactUtils { private static final Logger log = LoggerFactory.getLogger(ContactUtils.class); private ContactUtils() { throw new UnsupportedOperationException("Utility class"); } public static String getIdentityImageUrl(Contact contact) { if (contact.identityId() != NO_IDENTITY_ID) { return RemoteUtils.getControlUrl() + IDENTITIES_PATH + "/" + contact.identityId() + "/image"; } else if (contact.profileId() != NO_PROFILE_ID) { return RemoteUtils.getControlUrl() + PROFILES_PATH + "/" + contact.profileId() + "/image"; } log.error("Contact {} is empty", contact); return null; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/contentline/Content.java ================================================ /* * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.contentline; import javafx.scene.Node; public interface Content { Node getNode(); default String asText() { return ""; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/contentline/ContentCode.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.contentline; import javafx.scene.Node; import javafx.scene.text.Text; public class ContentCode implements Content { private static final String STYLE = "-fx-font-family: \"monospace\"; -fx-fill: -color-success-fg"; private final Text node; public ContentCode(String text) { node = new Text(text); node.setStyle(STYLE); } @Override public Node getNode() { return node; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java ================================================ /* * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.contentline; import javafx.scene.Node; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.text.Font; public class ContentEmoji implements Content { private static final double SCALE_FACTOR = 1.5; private final ImageView node; public ContentEmoji(Image image, String emoji) { node = new ImageView(image); node.setUserData(emoji); // Used for cut & paste node.setFitWidth(Font.getDefault().getSize() * SCALE_FACTOR); node.setFitHeight(Font.getDefault().getSize() * SCALE_FACTOR); } @Override public Node getNode() { return node; } } ================================================ FILE: ui/src/main/java/io/xeres/ui/support/contentline/ContentEmphasis.java ================================================ /* * Copyright (c) 2023 by David Gerber - https://zapek.com * * This file is part of Xeres. * * Xeres is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Xeres is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Xeres. If not, see . */ package io.xeres.ui.support.contentline; import javafx.scene.Node; import javafx.scene.text.Text; import java.util.Set; public class ContentEmphasis implements Content { public enum Style { BOLD, ITALIC } private final Text node; public ContentEmphasis(String text, Set