From c9171413240453da669a51ebf3934c46e7d7a88a Mon Sep 17 00:00:00 2001 From: Anna Clemens Date: Thu, 7 Jul 2022 22:58:32 -0400 Subject: [PATCH] chore: initial commit --- README.md | 40 + client/.gitignore | 365 +++ client/ExtraChat.sln | 16 + client/ExtraChat/ArrayExtensions.cs | 18 + client/ExtraChat/Client.cs | 950 +++++++ client/ExtraChat/Commands.cs | 100 + client/ExtraChat/Configuration.cs | 151 ++ client/ExtraChat/Ext.cs | 34 + client/ExtraChat/ExtraChat.csproj | 64 + client/ExtraChat/ExtraChat.yaml | 5 + .../Formatters/BinaryUuidFormatter.cs | 26 + .../Formatters/BinaryUuidNullableFormatter.cs | 28 + .../Formatters/ListRequestFormatter.cs | 41 + .../Formatters/ListResponseFormatter.cs | 56 + .../Formatters/MemberChangeKindFormatter.cs | 69 + .../Formatters/RegisterResponseFormatter.cs | 81 + .../Formatters/RequestKindFormatter.cs | 160 ++ .../Formatters/ResponseKindFormatter.cs | 105 + .../Formatters/UpdateKindFormatter.cs | 47 + client/ExtraChat/GameFunctions.cs | 268 ++ client/ExtraChat/Ipc.cs | 65 + client/ExtraChat/Plugin.cs | 155 ++ .../ExtraChat/Protocol/AuthenticateRequest.cs | 13 + .../Protocol/AuthenticateResponse.cs | 10 + client/ExtraChat/Protocol/Channels/Channel.cs | 24 + client/ExtraChat/Protocol/Channels/Member.cs | 19 + client/ExtraChat/Protocol/Channels/Rank.cs | 20 + .../Protocol/Channels/SimpleChannel.cs | 18 + client/ExtraChat/Protocol/CreateRequest.cs | 10 + client/ExtraChat/Protocol/CreateResponse.cs | 11 + client/ExtraChat/Protocol/DisbandRequest.cs | 12 + client/ExtraChat/Protocol/DisbandResponse.cs | 12 + client/ExtraChat/Protocol/ErrorResponse.cs | 15 + client/ExtraChat/Protocol/InviteRequest.cs | 21 + client/ExtraChat/Protocol/InviteResponse.cs | 18 + client/ExtraChat/Protocol/InvitedResponse.cs | 23 + client/ExtraChat/Protocol/JoinRequest.cs | 12 + client/ExtraChat/Protocol/JoinResponse.cs | 11 + client/ExtraChat/Protocol/KickRequest.cs | 18 + client/ExtraChat/Protocol/KickResponse.cs | 18 + client/ExtraChat/Protocol/LeaveRequest.cs | 12 + client/ExtraChat/Protocol/LeaveResponse.cs | 15 + client/ExtraChat/Protocol/ListRequest.cs | 17 + client/ExtraChat/Protocol/ListResponse.cs | 22 + client/ExtraChat/Protocol/MemberChangeKind.cs | 31 + .../Protocol/MemberChangeResponse.cs | 21 + client/ExtraChat/Protocol/MessageRequest.cs | 15 + client/ExtraChat/Protocol/MessageResponse.cs | 21 + client/ExtraChat/Protocol/PingRequest.cs | 8 + client/ExtraChat/Protocol/PingResponse.cs | 8 + client/ExtraChat/Protocol/PromoteRequest.cs | 22 + client/ExtraChat/Protocol/PromoteResponse.cs | 22 + client/ExtraChat/Protocol/PublicKeyRequest.cs | 13 + .../ExtraChat/Protocol/PublicKeyResponse.cs | 16 + client/ExtraChat/Protocol/RegisterRequest.cs | 16 + client/ExtraChat/Protocol/RegisterResponse.cs | 18 + client/ExtraChat/Protocol/RequestContainer.cs | 13 + client/ExtraChat/Protocol/RequestKind.cs | 57 + .../ExtraChat/Protocol/ResponseContainer.cs | 13 + client/ExtraChat/Protocol/ResponseKind.cs | 69 + client/ExtraChat/Protocol/SecretsRequest.cs | 12 + client/ExtraChat/Protocol/SecretsResponse.cs | 18 + .../ExtraChat/Protocol/SendSecretsRequest.cs | 15 + .../ExtraChat/Protocol/SendSecretsResponse.cs | 19 + client/ExtraChat/Protocol/UpdateKind.cs | 12 + client/ExtraChat/Protocol/UpdateRequest.cs | 15 + client/ExtraChat/Protocol/UpdateResponse.cs | 12 + client/ExtraChat/Protocol/UpdatedResponse.cs | 15 + client/ExtraChat/Ui/PluginUi.cs | 738 ++++++ client/ExtraChat/Util/ColourUtil.cs | 118 + client/ExtraChat/Util/ImGuiUtil.cs | 72 + client/ExtraChat/Util/SecretBox.cs | 20 + client/ExtraChat/Util/WorldUtil.cs | 29 + server/.gitignore | 7 + server/Cargo.lock | 2348 +++++++++++++++++ server/Cargo.toml | 35 + server/config.example.toml | 5 + server/migrations/.gitkeep | 0 server/migrations/1_initial_schema.sql | 45 + server/migrations/2_caching.sql | 3 + server/migrations/3_additional_indexes.sql | 3 + server/src/handlers/authenticate.rs | 86 + server/src/handlers/create.rs | 55 + server/src/handlers/disband.rs | 33 + server/src/handlers/invite.rs | 121 + server/src/handlers/join.rs | 60 + server/src/handlers/kick.rs | 98 + server/src/handlers/leave.rs | 109 + server/src/handlers/list.rs | 159 ++ server/src/handlers/message.rs | 56 + server/src/handlers/mod.rs | 38 + server/src/handlers/ping.rs | 8 + server/src/handlers/promote.rs | 112 + server/src/handlers/public_key.rs | 34 + server/src/handlers/register.rs | 135 + server/src/handlers/secrets.rs | 84 + server/src/handlers/send_secrets.rs | 42 + server/src/handlers/update.rs | 39 + server/src/handlers/version.rs | 24 + server/src/logging.rs | 47 + server/src/main.rs | 443 ++++ server/src/types/config.rs | 17 + server/src/types/mod.rs | 3 + server/src/types/protocol/announce.rs | 14 + server/src/types/protocol/authenticate.rs | 28 + server/src/types/protocol/channel.rs | 180 ++ server/src/types/protocol/container.rs | 125 + server/src/types/protocol/create.rs | 15 + server/src/types/protocol/disband.rs | 12 + server/src/types/protocol/error.rs | 17 + server/src/types/protocol/invite.rs | 32 + server/src/types/protocol/join.rs | 13 + server/src/types/protocol/kick.rs | 16 + server/src/types/protocol/leave.rs | 29 + server/src/types/protocol/list.rs | 28 + server/src/types/protocol/member_change.rs | 35 + server/src/types/protocol/message.rs | 19 + server/src/types/protocol/mod.rs | 45 + server/src/types/protocol/ping.rs | 9 + server/src/types/protocol/promote.rs | 19 + server/src/types/protocol/public_key.rs | 16 + server/src/types/protocol/register.rs | 21 + server/src/types/protocol/secrets.rs | 46 + server/src/types/protocol/update.rs | 26 + server/src/types/protocol/version.rs | 11 + server/src/types/user.rs | 9 + server/src/updater.rs | 86 + server/src/util.rs | 261 ++ server/src/util/redacted.rs | 112 + 129 files changed, 10126 insertions(+) create mode 100644 README.md create mode 100644 client/.gitignore create mode 100644 client/ExtraChat.sln create mode 100644 client/ExtraChat/ArrayExtensions.cs create mode 100644 client/ExtraChat/Client.cs create mode 100644 client/ExtraChat/Commands.cs create mode 100644 client/ExtraChat/Configuration.cs create mode 100644 client/ExtraChat/Ext.cs create mode 100644 client/ExtraChat/ExtraChat.csproj create mode 100644 client/ExtraChat/ExtraChat.yaml create mode 100644 client/ExtraChat/Formatters/BinaryUuidFormatter.cs create mode 100644 client/ExtraChat/Formatters/BinaryUuidNullableFormatter.cs create mode 100644 client/ExtraChat/Formatters/ListRequestFormatter.cs create mode 100644 client/ExtraChat/Formatters/ListResponseFormatter.cs create mode 100644 client/ExtraChat/Formatters/MemberChangeKindFormatter.cs create mode 100644 client/ExtraChat/Formatters/RegisterResponseFormatter.cs create mode 100644 client/ExtraChat/Formatters/RequestKindFormatter.cs create mode 100644 client/ExtraChat/Formatters/ResponseKindFormatter.cs create mode 100644 client/ExtraChat/Formatters/UpdateKindFormatter.cs create mode 100644 client/ExtraChat/GameFunctions.cs create mode 100644 client/ExtraChat/Ipc.cs create mode 100644 client/ExtraChat/Plugin.cs create mode 100644 client/ExtraChat/Protocol/AuthenticateRequest.cs create mode 100644 client/ExtraChat/Protocol/AuthenticateResponse.cs create mode 100644 client/ExtraChat/Protocol/Channels/Channel.cs create mode 100644 client/ExtraChat/Protocol/Channels/Member.cs create mode 100644 client/ExtraChat/Protocol/Channels/Rank.cs create mode 100644 client/ExtraChat/Protocol/Channels/SimpleChannel.cs create mode 100644 client/ExtraChat/Protocol/CreateRequest.cs create mode 100644 client/ExtraChat/Protocol/CreateResponse.cs create mode 100644 client/ExtraChat/Protocol/DisbandRequest.cs create mode 100644 client/ExtraChat/Protocol/DisbandResponse.cs create mode 100644 client/ExtraChat/Protocol/ErrorResponse.cs create mode 100644 client/ExtraChat/Protocol/InviteRequest.cs create mode 100644 client/ExtraChat/Protocol/InviteResponse.cs create mode 100644 client/ExtraChat/Protocol/InvitedResponse.cs create mode 100644 client/ExtraChat/Protocol/JoinRequest.cs create mode 100644 client/ExtraChat/Protocol/JoinResponse.cs create mode 100644 client/ExtraChat/Protocol/KickRequest.cs create mode 100644 client/ExtraChat/Protocol/KickResponse.cs create mode 100644 client/ExtraChat/Protocol/LeaveRequest.cs create mode 100644 client/ExtraChat/Protocol/LeaveResponse.cs create mode 100644 client/ExtraChat/Protocol/ListRequest.cs create mode 100644 client/ExtraChat/Protocol/ListResponse.cs create mode 100644 client/ExtraChat/Protocol/MemberChangeKind.cs create mode 100644 client/ExtraChat/Protocol/MemberChangeResponse.cs create mode 100644 client/ExtraChat/Protocol/MessageRequest.cs create mode 100644 client/ExtraChat/Protocol/MessageResponse.cs create mode 100644 client/ExtraChat/Protocol/PingRequest.cs create mode 100644 client/ExtraChat/Protocol/PingResponse.cs create mode 100644 client/ExtraChat/Protocol/PromoteRequest.cs create mode 100644 client/ExtraChat/Protocol/PromoteResponse.cs create mode 100644 client/ExtraChat/Protocol/PublicKeyRequest.cs create mode 100644 client/ExtraChat/Protocol/PublicKeyResponse.cs create mode 100644 client/ExtraChat/Protocol/RegisterRequest.cs create mode 100644 client/ExtraChat/Protocol/RegisterResponse.cs create mode 100644 client/ExtraChat/Protocol/RequestContainer.cs create mode 100644 client/ExtraChat/Protocol/RequestKind.cs create mode 100644 client/ExtraChat/Protocol/ResponseContainer.cs create mode 100644 client/ExtraChat/Protocol/ResponseKind.cs create mode 100644 client/ExtraChat/Protocol/SecretsRequest.cs create mode 100644 client/ExtraChat/Protocol/SecretsResponse.cs create mode 100644 client/ExtraChat/Protocol/SendSecretsRequest.cs create mode 100644 client/ExtraChat/Protocol/SendSecretsResponse.cs create mode 100644 client/ExtraChat/Protocol/UpdateKind.cs create mode 100644 client/ExtraChat/Protocol/UpdateRequest.cs create mode 100644 client/ExtraChat/Protocol/UpdateResponse.cs create mode 100644 client/ExtraChat/Protocol/UpdatedResponse.cs create mode 100644 client/ExtraChat/Ui/PluginUi.cs create mode 100644 client/ExtraChat/Util/ColourUtil.cs create mode 100644 client/ExtraChat/Util/ImGuiUtil.cs create mode 100644 client/ExtraChat/Util/SecretBox.cs create mode 100644 client/ExtraChat/Util/WorldUtil.cs create mode 100755 server/.gitignore create mode 100755 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/config.example.toml create mode 100644 server/migrations/.gitkeep create mode 100644 server/migrations/1_initial_schema.sql create mode 100644 server/migrations/2_caching.sql create mode 100644 server/migrations/3_additional_indexes.sql create mode 100644 server/src/handlers/authenticate.rs create mode 100644 server/src/handlers/create.rs create mode 100644 server/src/handlers/disband.rs create mode 100644 server/src/handlers/invite.rs create mode 100644 server/src/handlers/join.rs create mode 100644 server/src/handlers/kick.rs create mode 100644 server/src/handlers/leave.rs create mode 100644 server/src/handlers/list.rs create mode 100644 server/src/handlers/message.rs create mode 100644 server/src/handlers/mod.rs create mode 100644 server/src/handlers/ping.rs create mode 100644 server/src/handlers/promote.rs create mode 100644 server/src/handlers/public_key.rs create mode 100644 server/src/handlers/register.rs create mode 100644 server/src/handlers/secrets.rs create mode 100644 server/src/handlers/send_secrets.rs create mode 100644 server/src/handlers/update.rs create mode 100644 server/src/handlers/version.rs create mode 100644 server/src/logging.rs create mode 100644 server/src/main.rs create mode 100644 server/src/types/config.rs create mode 100644 server/src/types/mod.rs create mode 100644 server/src/types/protocol/announce.rs create mode 100644 server/src/types/protocol/authenticate.rs create mode 100644 server/src/types/protocol/channel.rs create mode 100644 server/src/types/protocol/container.rs create mode 100644 server/src/types/protocol/create.rs create mode 100644 server/src/types/protocol/disband.rs create mode 100644 server/src/types/protocol/error.rs create mode 100644 server/src/types/protocol/invite.rs create mode 100644 server/src/types/protocol/join.rs create mode 100644 server/src/types/protocol/kick.rs create mode 100644 server/src/types/protocol/leave.rs create mode 100644 server/src/types/protocol/list.rs create mode 100644 server/src/types/protocol/member_change.rs create mode 100644 server/src/types/protocol/message.rs create mode 100644 server/src/types/protocol/mod.rs create mode 100644 server/src/types/protocol/ping.rs create mode 100644 server/src/types/protocol/promote.rs create mode 100644 server/src/types/protocol/public_key.rs create mode 100644 server/src/types/protocol/register.rs create mode 100644 server/src/types/protocol/secrets.rs create mode 100644 server/src/types/protocol/update.rs create mode 100644 server/src/types/protocol/version.rs create mode 100644 server/src/types/user.rs create mode 100644 server/src/updater.rs create mode 100644 server/src/util.rs create mode 100644 server/src/util/redacted.rs diff --git a/README.md b/README.md new file mode 100644 index 0000000..f600475 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# ExtraChat + +ExtraChat is a Dalamud plugin and an associated server that function +to add extra chat channels to FFXIV. Basically, this adds +cross-data-centre linkshells that don't have a member limit. + +## Security and privacy + +For your privacy and to ensure a lack of liability for server hosts, +messages and linkshell names are encrypted between the members of each +linkshell. The server is unable to decrypt the content of messages or +linkshell names. + +The server *does* know which characters are in which linkshells (an +operational requirement). + +Due to this design decision, it is impossible for a server to moderate +these extra linkshells, and linkshells will need to self-moderate +instead. As such, there is no ability to report users. + +## Encryption details + +When a user initiates the process to create a linkshell, their client +generates a random shared secret. The secret is saved locally by the +client. When the user invites another user to the linkshell, a +Diffie-Hellman key exchange is mediated by the server between the two +users, and then the inviter transmits the shared secret to the +invitee, encrypting it with their ephemeral shared secret created by +the key exchange. Due to the nature of the Diffie-Hellman exchange, +the server is unable to read the shared secret when it is sent. + +After this, the newly-invited user receives information about the +linkshell they have been invited to, and can decrypt the name, as well +as see any members. If the invitee decides to join, their client will +save this shared secret. + +Any messages sent to the linkshell are encrypted with the shared +secret, making their contents opaque to the server. The only way to +read these messages is to know the shared secret, which the server is +never able to discern. diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..1db30bf --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,365 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# Packaging +pack/ + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd diff --git a/client/ExtraChat.sln b/client/ExtraChat.sln new file mode 100644 index 0000000..c35c90a --- /dev/null +++ b/client/ExtraChat.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtraChat", "ExtraChat\ExtraChat.csproj", "{19904E25-FE96-41F8-BED5-24894CF189C2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {19904E25-FE96-41F8-BED5-24894CF189C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19904E25-FE96-41F8-BED5-24894CF189C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19904E25-FE96-41F8-BED5-24894CF189C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19904E25-FE96-41F8-BED5-24894CF189C2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/client/ExtraChat/ArrayExtensions.cs b/client/ExtraChat/ArrayExtensions.cs new file mode 100644 index 0000000..f9ccd97 --- /dev/null +++ b/client/ExtraChat/ArrayExtensions.cs @@ -0,0 +1,18 @@ +namespace ExtraChat; + +internal static class ArrayExtensions { + internal static byte[] Concat(this byte[] a, byte[] b) { + var result = new byte[a.Length + b.Length]; + + var idx = 0; + foreach (var t in a) { + result[idx++] = t; + } + + foreach (var t in b) { + result[idx++] = t; + } + + return result; + } +} diff --git a/client/ExtraChat/Client.cs b/client/ExtraChat/Client.cs new file mode 100644 index 0000000..55736f7 --- /dev/null +++ b/client/ExtraChat/Client.cs @@ -0,0 +1,950 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.WebSockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Channels; +using ASodium; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Logging; +using Dalamud.Utility; +using ExtraChat.Protocol; +using ExtraChat.Protocol.Channels; +using ExtraChat.Ui; +using ExtraChat.Util; +using Lumina.Excel.GeneratedSheets; +using Channel = ExtraChat.Protocol.Channels.Channel; + +namespace ExtraChat; + +internal class Client : IDisposable { + private const int IsUpPingNumber = 42069; + + internal enum State { + Disconnected, + Connecting, + NotAuthenticated, + RetrievingChallenge, + WaitingForVerification, + Verifying, + Authenticating, + FailedAuthentication, + Connected, + } + + private Plugin Plugin { get; } + private ClientWebSocket WebSocket { get; set; } + internal State Status { get; private set; } = State.Disconnected; + private bool _active = true; + private uint _number = 1; + private bool _wasConnected; + + private KeyPair KeyPair { get; } + + private readonly Mutex _waitersMutex = new(); + private Dictionary> Waiters { get; set; } = new(); + private Channel<(RequestContainer, ChannelWriter>?)> ToSend { get; set; } = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter>?)>(); + + internal Dictionary Channels { get; } = new(); + internal Dictionary InvitedChannels { get; } = new(); + internal Dictionary ChannelRanks { get; } = new(); + + internal Client(Plugin plugin) { + this.Plugin = plugin; + this.WebSocket = new ClientWebSocket(); + this.KeyPair = SodiumKeyExchange.GenerateKeyPair(); + + this.Plugin.ClientState.Login += this.Login; + this.Plugin.ClientState.Logout += this.Logout; + + if (this.Plugin.ClientState.IsLoggedIn) { + this.StartLoop(); + } + } + + public void Dispose() { + this.Plugin.ClientState.Login -= this.Login; + this.Plugin.ClientState.Logout -= this.Logout; + + this._active = false; + this.WebSocket.Dispose(); + } + + private void Login(object? sender, EventArgs e) { + this.StartLoop(); + } + + private void Logout(object? sender, EventArgs e) { + this.StopLoop(); + } + + internal bool TryGetChannel(Guid id, [MaybeNullWhen(false)] out Channel channel) { + return this.Channels.TryGetValue(id, out channel) || this.InvitedChannels.TryGetValue(id, out channel); + } + + internal void StopLoop() { + this._active = false; + this.WebSocket.Abort(); + } + + internal void StartLoop() { + this._active = true; + + Task.Run(async () => { + while (this._active) { + try { + await this.Loop(); + } catch (Exception ex) { + PluginLog.LogError(ex, "Error in client loop"); + if (this._wasConnected) { + this.Plugin.ChatGui.PrintChat(new XivChatEntry { + Message = "Disconnected from ExtraChat. Trying to reconnect.", + Type = XivChatType.Urgent, + }); + } + } + + await Task.Delay(TimeSpan.FromSeconds(3)); + } + // ReSharper disable once FunctionNeverReturns + }); + } + + private ChannelReader RegisterWaiter(uint number) { + var channel = System.Threading.Channels.Channel.CreateBounded(1); + this._waitersMutex.WaitOne(); + this.Waiters[number] = channel.Writer; + this._waitersMutex.ReleaseMutex(); + return channel.Reader; + } + + private async Task QueueMessage(RequestKind request) { + var container = new RequestContainer { + Number = this._number++, + Kind = request, + }; + + await this.ToSend.Writer.WriteAsync((container, null)); + } + + private async Task QueueMessageAndWait(RequestKind request) { + var container = new RequestContainer { + Number = this._number++, + Kind = request, + }; + + var channel = System.Threading.Channels.Channel.CreateBounded>(1); + await this.ToSend.Writer.WriteAsync((container, channel.Writer)); + var what = await channel.Reader.ReadAsync(); + return await what.ReadAsync(); + } + + private byte[] GetPrivateKey() { + var key = new byte[this.KeyPair.GetPrivateKeyLength()]; + SodiumGuardedHeapAllocation.Sodium_MProtect_ReadOnly(this.KeyPair.GetPrivateKey()); + Marshal.Copy(this.KeyPair.GetPrivateKey(), key, 0, this.KeyPair.GetPrivateKeyLength()); + SodiumGuardedHeapAllocation.Sodium_MProtect_NoAccess(this.KeyPair.GetPrivateKey()); + return key; + } + + internal async Task Connect() { + await this.WebSocket.ConnectAsync(new Uri("wss://extrachat.annaclemens.io/"), CancellationToken.None); + } + + internal Task AuthenticateAndList() { + return Task.Run(async () => { + if (await this.Authenticate()) { + this._wasConnected = true; + this.Plugin.ChatGui.PrintChat(new XivChatEntry { + Message = "Connected to ExtraChat.", + Type = XivChatType.Notice, + }); + + await this.ListAll(); + } + }); + } + + /// + /// Gets the challenge to put in the user's Lodestone profile. + /// + /// challenge or null if LocalPlayer is null + /// if the server returns an error or unexpected output + internal async Task GetChallenge() { + if (this.Plugin.LocalPlayer is not { } player) { + return null; + } + + this.Status = State.RetrievingChallenge; + var response = await this.QueueMessageAndWait(new RequestKind.Register(new RegisterRequest { + Name = player.Name.TextValue, + World = (ushort) player.HomeWorld.Id, + ChallengeCompleted = false, + })); + + switch (response) { + case ResponseKind.Error { Response.Error: var error }: + this.Status = State.NotAuthenticated; + throw new Exception(error); + case ResponseKind.Register { Response: RegisterResponse.Challenge { Text: var challenge } }: + this.Status = State.WaitingForVerification; + return challenge; + default: + this.Status = State.NotAuthenticated; + throw new Exception("Unexpected response"); + } + } + + internal async Task<(Channel, byte[])> Create(string name) { + var shared = SodiumSecretBoxXChaCha20Poly1305.GenerateKey(); + var nonce = SodiumSecretBoxXChaCha20Poly1305.GenerateNonce(); + var ciphertext = SodiumSecretBoxXChaCha20Poly1305.Create(Encoding.UTF8.GetBytes(name), nonce, shared); + var encryptedName = nonce.Concat(ciphertext); + + var response = await this.QueueMessageAndWait(new RequestKind.Create(new CreateRequest { + Name = encryptedName, + })); + + var channelInfo = response switch { + ResponseKind.Error { Response.Error: var error } => throw new Exception(error), + ResponseKind.Create { Response.Channel: var channel } => (channel, shared), + _ => throw new Exception("invalid response"), + }; + + this.Plugin.ConfigInfo.RegisterChannel(channelInfo.channel, channelInfo.shared); + this.Channels[channelInfo.channel.Id] = channelInfo.channel; + this.ChannelRanks[channelInfo.channel.Id] = Rank.Admin; + this.Plugin.Commands.ReregisterAll(); + this.Plugin.SaveConfig(); + + return channelInfo; + } + + internal async Task Invite(string name, ushort world, Guid channel) { + // Invite requires three steps: + // 1. Get the public key of the invitee + // 2. Encrypt the shared key with the public key + // NOTE: in all cases, the party initiating the key exchange is + // considered the CLIENT + // 3. Send the invite with the encrypted shared key + + // 0. Get the channel shared key + if (!this.Plugin.ConfigInfo.Channels.TryGetValue(channel, out var channelInfo)) { + return null; + } + + // 1. Get the public key of the invitee + var response = await this.QueueMessageAndWait(new RequestKind.PublicKey(new PublicKeyRequest { + Name = name, + World = world, + })); + + var invitee = response switch { + ResponseKind.Error { Response.Error: var error } => throw new Exception(error), + ResponseKind.PublicKey { Response.PublicKey: var respKey } => respKey, + _ => throw new Exception("invalid response"), + }; + + if (invitee == null) { + return null; + } + + // 2. Encrypt the shared key with the public key + var kx = SodiumKeyExchange.CalculateClientSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), invitee); + var encryptedShared = SecretBox.Encrypt(kx.TransferSharedSecret, channelInfo.SharedSecret); + + // 3. Send the invite with the encrypted shared key + response = await this.QueueMessageAndWait(new RequestKind.Invite(new InviteRequest { + Channel = channel, + Name = name, + World = world, + EncryptedSecret = encryptedShared, + })); + + return response switch { + ResponseKind.Error { Response.Error: var error } => throw new Exception(error), + ResponseKind.Invite { Response: var invite } => invite, + _ => throw new Exception("Unexpected response"), + }; + } + + internal async Task InviteToast(string name, ushort world, Guid channel) { + var worldName = WorldUtil.WorldName(world); + var channelName = this.Plugin.ConfigInfo.GetName(channel); + try { + if (await this.Invite(name, world, channel) == null) { + this.Plugin.ShowError($"Could not invite {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\": not logged into ExtraChat"); + } else { + this.Plugin.ShowInfo($"Invited {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\""); + } + } catch (Exception ex) { + this.Plugin.ShowError($"Could not invite {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\": {ex.Message}"); + } + } + + /// + /// Attempts to register the user after the challenge has been completed. + /// + /// authentication key or null if LocalPlayer was null or the challenge failed + /// if the server returns an error or unexpected output + internal async Task Register() { + if (this.Plugin.LocalPlayer is not { } player) { + return null; + } + + this.Status = State.Verifying; + var response = await this.QueueMessageAndWait(new RequestKind.Register(new RegisterRequest { + Name = player.Name.TextValue, + World = (ushort) player.HomeWorld.Id, + ChallengeCompleted = true, + })); + + switch (response) { + case ResponseKind.Error { Response.Error: var error }: + this.Status = State.WaitingForVerification; + throw new Exception(error); + case ResponseKind.Register { Response: RegisterResponse.Failure }: + this.Status = State.WaitingForVerification; + return null; + case ResponseKind.Register { Response: RegisterResponse.Success { Key: var key } }: + this.Status = State.NotAuthenticated; + return key; + default: + throw new Exception("Unexpected response"); + } + } + + internal async Task Authenticate() { + if (this.Plugin.ConfigInfo.Key is not { } key) { + return false; + } + + this.Status = State.Authenticating; + + var response = await this.QueueMessageAndWait(new RequestKind.Authenticate(new AuthenticateRequest { + Key = key, + PublicKey = this.KeyPair.GetPublicKey(), + })); + + var success = response switch { + ResponseKind.Error => false, + ResponseKind.Authenticate { Response.Error: null } => true, + ResponseKind.Authenticate => false, + _ => false, + }; + + this.Status = success ? State.Connected : State.FailedAuthentication; + return success; + } + + internal async Task SendMessage(Guid channel, byte[] message) { + await this.QueueMessage(new RequestKind.Message(new MessageRequest { + Channel = channel, + Message = message, + })); + } + + internal async Task ListAll() { + await this.QueueMessage(new RequestKind.List(new ListRequest.All())); + } + + internal async Task ListMembers(Guid channelId) { + await this.QueueMessage(new RequestKind.List(new ListRequest.Members(channelId))); + } + + internal async Task Join(Guid channelId) { + if (!this.Plugin.ConfigInfo.Channels.TryGetValue(channelId, out var info)) { + return; + } + + var response = await this.QueueMessageAndWait(new RequestKind.Join(new JoinRequest { + Channel = channelId, + })); + + switch (response) { + case ResponseKind.Error { Response.Error: var error }: { + this.Plugin.ShowError($"Failed to join \"{info.Name}\": {error}"); + break; + } + case ResponseKind.Join { Response: var resp }: { + this.Plugin.ShowInfo($"Joined \"{info.Name}\""); + this.InvitedChannels.Remove(channelId); + this.Channels[channelId] = resp.Channel; + this.ChannelRanks[channelId] = Rank.Member; + + this.Plugin.ConfigInfo.AddChannelIndex(resp.Channel.Id); + this.Plugin.ConfigInfo.UpdateChannel(resp.Channel); + + this.Plugin.SaveConfig(); + this.Plugin.Commands.ReregisterAll(); + break; + } + default: { + throw new Exception("Unexpected response"); + } + } + } + + internal async Task Leave(Guid channelId) { + var response = await this.QueueMessageAndWait(new RequestKind.Leave(new LeaveRequest { + Channel = channelId, + })); + + if (response is ResponseKind.Leave { Response: { Error: null, Channel: var id } }) { + this.ActuallyLeave(id); + } + } + + private void ActuallyLeave(Guid id) { + this.Channels.Remove(id); + this.InvitedChannels.Remove(id); + + var idx = this.Plugin.ConfigInfo.ChannelOrder + .Select(entry => (entry.Key, entry.Value)) + .FirstOrDefault(entry => entry.Value == id); + + if (idx != default) { + this.Plugin.ConfigInfo.ChannelOrder.Remove(idx.Key); + this.Plugin.SaveConfig(); + } + } + + internal async Task Kick(Guid id, string name, ushort world) { + var response = await this.QueueMessageAndWait(new RequestKind.Kick(new KickRequest { + Channel = id, + Name = name, + World = world, + })); + + return response switch { + ResponseKind.Error { Response.Error: var error } => error, + _ => null, + }; + } + + internal async Task Promote(Guid id, string name, ushort world, Rank rank) { + var resp = await this.QueueMessageAndWait(new RequestKind.Promote(new PromoteRequest { + Channel = id, + Name = name, + World = world, + Rank = rank, + })); + + return resp switch { + ResponseKind.Error { Response.Error: var error } => error, + _ => null, + }; + } + + internal async Task Disband(Guid id) { + var resp = await this.QueueMessageAndWait(new RequestKind.Disband(new DisbandRequest { + Channel = id, + })); + + return resp switch { + ResponseKind.Error { Response.Error: var error } => error, + _ => null, + }; + } + + internal async Task Update(Guid id, UpdateKind kind) { + var resp = await this.QueueMessageAndWait(new RequestKind.Update(new UpdateRequest { + Channel = id, + Kind = kind, + })); + + return resp switch { + ResponseKind.Error { Response.Error: var error } => error, + ResponseKind.Update => null, + _ => throw new Exception("Unexpected response"), + }; + } + + internal async Task UpdateToast(Guid id, UpdateKind kind) { + if (await this.Update(id, kind) is not { } error) { + return; + } + + var name = this.Plugin.ConfigInfo.GetName(id); + this.Plugin.ShowError($"Could not update \"{name}\": {error}"); + } + + internal async Task RequestSecrets(Guid id) { + await this.QueueMessage(new RequestKind.Secrets(new SecretsRequest { + Channel = id, + })); + } + + private bool _up; + + #pragma warning disable CS4014 + private async Task Loop() { + Start: + this._wasConnected = false; + this._up = false; + this._number = 1; + this.WebSocket.Abort(); + this.Status = State.Disconnected; + + if (!this._active) { + return; + } + + this.ToSend = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter>?)>(); + this._waitersMutex.WaitOne(); + this.Waiters = new Dictionary>(); + this._waitersMutex.ReleaseMutex(); + + // If the websocket is closed, we need to reconnect + this.WebSocket.Dispose(); + this.WebSocket = new ClientWebSocket(); + + this.Status = State.Connecting; + await this.Connect(); + + Task.Run(async () => { + while (this._active && !this._up) { + await this.WebSocket.SendMessage(new RequestContainer { + Number = IsUpPingNumber, + Kind = new RequestKind.Ping(new PingRequest()), + }); + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + if (this._active && this.Plugin.ConfigInfo.Key != null) { + this.AuthenticateAndList(); + } + }); + + if (this.Plugin.ConfigInfo.Key == null) { + this.Status = State.NotAuthenticated; + } + + var websocketMessage = this.WebSocket.ReceiveMessage(); + var toSend = this.ToSend.Reader.ReadAsync().AsTask(); + + while (this._active && this.WebSocket.State == WebSocketState.Open) { + var finished = await Task.WhenAny(websocketMessage, toSend); + + if (finished == websocketMessage) { + var response = await websocketMessage; + websocketMessage = this.WebSocket.ReceiveMessage(); + + switch (response) { + case { Kind: ResponseKind.Ping, Number: IsUpPingNumber } when !this._up: { + this._up = true; + + break; + } + case { Kind: ResponseKind.Message { Response: var resp } }: { + Task.Run(() => this.HandleMessage(resp)); + break; + } + case { Kind: ResponseKind.Invited { Response: var resp } }: { + Task.Run(() => this.HandleInvited(resp)); + break; + } + case { Kind: ResponseKind.List { Response: var resp } }: { + Task.Run(() => this.HandleList(resp)); + break; + } + case { Kind: ResponseKind.MemberChange { Response: var resp } }: { + Task.Run(() => this.HandleMemberChange(resp)); + break; + } + case { Kind: ResponseKind.Disband { Response: var resp }, Number: 0 }: { + // this is a disband notification, not a response to a command + Task.Run(() => this.HandleDisband(resp)); + break; + } + case { Kind: ResponseKind.Updated { Response: var resp }, Number: 0 }: { + Task.Run(() => this.HandleUpdated(resp)); + break; + } + case { Kind: ResponseKind.Secrets { Response: var resp } }: { + Task.Run(() => this.HandleSecrets(resp)); + break; + } + case { Kind: ResponseKind.SendSecrets { Response: var resp }, Number: 0 }: { + Task.Run(async () => await this.HandleSendSecrets(resp)); + break; + } + default: { + this._waitersMutex.WaitOne(); + try { + if (this.Waiters.Remove(response.Number, out var waiter)) { + await waiter.WriteAsync(response.Kind); + } + } finally { + this._waitersMutex.ReleaseMutex(); + } + + break; + } + } + } else if (finished == toSend) { + var (req, update) = await toSend; + toSend = this.ToSend.Reader.ReadAsync().AsTask(); + + await this.WebSocket.SendMessage(req); + if (update != null) { + await update.WriteAsync(this.RegisterWaiter(req.Number)); + } + } + } + + await Task.Delay(TimeSpan.FromSeconds(3)); + goto Start; + // ReSharper disable once FunctionNeverReturns + } + #pragma warning restore CS4014 + + private void HandleSecrets(SecretsResponse resp) { + var kx = SodiumKeyExchange.CalculateClientSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), resp.PublicKey); + var shared = SecretBox.Decrypt(kx.ReadSharedSecret, resp.EncryptedSharedSecret); + + this.Plugin.ConfigInfo.GetOrInsertChannel(resp.Channel).SharedSecret = shared; + this.Plugin.SaveConfig(); + } + + private async Task HandleSendSecrets(SendSecretsResponse resp) { + if (!this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info) || info.SharedSecret.Length == 0) { + await this.QueueMessage(new RequestKind.SendSecrets(new SendSecretsRequest { + RequestId = resp.RequestId, + EncryptedSharedSecret = null, + })); + return; + } + + var kx = SodiumKeyExchange.CalculateServerSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), resp.PublicKey); + var encrypted = SecretBox.Encrypt(kx.TransferSharedSecret, info.SharedSecret); + await this.QueueMessage(new RequestKind.SendSecrets(new SendSecretsRequest { + RequestId = resp.RequestId, + EncryptedSharedSecret = encrypted, + })); + } + + private void HandleUpdated(UpdatedResponse resp) { + switch (resp.Kind) { + case UpdateKind.Name name: { + if (this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info)) { + var newName = Encoding.UTF8.GetString(SecretBox.Decrypt(info.SharedSecret, name.NewName)); + info.Name = newName; + this.Plugin.SaveConfig(); + } + + break; + } + default: { + PluginLog.LogWarning($"Unhandled update kind: {resp.Kind}"); + break; + } + } + } + + private void HandleMemberChange(MemberChangeResponse resp) { + if (!this.TryGetChannel(resp.Channel, out var channel)) { + return; + } + + var channelName = this.Plugin.ConfigInfo.GetName(resp.Channel); + + var self = this.Plugin.LocalPlayer; + var isSelf = self?.Name.TextValue == resp.Name && self.HomeWorld.Id == resp.World; + + switch (resp.Kind) { + case MemberChangeKind.Invite: { + channel.Members.Add(new Member { + Name = resp.Name, + World = resp.World, + Rank = Rank.Invited, + Online = true, + }); + + break; + } + case MemberChangeKind.InviteCancel: { + channel.Members.RemoveAll( + member => member.Name == resp.Name + && member.World == resp.World + && member.Rank == Rank.Invited + ); + + if (isSelf) { + this.ChannelRanks.Remove(resp.Channel); + this.InvitedChannels.Remove(resp.Channel); + } + + break; + } + case MemberChangeKind.InviteDecline: { + channel.Members.RemoveAll( + member => member.Name == resp.Name + && member.World == resp.World + && member.Rank == Rank.Invited + ); + + if (isSelf) { + this.ChannelRanks.Remove(resp.Channel); + this.InvitedChannels.Remove(resp.Channel); + } + + break; + } + case MemberChangeKind.Join: { + var member = channel.Members.FirstOrDefault(member => member.Name == resp.Name && member.World == resp.World); + if (member != null) { + member.Rank = Rank.Member; + } else { + channel.Members.Add(new Member { + Name = resp.Name, + World = resp.World, + Rank = Rank.Member, + }); + } + + if (isSelf) { + this.ChannelRanks[resp.Channel] = Rank.Member; + this.Plugin.ShowInfo($"You have joined \"{channelName}\""); + } else { + var worldName = WorldUtil.WorldName(resp.World); + this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has joined \"{channelName}\""); + } + + break; + } + case MemberChangeKind.Kick: { + channel.Members.RemoveAll(member => member.Name == resp.Name && member.World == resp.World); + + if (isSelf) { + this.ChannelRanks.Remove(resp.Channel); + this.Plugin.ConfigInfo.RemoveChannelIndex(resp.Channel); + this.Plugin.SaveConfig(); + + this.Plugin.ShowInfo($"You have been kicked from \"{channelName}\""); + } else { + var worldName = WorldUtil.WorldName(resp.World); + this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has been kicked from \"{channelName}\""); + } + + break; + } + case MemberChangeKind.Leave: { + channel.Members.RemoveAll(member => member.Name == resp.Name && member.World == resp.World); + + if (isSelf) { + this.ChannelRanks.Remove(resp.Channel); + this.Plugin.ShowInfo($"You have left \"{channelName}\""); + } else { + var worldName = WorldUtil.WorldName(resp.World); + this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has left \"{channelName}\""); + } + + break; + } + case MemberChangeKind.Promote promote: { + bool wasPromotion; + var member = channel.Members.FirstOrDefault(member => member.Name == resp.Name && member.World == resp.World); + if (member != null) { + wasPromotion = promote.Rank >= member.Rank; + member.Rank = promote.Rank; + } else { + wasPromotion = true; + channel.Members.Add(new Member { + Name = resp.Name, + World = resp.World, + Rank = promote.Rank, + }); + } + + var verb = wasPromotion ? "promoted" : "demoted"; + + if (isSelf) { + this.ChannelRanks[resp.Channel] = promote.Rank; + this.Plugin.ShowInfo($"You have been {verb} to {promote.Rank} in \"{channelName}\""); + } else { + var worldName = WorldUtil.WorldName(resp.World); + this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has been {verb} to {promote.Rank} in \"{channelName}\""); + } + + break; + } + default: { + throw new ArgumentOutOfRangeException(); + } + } + } + + private void HandleDisband(DisbandResponse resp) { + if (this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info)) { + this.Plugin.ShowInfo($"\"{info.Name}\" has been disbanded."); + } + + this.ActuallyLeave(resp.Channel); + } + + private void HandleList(ListResponse resp) { + var self = this.Plugin.LocalPlayer; + + switch (resp) { + case ListResponse.All all: { + this.Channels.Clear(); + this.InvitedChannels.Clear(); + + foreach (var channel in all.AllChannels) { + this.Channels[channel.Id] = channel; + + var member = channel.Members + .FirstOrDefault(member => member.Name == self?.Name.TextValue + && member.World == self.HomeWorld.Id); + this.ChannelRanks.Remove(channel.Id); + if (member != null) { + this.ChannelRanks[channel.Id] = member.Rank; + } + + this.Plugin.ConfigInfo.UpdateChannel(channel); + } + + foreach (var channel in all.AllInvites) { + this.InvitedChannels[channel.Id] = channel; + this.ChannelRanks[channel.Id] = Rank.Invited; + + this.Plugin.ConfigInfo.UpdateChannel(channel); + this.Plugin.SaveConfig(); + } + + this.Plugin.SaveConfig(); + break; + } + case ListResponse.Channels channels: { + foreach (var channel in channels.SimpleChannels) { + this.Channels[channel.Id] = new Channel { + Id = channel.Id, + Name = channel.Name, + Members = new List(), + }; + + this.ChannelRanks[channel.Id] = channel.Rank; + this.Plugin.ConfigInfo.UpdateChannel(channel); + } + + this.Plugin.SaveConfig(); + break; + } + case ListResponse.Invites invites: { + foreach (var channel in invites.AllInvites) { + this.InvitedChannels[channel.Id] = new Channel { + Id = channel.Id, + Name = channel.Name, + Members = new List(), + }; + + this.ChannelRanks[channel.Id] = channel.Rank; + this.Plugin.ConfigInfo.UpdateChannel(channel); + } + + this.Plugin.SaveConfig(); + break; + } + case ListResponse.Members members: { + if (!this.Channels.TryGetValue(members.ChannelId, out var channel)) { + break; + } + + channel.Members = members.AllMembers.ToList(); + + var member = channel.Members + .FirstOrDefault(member => member.Name == self?.Name.TextValue + && member.World == self.HomeWorld.Id); + this.ChannelRanks.Remove(channel.Id); + if (member != null) { + this.ChannelRanks[channel.Id] = member.Rank; + } + + break; + } + } + + this.Plugin.Commands.ReregisterAll(); + } + + private void HandleMessage(MessageResponse resp) { + var config = this.Plugin.ConfigInfo; + + if (!config.Channels.TryGetValue(resp.Channel, out var info)) { + return; + } + + var message = SeString.Parse(SecretBox.Decrypt(info.SharedSecret, resp.Message)); + + var output = new SeStringBuilder(); + + var colour = config.GetUiColour(resp.Channel); + output.AddUiForeground(colour); + + var marker = config.GetMarker(resp.Channel) ?? "ECLS?"; + + var isSelf = resp.Sender == this.Plugin.LocalPlayer?.Name.TextValue && resp.World == this.Plugin.LocalPlayer?.HomeWorld.Id; + + output.AddText($"[{marker}]<"); + if (isSelf) { + output.AddText(resp.Sender); + } else { + output.Add(new PlayerPayload(resp.Sender, resp.World)); + } + + if (!isSelf && resp.World != this.Plugin.LocalPlayer?.CurrentWorld.Id) { + output.AddIcon(BitmapFontIcon.CrossWorld); + var world = this.Plugin.DataManager.GetExcelSheet()?.GetRow(resp.World)?.Name.ToDalamudString(); + if (world != null) { + foreach (var payload in world.Payloads) { + output.Add(payload); + } + } else { + output.AddText($"[Unknown {resp.World}]"); + } + } + + output.AddText("> "); + + foreach (var payload in message.Payloads) { + output.Add(payload); + } + + output.AddUiForegroundOff(); + + if (!this.Plugin.ConfigInfo.ChannelChannels.TryGetValue(resp.Channel, out var outputChannel)) { + outputChannel = XivChatType.Debug; + } + + this.Plugin.ChatGui.PrintChat(new XivChatEntry { + Message = output.Build(), + Name = isSelf + ? resp.Sender + : new SeString(new PlayerPayload(resp.Sender, resp.World)), + Type = outputChannel, + }); + } + + private void HandleInvited(InvitedResponse info) { + // 1. Decrypt the shared key + // 2. Decrypt the channel name + + var inviter = info.PublicKey; + var kx = SodiumKeyExchange.CalculateServerSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), inviter); + var shared = SecretBox.Decrypt(kx.ReadSharedSecret, info.EncryptedSecret); + var name = Encoding.UTF8.GetString(SecretBox.Decrypt(shared, info.Channel.Name)); + + this.Plugin.ConfigInfo.Channels[info.Channel.Id] = new ChannelInfo { + Name = name, + SharedSecret = shared, + }; + this.InvitedChannels[info.Channel.Id] = info.Channel; + this.ChannelRanks[info.Channel.Id] = Rank.Invited; + this.Plugin.SaveConfig(); + + this.Plugin.ShowInfo($"Invited to join \"{name}\" by {info.Name}{PluginUi.CrossWorld}{WorldUtil.WorldName(info.World)}"); + } +} diff --git a/client/ExtraChat/Commands.cs b/client/ExtraChat/Commands.cs new file mode 100644 index 0000000..93a6aa8 --- /dev/null +++ b/client/ExtraChat/Commands.cs @@ -0,0 +1,100 @@ +using Dalamud.Game.Command; +using Dalamud.Logging; +using ExtraChat.Util; + +namespace ExtraChat; + +internal class Commands : IDisposable { + private static readonly string[] MainCommands = { + "/extrachat", + "/ec", + "/eclcmd", + }; + + private Plugin Plugin { get; } + private Dictionary RegisteredInternal { get; } = new(); + internal IReadOnlyDictionary Registered => this.RegisteredInternal; + + internal Commands(Plugin plugin) { + this.Plugin = plugin; + this.Plugin.ClientState.Logout += this.OnLogout; + + this.RegisterMain(); + this.RegisterAll(); + } + + private void OnLogout(object? sender, EventArgs e) { + this.UnregisterAll(); + } + + private void RegisterMain() { + foreach (var command in MainCommands) { + this.Plugin.CommandManager.AddHandler(command, new CommandInfo(this.MainCommand)); + } + } + + private void UnregisterMain() { + foreach (var command in MainCommands) { + this.Plugin.CommandManager.RemoveHandler(command); + } + } + + private void MainCommand(string command, string arguments) { + this.Plugin.PluginUi.Visible ^= true; + } + + internal void ReregisterAll() { + this.UnregisterAll(); + this.RegisterAll(); + this.Plugin.Ipc.BroadcastChannelCommandColours(); + } + + internal void RegisterAll() { + var info = this.Plugin.ConfigInfo; + foreach (var (idx, id) in info.ChannelOrder) { + this.RegisterOne($"/ecl{idx + 1}", id); + } + + foreach (var (alias, id) in info.Aliases) { + this.RegisterOne(alias, id); + } + } + + internal void UnregisterAll() { + foreach (var command in this.Registered.Keys) { + this.Plugin.CommandManager.RemoveHandler(command); + } + + this.RegisteredInternal.Clear(); + } + + private void RegisterOne(string command, Guid id) { + this.RegisteredInternal[command] = id; + + void Handler(string _, string arguments) { + PluginLog.LogWarning("Command handler actually invoked"); + } + + this.Plugin.CommandManager.AddHandler(command, new CommandInfo(Handler) { + ShowInHelp = false, + }); + } + + internal void SendMessage(Guid id, byte[] bytes) { + if (!this.Plugin.ConfigInfo.Channels.TryGetValue(id, out var info)) { + this.Plugin.ChatGui.PrintError("ExtraChat Linkshell information could not be loaded."); + return; + } + + var message = this.Plugin.GameFunctions.ResolvePayloads(bytes); + var ciphertext = SecretBox.Encrypt(info.SharedSecret, message); + Task.Run(async () => await this.Plugin.Client.SendMessage(id, ciphertext)); + } + + public void Dispose() { + this.UnregisterAll(); + this.UnregisterMain(); + + this.Plugin.ClientState.Logout -= this.OnLogout; + } +} diff --git a/client/ExtraChat/Configuration.cs b/client/ExtraChat/Configuration.cs new file mode 100644 index 0000000..49f61fd --- /dev/null +++ b/client/ExtraChat/Configuration.cs @@ -0,0 +1,151 @@ +using System.Text; +using Dalamud.Configuration; +using Dalamud.Game.Text; +using ExtraChat.Protocol.Channels; +using ExtraChat.Util; + +namespace ExtraChat; + +[Serializable] +internal class Configuration : IPluginConfiguration { + public int Version { get; set; } = 1; + + public bool UseNativeToasts = true; + public XivChatType DefaultChannel = XivChatType.Debug; + public Dictionary Configs { get; } = new(); + + internal ConfigInfo GetConfig(ulong id) { + if (id == 0) { + // just pretend + return new ConfigInfo(); + } + + if (this.Configs.TryGetValue(id, out var config)) { + return config; + } + + var newConfig = new ConfigInfo(); + this.Configs[id] = newConfig; + + return newConfig; + } +} + +[Serializable] +internal class ConfigInfo { + public string? Key; + public Dictionary Channels = new(); + public Dictionary ChannelOrder = new(); + public Dictionary Aliases = new(); + public Dictionary ChannelColors = new(); + public Dictionary ChannelMarkers = new(); + public Dictionary ChannelChannels = new(); + + internal string GetName(Guid id) => this.Channels.TryGetValue(id, out var channel) + ? channel.Name + : "???"; + + internal ushort GetUiColour(Guid id) => this.ChannelColors.TryGetValue(id, out var colour) + ? colour + : Plugin.DefaultColour; + + internal string? GetMarker(Guid id) { + var order = this.GetOrder(id); + if (order == null) { + return null; + } + + return this.ChannelMarkers.TryGetValue(id, out var custom) + ? custom + : $"ECLS{order.Value + 1}"; + } + + internal int? GetOrder(Guid id) { + var pair = this.ChannelOrder + .Select(entry => (entry.Key, entry.Value)) + .FirstOrDefault(entry => entry.Value == id); + if (pair == default) { + return null; + } + + return pair.Key; + } + + internal string GetFullName(Guid id) { + var name = this.GetName(id); + + var order = "?"; + var orderEntry = this.ChannelOrder + .Select(entry => (entry.Key, entry.Value)) + .FirstOrDefault(entry => entry.Value == id); + if (orderEntry != default) { + order = (orderEntry.Key + 1).ToString(); + } + + return $"ECLS [{order}]: {name}"; + } + + internal ChannelInfo GetOrInsertChannel(Guid id) { + if (this.Channels.TryGetValue(id, out var channel)) { + return channel; + } + + var newChannel = new ChannelInfo(); + this.Channels[id] = newChannel; + return newChannel; + } + + internal int AddChannelIndex(Guid channelId) { + var existing = this.ChannelOrder + .Select(entry => (entry.Key, entry.Value)) + .FirstOrDefault(entry => entry.Value == channelId); + if (existing != default) { + return existing.Key; + } + + var indices = this.ChannelOrder.Keys; + var idx = indices.Count == 0 ? 0 : indices.Max() + 1; + this.ChannelOrder[idx] = channelId; + return idx; + } + + internal void RemoveChannelIndex(Guid channelId) { + var idx = this.ChannelOrder + .Select(entry => (entry.Key, entry.Value)) + .FirstOrDefault(entry => entry.Value == channelId); + + if (idx != default) { + this.ChannelOrder.Remove(idx.Key); + } + } + + internal void RegisterChannel(Channel channel, byte[] key) { + var name = channel.DecryptName(key); + this.Channels[channel.Id] = new ChannelInfo { + Name = name, + SharedSecret = key, + }; + + this.AddChannelIndex(channel.Id); + } + + internal void UpdateChannel(Guid id, byte[] name) { + if (this.Channels.TryGetValue(id, out var info)) { + info.Name = Encoding.UTF8.GetString(SecretBox.Decrypt(info.SharedSecret, name)); + } + } + + internal void UpdateChannel(Channel channel) { + this.UpdateChannel(channel.Id, channel.Name); + } + + internal void UpdateChannel(SimpleChannel channel) { + this.UpdateChannel(channel.Id, channel.Name); + } +} + +[Serializable] +internal class ChannelInfo { + public byte[] SharedSecret = Array.Empty(); + public string Name = "???"; +} diff --git a/client/ExtraChat/Ext.cs b/client/ExtraChat/Ext.cs new file mode 100644 index 0000000..983c6f3 --- /dev/null +++ b/client/ExtraChat/Ext.cs @@ -0,0 +1,34 @@ +using System.Net.WebSockets; +using ExtraChat.Protocol; +using MessagePack; + +namespace ExtraChat; + +public static class Ext { + public static string ToHexString(this IEnumerable bytes) { + return string.Join("", bytes.Select(b => b.ToString("x2"))); + } + + public static async Task SendMessage(this ClientWebSocket client, RequestContainer request) { + var bytes = MessagePackSerializer.Serialize(request); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await client.SendAsync(bytes, WebSocketMessageType.Binary, true, cts.Token); + } + + public static async Task ReceiveMessage(this ClientWebSocket client) { + var bytes = new ArraySegment(new byte[2048]); + + WebSocketReceiveResult result; + var i = 0; + do { + result = await client.ReceiveAsync(bytes[i..], CancellationToken.None); + i += result.Count; + + if (i >= bytes.Count) { + throw new Exception(); + } + } while (!result.EndOfMessage); + + return MessagePackSerializer.Deserialize(bytes[..i]); + } +} diff --git a/client/ExtraChat/ExtraChat.csproj b/client/ExtraChat/ExtraChat.csproj new file mode 100644 index 0000000..d2d8060 --- /dev/null +++ b/client/ExtraChat/ExtraChat.csproj @@ -0,0 +1,64 @@ + + + + 1.0.3 + net5.0-windows + preview + enable + enable + true + true + false + full + true + + + + $(AppData)\XIVLauncher\addon\Hooks\dev + + + + $(DALAMUD_HOME) + + + + $(HOME)/dalamud + + + + + $(Dalamud)\Dalamud.dll + false + + + $(Dalamud)\FFXIVClientStructs.dll + false + + + $(Dalamud)\ImGui.NET.dll + false + + + $(Dalamud)\ImGuiScene.dll + false + + + $(Dalamud)\Lumina.dll + false + + + $(Dalamud)\Lumina.Excel.dll + false + + + + + + + + + + + + + diff --git a/client/ExtraChat/ExtraChat.yaml b/client/ExtraChat/ExtraChat.yaml new file mode 100644 index 0000000..7672f96 --- /dev/null +++ b/client/ExtraChat/ExtraChat.yaml @@ -0,0 +1,5 @@ +name: ExtraChat +author: ascclemens +description: |- + It's more chat. Very alpha. /ecl# is the LS command. +punchline: '[ALPHA] Cross-data-centre linkshells with unlimited members.' diff --git a/client/ExtraChat/Formatters/BinaryUuidFormatter.cs b/client/ExtraChat/Formatters/BinaryUuidFormatter.cs new file mode 100644 index 0000000..e519f6e --- /dev/null +++ b/client/ExtraChat/Formatters/BinaryUuidFormatter.cs @@ -0,0 +1,26 @@ +using System.Buffers; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class BinaryUuidFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, Guid value, MessagePackSerializerOptions options) { + var bytes = value.ToByteArray(); + FlipBytes(bytes); + writer.Write(bytes); + } + + public Guid Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + var bytes = reader.ReadBytes()!.Value.ToArray(); + FlipBytes(bytes); + return new Guid(bytes); + } + + internal static void FlipBytes(byte[] bytes) { + // microsoft is stupid for no reason + Array.Reverse(bytes,0,4); + Array.Reverse(bytes,4,2); + Array.Reverse(bytes,6,2); + } +} diff --git a/client/ExtraChat/Formatters/BinaryUuidNullableFormatter.cs b/client/ExtraChat/Formatters/BinaryUuidNullableFormatter.cs new file mode 100644 index 0000000..6f27481 --- /dev/null +++ b/client/ExtraChat/Formatters/BinaryUuidNullableFormatter.cs @@ -0,0 +1,28 @@ +using System.Buffers; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class BinaryUuidNullableFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, Guid? value, MessagePackSerializerOptions options) { + if (!value.HasValue) { + writer.WriteNil(); + return; + } + + var bytes = value.Value.ToByteArray(); + BinaryUuidFormatter.FlipBytes(bytes); + writer.Write(bytes); + } + + public Guid? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + var bytes = reader.ReadBytes()?.ToArray(); + if (bytes == null) { + return null; + } + + BinaryUuidFormatter.FlipBytes(bytes); + return new Guid(bytes); + } +} diff --git a/client/ExtraChat/Formatters/ListRequestFormatter.cs b/client/ExtraChat/Formatters/ListRequestFormatter.cs new file mode 100644 index 0000000..484be18 --- /dev/null +++ b/client/ExtraChat/Formatters/ListRequestFormatter.cs @@ -0,0 +1,41 @@ +using System.Text; +using ExtraChat.Protocol; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class ListRequestFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, ListRequest value, MessagePackSerializerOptions options) { + var plain = value switch { + ListRequest.All => "all", + ListRequest.Channels => "channels", + ListRequest.Invites => "invites", + _ => null, + }; + + if (plain != null) { + writer.WriteString(Encoding.UTF8.GetBytes(plain)); + return; + } + + writer.WriteMapHeader(1); + + switch (value) { + case ListRequest.Members members: { + writer.WriteString(Encoding.UTF8.GetBytes("members")); + new BinaryUuidFormatter().Serialize(ref writer, members.ChannelId, options); + + break; + } + default: { + throw new MessagePackSerializationException("Invalid ListRequest value"); + } + } + } + + public ListRequest Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + // TODO + throw new NotImplementedException(); + } +} diff --git a/client/ExtraChat/Formatters/ListResponseFormatter.cs b/client/ExtraChat/Formatters/ListResponseFormatter.cs new file mode 100644 index 0000000..fd1a453 --- /dev/null +++ b/client/ExtraChat/Formatters/ListResponseFormatter.cs @@ -0,0 +1,56 @@ +using ExtraChat.Protocol; +using ExtraChat.Protocol.Channels; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class ListResponseFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, ListResponse value, MessagePackSerializerOptions options) { + // TODO + throw new NotImplementedException(); + } + + public ListResponse Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + if (reader.ReadMapHeader() != 1) { + throw new MessagePackSerializationException("Invalid map length"); + } + + var key = reader.ReadString(); + switch (key) { + case "all": { + if (reader.ReadArrayHeader() != 2) { + throw new MessagePackSerializationException("Invalid map length"); + } + + var channels = options.Resolver.GetFormatter().Deserialize(ref reader, options); + var invites = options.Resolver.GetFormatter().Deserialize(ref reader, options); + + return new ListResponse.All(channels, invites); + } + case "channels": { + var channels = options.Resolver.GetFormatter().Deserialize(ref reader, options); + + return new ListResponse.Channels(channels); + } + case "members": { + if (reader.ReadArrayHeader() != 2) { + throw new MessagePackSerializationException("Invalid map length"); + } + + var id = new BinaryUuidFormatter().Deserialize(ref reader, options); + var members = options.Resolver.GetFormatter().Deserialize(ref reader, options); + + return new ListResponse.Members(id, members); + } + case "invites": { + var channels = options.Resolver.GetFormatter().Deserialize(ref reader, options); + + return new ListResponse.Invites(channels); + } + default: { + throw new MessagePackSerializationException("Invalid list response type"); + } + } + } +} diff --git a/client/ExtraChat/Formatters/MemberChangeKindFormatter.cs b/client/ExtraChat/Formatters/MemberChangeKindFormatter.cs new file mode 100644 index 0000000..59e0bb9 --- /dev/null +++ b/client/ExtraChat/Formatters/MemberChangeKindFormatter.cs @@ -0,0 +1,69 @@ +using ExtraChat.Protocol; +using ExtraChat.Protocol.Channels; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class MemberChangeKindFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, MemberChangeKind value, MessagePackSerializerOptions options) { + throw new NotImplementedException(); + } + + public MemberChangeKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + if (reader.NextMessagePackType == MessagePackType.String) { + return reader.ReadString() switch { + "join" => new MemberChangeKind.Join(), + "leave" => new MemberChangeKind.Leave(), + "invite_decline" => new MemberChangeKind.InviteDecline(), + _ => throw new MessagePackSerializationException("invalid MemberChangeKind key"), + }; + } + + if (reader.ReadMapHeader() != 1) { + throw new MessagePackSerializationException("Invalid map length"); + } + + var key = reader.ReadString(); + switch (key) { + case "invite": { + if (reader.ReadArrayHeader() != 2) { + throw new MessagePackSerializationException("Invalid array length"); + } + + var inviter = reader.ReadString(); + var world = reader.ReadUInt16(); + return new MemberChangeKind.Invite(inviter, world); + } + case "invite_cancel": { + if (reader.ReadArrayHeader() != 2) { + throw new MessagePackSerializationException("Invalid array length"); + } + + var canceler = reader.ReadString(); + var world = reader.ReadUInt16(); + return new MemberChangeKind.InviteCancel(canceler, world); + } + case "promote": { + if (reader.ReadArrayHeader() != 1) { + throw new MessagePackSerializationException("Invalid array length"); + } + + var rank = options.Resolver.GetFormatter().Deserialize(ref reader, options); + return new MemberChangeKind.Promote(rank); + } + case "kick": { + if (reader.ReadArrayHeader() != 2) { + throw new MessagePackSerializationException("Invalid array length"); + } + + var kicker = reader.ReadString(); + var world = reader.ReadUInt16(); + return new MemberChangeKind.Kick(kicker, world); + } + default: { + throw new MessagePackSerializationException("invalid MemberChangeKind key"); + } + } + } +} diff --git a/client/ExtraChat/Formatters/RegisterResponseFormatter.cs b/client/ExtraChat/Formatters/RegisterResponseFormatter.cs new file mode 100644 index 0000000..8e3dd9e --- /dev/null +++ b/client/ExtraChat/Formatters/RegisterResponseFormatter.cs @@ -0,0 +1,81 @@ +using System.Text; +using ExtraChat.Protocol; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class RegisterResponseFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, RegisterResponse value, MessagePackSerializerOptions options) { + if (value is RegisterResponse.Failure) { + writer.WriteString(Encoding.UTF8.GetBytes("failure")); + return; + } + + writer.WriteMapHeader(1); + + var key = value switch { + RegisterResponse.Challenge => "challenge", + RegisterResponse.Failure => "failure", + RegisterResponse.Success => "success", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + writer.WriteString(Encoding.UTF8.GetBytes(key)); + + switch (value) { + case RegisterResponse.Challenge challenge: { + writer.WriteArrayHeader(1); + writer.WriteString(Encoding.UTF8.GetBytes(challenge.Text)); + break; + } + case RegisterResponse.Failure: + break; + case RegisterResponse.Success success: { + writer.WriteArrayHeader(1); + writer.WriteString(Encoding.UTF8.GetBytes(success.Key)); + break; + } + } + } + + public RegisterResponse Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + if (reader.NextMessagePackType == MessagePackType.Map) { + if (reader.ReadMapHeader() != 1) { + throw new MessagePackSerializationException("Invalid map length"); + } + } else if (reader.NextMessagePackType == MessagePackType.String) { + if (reader.ReadString() != "failure") { + throw new MessagePackSerializationException("Invalid RegisterResponse"); + } + + return new RegisterResponse.Failure(); + } else { + throw new MessagePackSerializationException("Invalid RegisterResponse"); + } + + var key = reader.ReadString(); + switch (key) { + case "challenge": { + if (reader.ReadArrayHeader() != 1) { + throw new MessagePackSerializationException("Invalid RegisterResponse"); + } + + var text = reader.ReadString(); + return new RegisterResponse.Challenge(text); + } + case "failure": + throw new MessagePackSerializationException("Invalid RegisterResponse"); + case "success": { + if (reader.ReadArrayHeader() != 1) { + throw new MessagePackSerializationException("Invalid RegisterResponse"); + } + + var text = reader.ReadString(); + return new RegisterResponse.Success(text); + } + default: + throw new MessagePackSerializationException("Invalid RegisterResponse type"); + } + } +} diff --git a/client/ExtraChat/Formatters/RequestKindFormatter.cs b/client/ExtraChat/Formatters/RequestKindFormatter.cs new file mode 100644 index 0000000..b342593 --- /dev/null +++ b/client/ExtraChat/Formatters/RequestKindFormatter.cs @@ -0,0 +1,160 @@ +using System.Text; +using ExtraChat.Protocol; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class RequestKindFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, RequestKind value, MessagePackSerializerOptions options) { + writer.WriteMapHeader(1); + + var key = value switch { + RequestKind.Ping => "ping", + RequestKind.Authenticate => "authenticate", + RequestKind.Create => "create", + RequestKind.Invite => "invite", + RequestKind.Join => "join", + RequestKind.Message => "message", + RequestKind.PublicKey => "public_key", + RequestKind.Register => "register", + RequestKind.List => "list", + RequestKind.Leave => "leave", + RequestKind.Kick => "kick", + RequestKind.Disband => "disband", + RequestKind.Promote => "promote", + RequestKind.Update => "update", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + writer.WriteString(Encoding.UTF8.GetBytes(key)); + + switch (value) { + case RequestKind.Ping ping: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, ping.Request, options); + break; + case RequestKind.Authenticate authenticate: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, authenticate.Request, options); + break; + case RequestKind.Create create: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, create.Request, options); + break; + case RequestKind.Invite invite: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, invite.Request, options); + break; + case RequestKind.Join join: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, join.Request, options); + break; + case RequestKind.Message message: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, message.Request, options); + break; + case RequestKind.PublicKey publicKey: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, publicKey.Request, options); + break; + case RequestKind.Register register: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, register.Request, options); + break; + case RequestKind.List list: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, list.Request, options); + break; + case RequestKind.Leave leave: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, leave.Request, options); + break; + case RequestKind.Kick kick: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, kick.Request, options); + break; + case RequestKind.Disband disband: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, disband.Request, options); + break; + case RequestKind.Promote promote: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, promote.Request, options); + break; + case RequestKind.Update update: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, update.Request, options); + break; + case RequestKind.Secrets secrets: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, secrets.Request, options); + break; + case RequestKind.SendSecrets sendSecrets: + options.Resolver.GetFormatterWithVerify().Serialize(ref writer, sendSecrets.Request, options); + break; + } + } + + public RequestKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + if (reader.ReadMapHeader() != 1) { + throw new MessagePackSerializationException("Invalid RequestKind"); + } + + var key = reader.ReadString(); + + switch (key) { + case "ping": { + var request = MessagePackSerializer.Deserialize(ref reader, options); + return new RequestKind.Ping(request); + } + case "authenticate": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Authenticate(request); + } + case "create": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Create(request); + } + case "invite": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Invite(request); + } + case "join": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Join(request); + } + case "message": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Message(request); + } + case "public_key": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.PublicKey(request); + } + case "register": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Register(request); + } + case "list": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.List(request); + } + case "leave": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Leave(request); + } + case "kick": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Kick(request); + } + case "disband": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Disband(request); + } + case "promote": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Promote(request); + } + case "update": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Update(request); + } + case "secrets": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.Secrets(request); + } + case "send_secrets": { + var request = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new RequestKind.SendSecrets(request); + } + default: + throw new MessagePackSerializationException("Invalid RequestKind"); + } + } +} diff --git a/client/ExtraChat/Formatters/ResponseKindFormatter.cs b/client/ExtraChat/Formatters/ResponseKindFormatter.cs new file mode 100644 index 0000000..9a8189a --- /dev/null +++ b/client/ExtraChat/Formatters/ResponseKindFormatter.cs @@ -0,0 +1,105 @@ +using ExtraChat.Protocol; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class ResponseKindFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, ResponseKind value, MessagePackSerializerOptions options) { + // TODO + throw new NotImplementedException(); + } + + public ResponseKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + if (reader.ReadMapHeader() != 1) { + throw new MessagePackSerializationException("Invalid ResponseKind"); + } + + var key = reader.ReadString(); + + switch (key) { + case "ping": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Ping(response); + } + case "error": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Error(response); + } + case "authenticate": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Authenticate(response); + } + case "create": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Create(response); + } + case "invite": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Invite(response); + } + case "invited": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Invited(response); + } + case "join": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Join(response); + } + case "message": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Message(response); + } + case "public_key": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.PublicKey(response); + } + case "register": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Register(response); + } + case "list": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.List(response); + } + case "leave": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Leave(response); + } + case "kick": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Kick(response); + } + case "disband": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Disband(response); + } + case "promote": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Promote(response); + } + case "member_change": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.MemberChange(response); + } + case "update": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Update(response); + } + case "updated": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Updated(response); + } + case "secrets": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.Secrets(response); + } + case "send_secrets": { + var response = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options); + return new ResponseKind.SendSecrets(response); + } + default: + throw new MessagePackSerializationException("Invalid ResponseKind"); + } + } +} diff --git a/client/ExtraChat/Formatters/UpdateKindFormatter.cs b/client/ExtraChat/Formatters/UpdateKindFormatter.cs new file mode 100644 index 0000000..05cbfbd --- /dev/null +++ b/client/ExtraChat/Formatters/UpdateKindFormatter.cs @@ -0,0 +1,47 @@ +using System.Buffers; +using System.Text; +using ExtraChat.Protocol; +using MessagePack; +using MessagePack.Formatters; + +namespace ExtraChat.Formatters; + +public class UpdateKindFormatter : IMessagePackFormatter { + public void Serialize(ref MessagePackWriter writer, UpdateKind value, MessagePackSerializerOptions options) { + writer.WriteMapHeader(1); + + var key = value switch { + UpdateKind.Name => "name", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + writer.WriteString(Encoding.UTF8.GetBytes(key)); + + switch (value) { + case UpdateKind.Name name: { + writer.Write(name.NewName); + break; + } + default: { + throw new MessagePackSerializationException("Unknown UpdateKind"); + } + } + } + + public UpdateKind Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + if (reader.ReadMapHeader() != 1) { + throw new MessagePackSerializationException("UpdateKindFormatter: Invalid map length"); + } + + var key = reader.ReadString(); + switch (key) { + case "name": { + var name = reader.ReadBytes()!.Value.ToArray(); + return new UpdateKind.Name(name); + } + default: { + throw new MessagePackSerializationException("UpdateKindFormatter: Invalid key"); + } + } + } +} diff --git a/client/ExtraChat/GameFunctions.cs b/client/ExtraChat/GameFunctions.cs new file mode 100644 index 0000000..278f430 --- /dev/null +++ b/client/ExtraChat/GameFunctions.cs @@ -0,0 +1,268 @@ +using System.Text; +using Dalamud.Hooking; +using Dalamud.Logging; +using Dalamud.Memory; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Client.UI.Shell; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Lumina.Excel.GeneratedSheets; + +namespace ExtraChat; + +internal unsafe class GameFunctions : IDisposable { + private Plugin Plugin { get; } + + // all this comes from 6.15: 751AF0 + + [Signature("4D 85 C0 74 08 45 8B C1")] + private readonly delegate* unmanaged _resolvePayloads; + + // [Signature("E8 ?? ?? ?? ?? 48 8B D0 48 8D 4D E0 E8 ?? ?? ?? ?? 41 B0 01")] + // private readonly delegate* unmanaged _step1; + + [Signature("E8 ?? ?? ?? ?? 0F B7 7F 08")] + private readonly delegate* unmanaged _step2; + + [Signature("E8 ?? ?? ?? ?? 49 8B 45 00 49 8B CD FF 50 68")] + private readonly delegate* unmanaged _setChatChannel; + + private delegate void SendMessageDelegate(IntPtr a1, Utf8String* message, IntPtr a3); + + private delegate void SetChatChannelDelegate(RaptureShellModule* module, uint channel); + + [Signature( + "E8 ?? ?? ?? ?? FE 86 ?? ?? ?? ?? C7 86", + DetourName = nameof(SendMessageDetour) + )] + private Hook SendMessageHook { get; init; } + + [Signature( + "E8 ?? ?? ?? ?? 49 8B 45 00 49 8B CD FF 50 68", + DetourName = nameof(SetChatChannelDetour) + )] + private Hook SetChatChannelHook { get; init; } + + private delegate IntPtr ChangeChannelNameDelegate(IntPtr agent); + + [Signature( + "E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6", + DetourName = nameof(ChangeChannelNameDetour) + )] + private Hook ChangeChannelNameHook { get; init; } + + private delegate byte ShouldDoNameLookupDelegate(IntPtr agent); + + [Signature( + "E8 ?? ?? ?? ?? 84 C0 75 1A 8B 93", + DetourName = nameof(ShouldDoNameLookupDetour) + )] + private Hook ShouldDoNameLookupHook { get; init; } + + private delegate ulong GetChatColourDelegate(IntPtr a1, int a2); + + [Signature( + "E8 ?? ?? ?? ?? 48 8B 4B 10 B2 01 89 83", + DetourName = nameof(GetChatColourDetour) + )] + private Hook GetChatColourHook { get; init; } + + [Obsolete("Use OverrideChannel")] + private Guid _overrideChannel = Guid.Empty; + + #pragma warning disable CS0618 + internal Guid OverrideChannel { + get => this._overrideChannel; + private set { + this._overrideChannel = value; + this.UpdateChat(); + this.Plugin.Ipc.BroadcastOverride(); + } + } + #pragma warning restore CS0618 + private bool _shouldForceNameLookup; + + internal GameFunctions(Plugin plugin) { + SignatureHelper.Initialise(this); + this.Plugin = plugin; + + this.SendMessageHook!.Enable(); + this.SetChatChannelHook!.Enable(); + this.ChangeChannelNameHook!.Enable(); + this.ShouldDoNameLookupHook!.Enable(); + this.GetChatColourHook!.Enable(); + } + + public void Dispose() { + this.GetChatColourHook.Dispose(); + this.ShouldDoNameLookupHook.Dispose(); + this.ChangeChannelNameHook.Dispose(); + this.SetChatChannelHook.Dispose(); + this.SendMessageHook.Dispose(); + } + + internal void ResetOverride() { + this.OverrideChannel = Guid.Empty; + } + + internal byte[] ResolvePayloads(byte[] input) { + if (input.Length == 0) { + return input; + } + + var module = Framework.Instance()->GetUiModule()->GetPronounModule(); + var memorySpace = IMemorySpace.GetDefaultSpace(); + var str = memorySpace->Create(); + + if (input[^1] != 0) { + var replacement = new byte[input.Length + 1]; + input.CopyTo(replacement, 0); + replacement[^1] = 0; + input = replacement; + } + + fixed (byte* bytesPtr = input) { + str->SetString(bytesPtr); + } + + var postStep1 = this._resolvePayloads(module, str, 1, 0x3FF); + var postStep2 = this._step2(module, postStep1, 1); + + var list = new List(); + for (var i = 0; i < postStep2->BufUsed && postStep2->StringPtr[i] != 0; i++) { + list.Add(postStep2->StringPtr[i]); + } + + str->Dtor(); + IMemorySpace.Free(str); + + // postStep1->Dtor(); + // IMemorySpace.Free(postStep1); + + // game dies if you do this + // postStep2->Dtor(); + // IMemorySpace.Free(postStep2); + + return list.ToArray(); + } + + private void SendMessageDetour(IntPtr a1, Utf8String* message, IntPtr a3) { + try { + if (this.SendMessageDetourInner(message)) { + this.SendMessageHook.Original(a1, message, a3); + } + } catch (Exception ex) { + PluginLog.LogError(ex, "Error in message detour"); + } + } + + /// true if the original function should be called + private bool SendMessageDetourInner(Utf8String* message) { + var sendTo = this.OverrideChannel; + + byte[]? toSend = null; + if (message->StringPtr[0] == '/') { + sendTo = Guid.Empty; + var command = ""; + int i; + for (i = 0; i < message->BufSize; i++) { + var c = message->StringPtr[i]; + if (c == 0 || char.IsWhiteSpace((char) c)) { + break; + } + + command += (char) c; + } + + if (this.Plugin.Commands.Registered.TryGetValue(command, out var id)) { + var entireMessage = MemoryHelper.ReadRawNullTerminated((IntPtr) message->StringPtr); + sendTo = id; + if (entireMessage.Length - 1 >= i && char.IsWhiteSpace((char) entireMessage[i])) { + i += 1; + } + + toSend = entireMessage[i..]; + + var isBlank = toSend.Length == 0 || toSend.All(c => char.IsWhiteSpace((char) c)); + if (isBlank) { + this.OverrideChannel = id; + return false; + } + } + } + + if (sendTo == Guid.Empty) { + return true; + } + + toSend ??= MemoryHelper.ReadRawNullTerminated((IntPtr) message->StringPtr); + + if (toSend.Length == 0 || toSend.All(c => char.IsWhiteSpace((char) c))) { + // don't send blank messages even to the original handler + return false; + } + + this.Plugin.Commands.SendMessage(sendTo, toSend); + return false; + } + + private void UpdateChat() { + this._shouldForceNameLookup = true; + var agent = Framework.Instance()->GetUiModule()->GetAgentModule()->GetAgentByInternalId(AgentId.ChatLog); + var update = (delegate* unmanaged) ((void**) agent->VTable)[6]; + update(agent); + } + + private void SetChatChannelDetour(RaptureShellModule* module, uint channel) { + // avoid potential stack overflow from recursion + if (this.OverrideChannel != Guid.Empty) { + this.OverrideChannel = Guid.Empty; + } + + this.SetChatChannelHook.Original(module, channel); + } + + private IntPtr ChangeChannelNameDetour(IntPtr agent) { + var ret = this.ChangeChannelNameHook.Original(agent); + + if (this.OverrideChannel == Guid.Empty) { + return ret; + } + + var chatChannel = (Utf8String*) (agent + 0x48); + var name = this.Plugin.ConfigInfo.GetFullName(this.OverrideChannel); + fixed (byte* bytesPtr = Encoding.UTF8.GetBytes("\u3000 " + name + "\0")) { + chatChannel->SetString(bytesPtr); + } + + return (IntPtr) chatChannel->StringPtr; + } + + private byte ShouldDoNameLookupDetour(IntPtr agent) { + if (this._shouldForceNameLookup) { + this._shouldForceNameLookup = false; + return 1; + } + + return this.ShouldDoNameLookupHook.Original(agent); + } + + private ulong GetChatColourDetour(IntPtr a1, int a2) { + try { + if (this.OverrideChannel != Guid.Empty) { + var ui = this.Plugin.ConfigInfo.GetUiColour(this.OverrideChannel); + if (this.Plugin.DataManager.GetExcelSheet()?.GetRow(ui)?.UIForeground is { } colour) { + return colour >> 8; + } + } + } catch (Exception ex) { + PluginLog.LogError(ex, "Error in get chat colour detour"); + } + + return this.GetChatColourHook.Original(a1, a2); + } +} diff --git a/client/ExtraChat/Ipc.cs b/client/ExtraChat/Ipc.cs new file mode 100644 index 0000000..e41d70d --- /dev/null +++ b/client/ExtraChat/Ipc.cs @@ -0,0 +1,65 @@ +using Dalamud.Plugin.Ipc; +using Lumina.Excel.GeneratedSheets; + +namespace ExtraChat; + +internal class Ipc : IDisposable { + [Serializable] + private struct OverrideInfo { + public string? Channel; + public ushort UiColour; + public uint Rgba; + } + + private Plugin Plugin { get; } + private ICallGateProvider OverrideChannelColour { get; } + private ICallGateProvider, Dictionary> ChannelCommandColours { get; } + + internal Ipc(Plugin plugin) { + this.Plugin = plugin; + + this.OverrideChannelColour = this.Plugin.Interface.GetIpcProvider("ExtraChat.OverrideChannelColour"); + this.ChannelCommandColours = this.Plugin.Interface.GetIpcProvider, Dictionary>("ExtraChat.ChannelCommandColours"); + + this.ChannelCommandColours.RegisterFunc(_ => this.GetChannelColours()); + } + + public void Dispose() { + this.ChannelCommandColours.UnregisterFunc(); + } + + private Dictionary GetChannelColours() { + var dict = new Dictionary(this.Plugin.Commands.Registered.Count); + + foreach (var (command, id) in this.Plugin.Commands.Registered) { + var colour = this.Plugin.ConfigInfo.GetUiColour(id); + if (this.Plugin.DataManager.GetExcelSheet()?.GetRow(colour)?.UIForeground is { } rgba) { + dict[command] = rgba; + } + } + + return dict; + } + + internal void BroadcastChannelCommandColours() { + this.ChannelCommandColours.SendMessage(this.GetChannelColours()); + } + + internal void BroadcastOverride() { + var over = this.Plugin.GameFunctions.OverrideChannel; + if (over == Guid.Empty) { + this.OverrideChannelColour.SendMessage(new OverrideInfo()); + return; + } + + var name = this.Plugin.ConfigInfo.GetFullName(over); + var colour = this.Plugin.ConfigInfo.GetUiColour(over); + var rgba = this.Plugin.DataManager.GetExcelSheet()?.GetRow(colour)?.UIForeground ?? 0; + + this.OverrideChannelColour.SendMessage(new OverrideInfo { + Channel = name, + UiColour = colour, + Rgba = rgba, + }); + } +} diff --git a/client/ExtraChat/Plugin.cs b/client/ExtraChat/Plugin.cs new file mode 100644 index 0000000..c1b80ed --- /dev/null +++ b/client/ExtraChat/Plugin.cs @@ -0,0 +1,155 @@ +using ASodium; +using Dalamud.ContextMenu; +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.Command; +using Dalamud.Game.Gui; +using Dalamud.Game.Gui.Toast; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.IoC; +using Dalamud.Plugin; +using ExtraChat.Ui; +using ExtraChat.Util; + +namespace ExtraChat; + +// ReSharper disable once ClassNeverInstantiated.Global +public class Plugin : IDalamudPlugin { + internal const string PluginName = "ExtraChat"; + internal const ushort DefaultColour = 578; + + public string Name => PluginName; + + [PluginService] + internal DalamudPluginInterface Interface { get; init; } + + [PluginService] + internal ClientState ClientState { get; init; } + + [PluginService] + internal CommandManager CommandManager { get; init; } + + [PluginService] + internal ChatGui ChatGui { get; init; } + + [PluginService] + internal DataManager DataManager { get; init; } + + [PluginService] + internal Framework Framework { get; init; } + + [PluginService] + internal GameGui GameGui { get; init; } + + [PluginService] + internal ObjectTable ObjectTable { get; init; } + + [PluginService] + internal TargetManager TargetManager { get; init; } + + [PluginService] + private ToastGui ToastGui { get; init; } + + internal Configuration Config { get; } + internal ConfigInfo ConfigInfo => this.Config.GetConfig(this.ClientState.LocalContentId); + internal Client Client { get; } + internal Commands Commands { get; } + internal PluginUi PluginUi { get; } + internal DalamudContextMenuBase ContextMenu { get; } + internal GameFunctions GameFunctions { get; } + internal Ipc Ipc { get; } + + private PlayerCharacter? _localPlayer; + private readonly Mutex _localPlayerLock = new(); + + internal PlayerCharacter? LocalPlayer { + get { + this._localPlayerLock.WaitOne(); + var player = this._localPlayer; + this._localPlayerLock.ReleaseMutex(); + return player; + } + private set { + this._localPlayerLock.WaitOne(); + this._localPlayer = value; + this._localPlayerLock.ReleaseMutex(); + } + } + + public Plugin() { + SodiumInit.Init(); + WorldUtil.Initialise(this.DataManager!); + this.ContextMenu = new DalamudContextMenuBase(); + this.Config = this.Interface!.GetPluginConfig() as Configuration ?? new Configuration(); + this.Client = new Client(this); + this.Commands = new Commands(this); + this.PluginUi = new PluginUi(this); + this.GameFunctions = new GameFunctions(this); + this.Ipc = new Ipc(this); + + this.Framework!.Update += this.FrameworkUpdate; + this.ContextMenu.Functions.ContextMenu.OnOpenGameObjectContextMenu += this.OnOpenGameObjectContextMenu; + } + + public void Dispose() { + this.GameFunctions.ResetOverride(); + + this.ContextMenu.Functions.ContextMenu.OnOpenGameObjectContextMenu -= this.OnOpenGameObjectContextMenu; + this.Framework.Update -= this.FrameworkUpdate; + this._localPlayerLock.Dispose(); + this.Ipc.Dispose(); + this.GameFunctions.Dispose(); + this.PluginUi.Dispose(); + this.Commands.Dispose(); + this.Client.Dispose(); + this.ContextMenu.Dispose(); + } + + private void FrameworkUpdate(Framework framework) { + if (this.ClientState.LocalPlayer is { } player) { + this.LocalPlayer = player; + } else if (!this.ClientState.IsLoggedIn) { + // only set to null if not logged in + this.LocalPlayer = null; + } + } + + private void OnOpenGameObjectContextMenu(GameObjectContextMenuOpenArgs args) { + if (args.ObjectId == 0xE0000000) { + return; + } + + var obj = this.ObjectTable.SearchById(args.ObjectId); + if (obj is not PlayerCharacter chara) { + return; + } + + args.AddCustomItem(new GameObjectContextMenuItem("Invite to ExtraChat Linkshell", _ => { + var name = chara.Name.TextValue; + this.PluginUi.InviteInfo = (name, (ushort) chara.HomeWorld.Id); + })); + } + + internal void SaveConfig() { + this.Interface.SavePluginConfig(this.Config); + } + + internal void ShowInfo(string message) { + if (this.Config.UseNativeToasts) { + this.ToastGui.ShowNormal(message); + } else { + this.Interface.UiBuilder.AddNotification(message, this.Name, NotificationType.Info); + } + } + + internal void ShowError(string message) { + if (this.Config.UseNativeToasts) { + this.ToastGui.ShowError(message); + } else { + this.Interface.UiBuilder.AddNotification(message, this.Name, NotificationType.Error); + } + } +} diff --git a/client/ExtraChat/Protocol/AuthenticateRequest.cs b/client/ExtraChat/Protocol/AuthenticateRequest.cs new file mode 100644 index 0000000..542d28d --- /dev/null +++ b/client/ExtraChat/Protocol/AuthenticateRequest.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class AuthenticateRequest { + [Key(0)] + public string Key; + + [Key(1)] + public byte[] PublicKey; +} diff --git a/client/ExtraChat/Protocol/AuthenticateResponse.cs b/client/ExtraChat/Protocol/AuthenticateResponse.cs new file mode 100644 index 0000000..bbd5fc8 --- /dev/null +++ b/client/ExtraChat/Protocol/AuthenticateResponse.cs @@ -0,0 +1,10 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class AuthenticateResponse { + [Key(0)] + public string? Error; +} diff --git a/client/ExtraChat/Protocol/Channels/Channel.cs b/client/ExtraChat/Protocol/Channels/Channel.cs new file mode 100644 index 0000000..12c46f9 --- /dev/null +++ b/client/ExtraChat/Protocol/Channels/Channel.cs @@ -0,0 +1,24 @@ +using System.Text; +using ExtraChat.Formatters; +using ExtraChat.Util; +using MessagePack; + +namespace ExtraChat.Protocol.Channels; + +[Serializable] +[MessagePackObject] +public class Channel { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Id; + + [Key(1)] + public byte[] Name; + + [Key(2)] + public List Members; + + internal string DecryptName(byte[] key) { + return Encoding.UTF8.GetString(SecretBox.Decrypt(key, this.Name)); + } +} diff --git a/client/ExtraChat/Protocol/Channels/Member.cs b/client/ExtraChat/Protocol/Channels/Member.cs new file mode 100644 index 0000000..3adacda --- /dev/null +++ b/client/ExtraChat/Protocol/Channels/Member.cs @@ -0,0 +1,19 @@ +using MessagePack; + +namespace ExtraChat.Protocol.Channels; + +[Serializable] +[MessagePackObject] +public class Member { + [Key(0)] + public string Name; + + [Key(1)] + public ushort World; + + [Key(2)] + public Rank Rank; + + [Key(3)] + public bool Online; +} diff --git a/client/ExtraChat/Protocol/Channels/Rank.cs b/client/ExtraChat/Protocol/Channels/Rank.cs new file mode 100644 index 0000000..9154ef2 --- /dev/null +++ b/client/ExtraChat/Protocol/Channels/Rank.cs @@ -0,0 +1,20 @@ +namespace ExtraChat.Protocol.Channels; + +[Serializable] +public enum Rank : byte { + Invited = 0, + Member = 1, + Moderator = 2, + Admin = 3, +} + +internal static class RankExt { + internal static string Symbol(this Rank rank) => rank switch { + // invited: a question mark with a circle around it + Rank.Invited => "? ", + Rank.Member => "", + Rank.Moderator => "☆ ", + Rank.Admin => "★ ", + _ => "", + }; +} diff --git a/client/ExtraChat/Protocol/Channels/SimpleChannel.cs b/client/ExtraChat/Protocol/Channels/SimpleChannel.cs new file mode 100644 index 0000000..dcc95f4 --- /dev/null +++ b/client/ExtraChat/Protocol/Channels/SimpleChannel.cs @@ -0,0 +1,18 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol.Channels; + +[Serializable] +[MessagePackObject] +public class SimpleChannel { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Id; + + [Key(1)] + public byte[] Name; + + [Key(2)] + public Rank Rank; +} diff --git a/client/ExtraChat/Protocol/CreateRequest.cs b/client/ExtraChat/Protocol/CreateRequest.cs new file mode 100644 index 0000000..80126fb --- /dev/null +++ b/client/ExtraChat/Protocol/CreateRequest.cs @@ -0,0 +1,10 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class CreateRequest { + [Key(0)] + public byte[] Name; +} diff --git a/client/ExtraChat/Protocol/CreateResponse.cs b/client/ExtraChat/Protocol/CreateResponse.cs new file mode 100644 index 0000000..bf0e580 --- /dev/null +++ b/client/ExtraChat/Protocol/CreateResponse.cs @@ -0,0 +1,11 @@ +using ExtraChat.Protocol.Channels; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class CreateResponse { + [Key(0)] + public Channel Channel; +} diff --git a/client/ExtraChat/Protocol/DisbandRequest.cs b/client/ExtraChat/Protocol/DisbandRequest.cs new file mode 100644 index 0000000..3551086 --- /dev/null +++ b/client/ExtraChat/Protocol/DisbandRequest.cs @@ -0,0 +1,12 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class DisbandRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; +} diff --git a/client/ExtraChat/Protocol/DisbandResponse.cs b/client/ExtraChat/Protocol/DisbandResponse.cs new file mode 100644 index 0000000..725a834 --- /dev/null +++ b/client/ExtraChat/Protocol/DisbandResponse.cs @@ -0,0 +1,12 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class DisbandResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; +} diff --git a/client/ExtraChat/Protocol/ErrorResponse.cs b/client/ExtraChat/Protocol/ErrorResponse.cs new file mode 100644 index 0000000..3e581ae --- /dev/null +++ b/client/ExtraChat/Protocol/ErrorResponse.cs @@ -0,0 +1,15 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class ErrorResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidNullableFormatter))] + public Guid? Channel; + + [Key(1)] + public string Error; +} diff --git a/client/ExtraChat/Protocol/InviteRequest.cs b/client/ExtraChat/Protocol/InviteRequest.cs new file mode 100644 index 0000000..6e0b21d --- /dev/null +++ b/client/ExtraChat/Protocol/InviteRequest.cs @@ -0,0 +1,21 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class InviteRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; + + [Key(3)] + public byte[] EncryptedSecret; +} diff --git a/client/ExtraChat/Protocol/InviteResponse.cs b/client/ExtraChat/Protocol/InviteResponse.cs new file mode 100644 index 0000000..f2ec62e --- /dev/null +++ b/client/ExtraChat/Protocol/InviteResponse.cs @@ -0,0 +1,18 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class InviteResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; +} diff --git a/client/ExtraChat/Protocol/InvitedResponse.cs b/client/ExtraChat/Protocol/InvitedResponse.cs new file mode 100644 index 0000000..cdfa3c8 --- /dev/null +++ b/client/ExtraChat/Protocol/InvitedResponse.cs @@ -0,0 +1,23 @@ +using ExtraChat.Protocol.Channels; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class InvitedResponse { + [Key(0)] + public Channel Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; + + [Key(3)] + public byte[] PublicKey; + + [Key(4)] + public byte[] EncryptedSecret; +} diff --git a/client/ExtraChat/Protocol/JoinRequest.cs b/client/ExtraChat/Protocol/JoinRequest.cs new file mode 100644 index 0000000..61a918c --- /dev/null +++ b/client/ExtraChat/Protocol/JoinRequest.cs @@ -0,0 +1,12 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class JoinRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; +} diff --git a/client/ExtraChat/Protocol/JoinResponse.cs b/client/ExtraChat/Protocol/JoinResponse.cs new file mode 100644 index 0000000..e839b14 --- /dev/null +++ b/client/ExtraChat/Protocol/JoinResponse.cs @@ -0,0 +1,11 @@ +using ExtraChat.Protocol.Channels; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class JoinResponse { + [Key(0)] + public Channel Channel; +} diff --git a/client/ExtraChat/Protocol/KickRequest.cs b/client/ExtraChat/Protocol/KickRequest.cs new file mode 100644 index 0000000..2807a53 --- /dev/null +++ b/client/ExtraChat/Protocol/KickRequest.cs @@ -0,0 +1,18 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class KickRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; +} diff --git a/client/ExtraChat/Protocol/KickResponse.cs b/client/ExtraChat/Protocol/KickResponse.cs new file mode 100644 index 0000000..a6cd9dc --- /dev/null +++ b/client/ExtraChat/Protocol/KickResponse.cs @@ -0,0 +1,18 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class KickResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; +} diff --git a/client/ExtraChat/Protocol/LeaveRequest.cs b/client/ExtraChat/Protocol/LeaveRequest.cs new file mode 100644 index 0000000..ec7fce6 --- /dev/null +++ b/client/ExtraChat/Protocol/LeaveRequest.cs @@ -0,0 +1,12 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class LeaveRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; +} diff --git a/client/ExtraChat/Protocol/LeaveResponse.cs b/client/ExtraChat/Protocol/LeaveResponse.cs new file mode 100644 index 0000000..c2381ce --- /dev/null +++ b/client/ExtraChat/Protocol/LeaveResponse.cs @@ -0,0 +1,15 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class LeaveResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string? Error; +} diff --git a/client/ExtraChat/Protocol/ListRequest.cs b/client/ExtraChat/Protocol/ListRequest.cs new file mode 100644 index 0000000..0ced7c6 --- /dev/null +++ b/client/ExtraChat/Protocol/ListRequest.cs @@ -0,0 +1,17 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +[MessagePackFormatter(typeof(ListRequestFormatter))] +public abstract record ListRequest { + public record All : ListRequest; + + public record Channels : ListRequest; + + public record Members(Guid ChannelId) : ListRequest; + + public record Invites : ListRequest; +} diff --git a/client/ExtraChat/Protocol/ListResponse.cs b/client/ExtraChat/Protocol/ListResponse.cs new file mode 100644 index 0000000..97fde8c --- /dev/null +++ b/client/ExtraChat/Protocol/ListResponse.cs @@ -0,0 +1,22 @@ +using ExtraChat.Formatters; +using ExtraChat.Protocol.Channels; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +[MessagePackFormatter(typeof(ListResponseFormatter))] +public abstract record ListResponse { + [MessagePackObject] + public record All(Channel[] AllChannels, Channel[] AllInvites) : ListResponse; + + [MessagePackObject] + public record Channels(SimpleChannel[] SimpleChannels) : ListResponse; + + [MessagePackObject] + public record Members(Guid ChannelId, Member[] AllMembers) : ListResponse; + + [MessagePackObject] + public record Invites(SimpleChannel[] AllInvites) : ListResponse; +} diff --git a/client/ExtraChat/Protocol/MemberChangeKind.cs b/client/ExtraChat/Protocol/MemberChangeKind.cs new file mode 100644 index 0000000..358c048 --- /dev/null +++ b/client/ExtraChat/Protocol/MemberChangeKind.cs @@ -0,0 +1,31 @@ +using ExtraChat.Formatters; +using ExtraChat.Protocol.Channels; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +[MessagePackFormatter(typeof(MemberChangeKindFormatter))] +public abstract record MemberChangeKind { + [MessagePackObject] + public record Invite(string Inviter, ushort InviterWorld) : MemberChangeKind; + + [MessagePackObject] + public record InviteDecline : MemberChangeKind; + + [MessagePackObject] + public record InviteCancel(string Canceler, ushort CancelerWorld) : MemberChangeKind; + + [MessagePackObject] + public record Join : MemberChangeKind; + + [MessagePackObject] + public record Leave : MemberChangeKind; + + [MessagePackObject] + public record Promote(Rank Rank) : MemberChangeKind; + + [MessagePackObject] + public record Kick(string Kicker, ushort KickerWorld) : MemberChangeKind; +} diff --git a/client/ExtraChat/Protocol/MemberChangeResponse.cs b/client/ExtraChat/Protocol/MemberChangeResponse.cs new file mode 100644 index 0000000..b5d6bda --- /dev/null +++ b/client/ExtraChat/Protocol/MemberChangeResponse.cs @@ -0,0 +1,21 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class MemberChangeResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; + + [Key(3)] + public MemberChangeKind Kind; +} diff --git a/client/ExtraChat/Protocol/MessageRequest.cs b/client/ExtraChat/Protocol/MessageRequest.cs new file mode 100644 index 0000000..96a56a9 --- /dev/null +++ b/client/ExtraChat/Protocol/MessageRequest.cs @@ -0,0 +1,15 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class MessageRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public byte[] Message; +} diff --git a/client/ExtraChat/Protocol/MessageResponse.cs b/client/ExtraChat/Protocol/MessageResponse.cs new file mode 100644 index 0000000..cd1b1b6 --- /dev/null +++ b/client/ExtraChat/Protocol/MessageResponse.cs @@ -0,0 +1,21 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class MessageResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Sender; + + [Key(2)] + public ushort World; + + [Key(3)] + public byte[] Message; +} diff --git a/client/ExtraChat/Protocol/PingRequest.cs b/client/ExtraChat/Protocol/PingRequest.cs new file mode 100644 index 0000000..a361b51 --- /dev/null +++ b/client/ExtraChat/Protocol/PingRequest.cs @@ -0,0 +1,8 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class PingRequest { +} diff --git a/client/ExtraChat/Protocol/PingResponse.cs b/client/ExtraChat/Protocol/PingResponse.cs new file mode 100644 index 0000000..89968a4 --- /dev/null +++ b/client/ExtraChat/Protocol/PingResponse.cs @@ -0,0 +1,8 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class PingResponse { +} diff --git a/client/ExtraChat/Protocol/PromoteRequest.cs b/client/ExtraChat/Protocol/PromoteRequest.cs new file mode 100644 index 0000000..dd552af --- /dev/null +++ b/client/ExtraChat/Protocol/PromoteRequest.cs @@ -0,0 +1,22 @@ +using ExtraChat.Formatters; +using ExtraChat.Protocol.Channels; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class PromoteRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; + + [Key(3)] + public Rank Rank; +} diff --git a/client/ExtraChat/Protocol/PromoteResponse.cs b/client/ExtraChat/Protocol/PromoteResponse.cs new file mode 100644 index 0000000..31fdc2a --- /dev/null +++ b/client/ExtraChat/Protocol/PromoteResponse.cs @@ -0,0 +1,22 @@ +using ExtraChat.Formatters; +using ExtraChat.Protocol.Channels; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class PromoteResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public string Name; + + [Key(2)] + public ushort World; + + [Key(3)] + public Rank Rank; +} diff --git a/client/ExtraChat/Protocol/PublicKeyRequest.cs b/client/ExtraChat/Protocol/PublicKeyRequest.cs new file mode 100644 index 0000000..4c72f5d --- /dev/null +++ b/client/ExtraChat/Protocol/PublicKeyRequest.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class PublicKeyRequest { + [Key(0)] + public string Name; + + [Key(1)] + public ushort World; +} diff --git a/client/ExtraChat/Protocol/PublicKeyResponse.cs b/client/ExtraChat/Protocol/PublicKeyResponse.cs new file mode 100644 index 0000000..f09bb28 --- /dev/null +++ b/client/ExtraChat/Protocol/PublicKeyResponse.cs @@ -0,0 +1,16 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class PublicKeyResponse { + [Key(0)] + public string Name; + + [Key(1)] + public ushort World; + + [Key(2)] + public byte[]? PublicKey; +} diff --git a/client/ExtraChat/Protocol/RegisterRequest.cs b/client/ExtraChat/Protocol/RegisterRequest.cs new file mode 100644 index 0000000..29218c4 --- /dev/null +++ b/client/ExtraChat/Protocol/RegisterRequest.cs @@ -0,0 +1,16 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class RegisterRequest { + [Key(0)] + public string Name; + + [Key(1)] + public ushort World; + + [Key(2)] + public bool ChallengeCompleted; +} diff --git a/client/ExtraChat/Protocol/RegisterResponse.cs b/client/ExtraChat/Protocol/RegisterResponse.cs new file mode 100644 index 0000000..415fc28 --- /dev/null +++ b/client/ExtraChat/Protocol/RegisterResponse.cs @@ -0,0 +1,18 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +[MessagePackFormatter(typeof(RegisterResponseFormatter))] +public abstract record RegisterResponse { + [MessagePackObject] + public record Challenge(string Text) : RegisterResponse; + + [MessagePackObject] + public record Failure : RegisterResponse; + + [MessagePackObject] + public record Success(string Key) : RegisterResponse; +} diff --git a/client/ExtraChat/Protocol/RequestContainer.cs b/client/ExtraChat/Protocol/RequestContainer.cs new file mode 100644 index 0000000..016af39 --- /dev/null +++ b/client/ExtraChat/Protocol/RequestContainer.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class RequestContainer { + [Key(0)] + public uint Number; + + [Key(1)] + public RequestKind Kind; +} diff --git a/client/ExtraChat/Protocol/RequestKind.cs b/client/ExtraChat/Protocol/RequestKind.cs new file mode 100644 index 0000000..d526159 --- /dev/null +++ b/client/ExtraChat/Protocol/RequestKind.cs @@ -0,0 +1,57 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +[MessagePackFormatter(typeof(RequestKindFormatter))] +public abstract record RequestKind { + [MessagePackObject] + public record Ping(PingRequest Request) : RequestKind; + + [MessagePackObject] + public record Register(RegisterRequest Request) : RequestKind; + + [MessagePackObject] + public record Authenticate(AuthenticateRequest Request) : RequestKind; + + [MessagePackObject] + public record Message(MessageRequest Request) : RequestKind; + + [MessagePackObject] + public record Create(CreateRequest Request) : RequestKind; + + [MessagePackObject] + public record PublicKey(PublicKeyRequest Request) : RequestKind; + + [MessagePackObject] + public record Invite(InviteRequest Request) : RequestKind; + + [MessagePackObject] + public record Join(JoinRequest Request) : RequestKind; + + [MessagePackObject] + public record List(ListRequest Request) : RequestKind; + + [MessagePackObject] + public record Leave(LeaveRequest Request) : RequestKind; + + [MessagePackObject] + public record Kick(KickRequest Request) : RequestKind; + + [MessagePackObject] + public record Disband(DisbandRequest Request) : RequestKind; + + [MessagePackObject] + public record Promote(PromoteRequest Request) : RequestKind; + + [MessagePackObject] + public record Update(UpdateRequest Request) : RequestKind; + + [MessagePackObject] + public record Secrets(SecretsRequest Request) : RequestKind; + + [MessagePackObject] + public record SendSecrets(SendSecretsRequest Request) : RequestKind; +} diff --git a/client/ExtraChat/Protocol/ResponseContainer.cs b/client/ExtraChat/Protocol/ResponseContainer.cs new file mode 100644 index 0000000..6c33806 --- /dev/null +++ b/client/ExtraChat/Protocol/ResponseContainer.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class ResponseContainer { + [Key(0)] + public uint Number; + + [Key(1)] + public ResponseKind Kind; +} diff --git a/client/ExtraChat/Protocol/ResponseKind.cs b/client/ExtraChat/Protocol/ResponseKind.cs new file mode 100644 index 0000000..da98fcf --- /dev/null +++ b/client/ExtraChat/Protocol/ResponseKind.cs @@ -0,0 +1,69 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +[MessagePackFormatter(typeof(ResponseKindFormatter))] +public abstract record ResponseKind { + [MessagePackObject] + public record Ping(PingResponse Response) : ResponseKind; + + [MessagePackObject] + public record Error(ErrorResponse Response) : ResponseKind; + + [MessagePackObject] + public record Register(RegisterResponse Response) : ResponseKind; + + [MessagePackObject] + public record Authenticate(AuthenticateResponse Response) : ResponseKind; + + [MessagePackObject] + public record Message(MessageResponse Response) : ResponseKind; + + [MessagePackObject] + public record Create(CreateResponse Response) : ResponseKind; + + [MessagePackObject] + public record PublicKey(PublicKeyResponse Response) : ResponseKind; + + [MessagePackObject] + public record Invite(InviteResponse Response) : ResponseKind; + + [MessagePackObject] + public record Invited(InvitedResponse Response) : ResponseKind; + + [MessagePackObject] + public record Join(JoinResponse Response) : ResponseKind; + + [MessagePackObject] + public record List(ListResponse Response) : ResponseKind; + + [MessagePackObject] + public record Leave(LeaveResponse Response) : ResponseKind; + + [MessagePackObject] + public record Kick(KickResponse Response) : ResponseKind; + + [MessagePackObject] + public record Disband(DisbandResponse Response) : ResponseKind; + + [MessagePackObject] + public record Promote(PromoteResponse Response) : ResponseKind; + + [MessagePackObject] + public record MemberChange(MemberChangeResponse Response) : ResponseKind; + + [MessagePackObject] + public record Update(UpdateResponse Response) : ResponseKind; + + [MessagePackObject] + public record Updated(UpdatedResponse Response) : ResponseKind; + + [MessagePackObject] + public record Secrets(SecretsResponse Response) : ResponseKind; + + [MessagePackObject] + public record SendSecrets(SendSecretsResponse Response) : ResponseKind; +} diff --git a/client/ExtraChat/Protocol/SecretsRequest.cs b/client/ExtraChat/Protocol/SecretsRequest.cs new file mode 100644 index 0000000..d4a0b1c --- /dev/null +++ b/client/ExtraChat/Protocol/SecretsRequest.cs @@ -0,0 +1,12 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class SecretsRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; +} diff --git a/client/ExtraChat/Protocol/SecretsResponse.cs b/client/ExtraChat/Protocol/SecretsResponse.cs new file mode 100644 index 0000000..e2d64a4 --- /dev/null +++ b/client/ExtraChat/Protocol/SecretsResponse.cs @@ -0,0 +1,18 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class SecretsResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public byte[] PublicKey; + + [Key(2)] + public byte[] EncryptedSharedSecret; +} diff --git a/client/ExtraChat/Protocol/SendSecretsRequest.cs b/client/ExtraChat/Protocol/SendSecretsRequest.cs new file mode 100644 index 0000000..e910531 --- /dev/null +++ b/client/ExtraChat/Protocol/SendSecretsRequest.cs @@ -0,0 +1,15 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class SendSecretsRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid RequestId; + + [Key(1)] + public byte[]? EncryptedSharedSecret; +} diff --git a/client/ExtraChat/Protocol/SendSecretsResponse.cs b/client/ExtraChat/Protocol/SendSecretsResponse.cs new file mode 100644 index 0000000..6d9dd28 --- /dev/null +++ b/client/ExtraChat/Protocol/SendSecretsResponse.cs @@ -0,0 +1,19 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class SendSecretsResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid RequestId; + + [Key(2)] + public byte[] PublicKey; +} diff --git a/client/ExtraChat/Protocol/UpdateKind.cs b/client/ExtraChat/Protocol/UpdateKind.cs new file mode 100644 index 0000000..b11076f --- /dev/null +++ b/client/ExtraChat/Protocol/UpdateKind.cs @@ -0,0 +1,12 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +[MessagePackFormatter(typeof(UpdateKindFormatter))] +public abstract record UpdateKind { + [MessagePackObject] + public record Name(byte[] NewName) : UpdateKind; +} diff --git a/client/ExtraChat/Protocol/UpdateRequest.cs b/client/ExtraChat/Protocol/UpdateRequest.cs new file mode 100644 index 0000000..ac0991a --- /dev/null +++ b/client/ExtraChat/Protocol/UpdateRequest.cs @@ -0,0 +1,15 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class UpdateRequest { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public UpdateKind Kind; +} diff --git a/client/ExtraChat/Protocol/UpdateResponse.cs b/client/ExtraChat/Protocol/UpdateResponse.cs new file mode 100644 index 0000000..8195949 --- /dev/null +++ b/client/ExtraChat/Protocol/UpdateResponse.cs @@ -0,0 +1,12 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class UpdateResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; +} diff --git a/client/ExtraChat/Protocol/UpdatedResponse.cs b/client/ExtraChat/Protocol/UpdatedResponse.cs new file mode 100644 index 0000000..b89967f --- /dev/null +++ b/client/ExtraChat/Protocol/UpdatedResponse.cs @@ -0,0 +1,15 @@ +using ExtraChat.Formatters; +using MessagePack; + +namespace ExtraChat.Protocol; + +[Serializable] +[MessagePackObject] +public class UpdatedResponse { + [Key(0)] + [MessagePackFormatter(typeof(BinaryUuidFormatter))] + public Guid Channel; + + [Key(1)] + public UpdateKind Kind; +} diff --git a/client/ExtraChat/Ui/PluginUi.cs b/client/ExtraChat/Ui/PluginUi.cs new file mode 100644 index 0000000..edbe9f1 --- /dev/null +++ b/client/ExtraChat/Ui/PluginUi.cs @@ -0,0 +1,738 @@ +using System.Diagnostics; +using System.Numerics; +using System.Text; +using System.Threading.Channels; +using Dalamud.Interface; +using Dalamud.Plugin; +using ExtraChat.Protocol; +using ExtraChat.Protocol.Channels; +using ExtraChat.Util; +using ImGuiNET; +using Lumina.Excel.GeneratedSheets; +using Channel = System.Threading.Channels.Channel; + +namespace ExtraChat.Ui; + +internal class PluginUi : IDisposable { + internal const string CrossWorld = "\ue05d"; + + private Plugin Plugin { get; } + + internal bool Visible; + + private readonly List<(string, List)> _worlds; + private readonly List<(uint Id, Vector4 Abgr)> _uiColours; + + internal PluginUi(Plugin plugin) { + this.Plugin = plugin; + + this._worlds = this.Plugin.DataManager.GetExcelSheet()! + .Where(row => row.IsPublic) + .GroupBy(row => row.DataCenter.Value!) + .Where(grouping => grouping.Key.Region != 0) + .OrderBy(grouping => grouping.Key.Region) + .ThenBy(grouping => grouping.Key.Name.RawString) + .Select(grouping => (grouping.Key.Name.RawString, grouping.OrderBy(row => row.Name.RawString).ToList())) + .ToList(); + + this._uiColours = this.Plugin.DataManager.GetExcelSheet()! + .Where(row => row.UIForeground is not (0 or 0x000000FF)) + .Select(row => (row.RowId, row.UIForeground, ColourUtil.Step(row.UIForeground))) + .GroupBy(row => row.UIForeground) + .Select(grouping => grouping.First()) + .OrderBy(row => row.Item3.Item1) + .ThenBy(row => row.Item3.Item2) + .ThenBy(row => row.Item3.Item3) + .Select(row => (row.RowId, ImGui.ColorConvertU32ToFloat4(ColourUtil.RgbaToAbgr(row.Item2)))) + .ToList(); + + this.Plugin.Interface.UiBuilder.Draw += this.Draw; + this.Plugin.Interface.UiBuilder.OpenConfigUi += this.OpenConfigUi; + + if (this.Plugin.Interface.Reason == PluginLoadReason.Installer && this.Plugin.ConfigInfo.Key == null) { + this.Visible = true; + } + } + + public void Dispose() { + this.Plugin.Interface.UiBuilder.OpenConfigUi -= this.OpenConfigUi; + this.Plugin.Interface.UiBuilder.Draw -= this.Draw; + } + + private void OpenConfigUi() { + this.Visible ^= true; + } + + internal (string, ushort)? InviteInfo; + + private volatile bool _busy; + private string? _challenge; + private string _createName = string.Empty; + private Guid? _inviteId; + private readonly Channel _challengeChannel = Channel.CreateUnbounded(); + + private void Draw() { + if (this._challengeChannel.Reader.TryRead(out var challenge)) { + this._challenge = challenge; + } + + this.DrawConfigWindow(); + this.DrawInviteWindow(); + } + + private void DrawConfigWindow() { + if (!this.Visible) { + return; + } + + ImGui.SetNextWindowSize(new Vector2(500, 325) * ImGuiHelpers.GlobalScale, ImGuiCond.FirstUseEver); + + if (!ImGui.Begin(this.Plugin.Name, ref this.Visible)) { + ImGui.End(); + return; + } + + if (!this.Plugin.ClientState.IsLoggedIn) { + ImGui.TextUnformatted("Please log in to a character."); + ImGui.End(); + return; + } + + if (ImGui.BeginTabBar("tabs")) { + if (ImGui.BeginTabItem("Linkshells")) { + var status = this.Plugin.Client.Status; + ImGui.TextUnformatted($"Status: {status}"); + + switch (status) { + case Client.State.Connected: + this.DrawList(); + break; + case Client.State.NotAuthenticated: + case Client.State.RetrievingChallenge: + case Client.State.WaitingForVerification: + case Client.State.Verifying: + this.DrawRegistrationPanel(); + break; + } + + ImGui.EndTabItem(); + } + + if (ImGui.BeginTabItem("Settings")) { + this.DrawSettings(); + ImGui.EndTabItem(); + } + + ImGui.EndTabBar(); + } + + ImGui.End(); + } + + private void DrawSettings() { + var anyChanged = false; + + if (ImGui.BeginTabBar("settings-tabs")) { + if (ImGui.BeginTabItem("General")) { + this.DrawSettingsGeneral(ref anyChanged); + ImGui.EndTabItem(); + } + + if (ImGui.BeginTabItem("Linkshells")) { + this.DrawSettingsLinkshells(ref anyChanged); + ImGui.EndTabItem(); + } + + ImGui.EndTabBar(); + } + + if (anyChanged) { + this.Plugin.SaveConfig(); + this.Plugin.Ipc.BroadcastChannelCommandColours(); + } + } + + private void DrawSettingsGeneral(ref bool anyChanged) { + anyChanged |= ImGui.Checkbox("Use native toasts", ref this.Plugin.Config.UseNativeToasts); + // ImGui.Spacing(); + // + // ImGui.TextUnformatted("Default channel"); + // ImGui.SetNextItemWidth(-1); + // if (ImGui.BeginCombo("##default-channel", $"{this.Plugin.Config.DefaultChannel}")) { + // foreach (var channel in Enum.GetValues()) { + // if (ImGui.Selectable($"{channel}", this.Plugin.Config.DefaultChannel == channel)) { + // this.Plugin.Config.DefaultChannel = channel; + // anyChanged = true; + // } + // } + // + // ImGui.EndCombo(); + // } + } + + private void DrawSettingsLinkshells(ref bool anyChanged) { + var channelOrder = this.Plugin.ConfigInfo.ChannelOrder.ToDictionary( + entry => entry.Value, + entry => entry.Key + ); + + var orderedChannels = this.Plugin.Client.Channels.Keys + .OrderBy(id => channelOrder.ContainsKey(id) ? channelOrder[id] : int.MaxValue) + .Concat(this.Plugin.Client.InvitedChannels.Keys); + + foreach (var id in orderedChannels) { + var name = this.Plugin.ConfigInfo.GetName(id); + + if (ImGui.CollapsingHeader($"{name}###{id}-settings")) { + ImGui.PushID($"{id}-settings"); + + ImGui.TextUnformatted("Number"); + channelOrder.TryGetValue(id, out var refOrder); + var old = refOrder; + refOrder += 1; + ImGui.SetNextItemWidth(-1); + if (ImGui.InputInt("##order", ref refOrder)) { + refOrder = Math.Max(1, refOrder) - 1; + + if (this.Plugin.ConfigInfo.ChannelOrder.TryGetValue(refOrder, out var other) && other != id) { + // another channel already has this number, so swap + this.Plugin.ConfigInfo.ChannelOrder[old] = other; + } else { + this.Plugin.ConfigInfo.ChannelOrder.Remove(old); + } + + this.Plugin.ConfigInfo.ChannelOrder[refOrder] = id; + anyChanged = true; + this.Plugin.Commands.ReregisterAll(); + } + + ImGui.Spacing(); + + if (ImGuiUtil.IconButton(FontAwesomeIcon.Undo, "colour-reset", "Reset")) { + anyChanged = true; + this.Plugin.ConfigInfo.ChannelColors.Remove(id); + } + + ImGui.SameLine(); + + var colourKey = this.Plugin.ConfigInfo.GetUiColour(id); + var colour = this.Plugin.DataManager.GetExcelSheet()!.GetRow(colourKey)?.UIForeground ?? 0xff5ad0ff; + var vec = ImGui.ColorConvertU32ToFloat4(ColourUtil.RgbaToAbgr(colour)); + + const string colourPickerId = "linkshell-colour-picker"; + + if (ImGui.ColorButton("Linkshell colour", vec, ImGuiColorEditFlags.NoTooltip)) { + ImGui.OpenPopup(colourPickerId); + } + + ImGui.SameLine(); + + ImGui.TextUnformatted("Linkshell colour"); + + if (ImGui.BeginPopup(colourPickerId)) { + var i = 0; + + foreach (var (uiColour, fg) in this._uiColours) { + if (ImGui.ColorButton($"Colour {uiColour}", fg, ImGuiColorEditFlags.NoTooltip)) { + this.Plugin.ConfigInfo.ChannelColors[id] = (ushort) uiColour; + anyChanged = true; + ImGui.CloseCurrentPopup(); + } + + if (i >= 11) { + i = 0; + } else { + ImGui.SameLine(); + i += 1; + } + } + + ImGui.EndPopup(); + } + + ImGui.Spacing(); + + var hint = $"ECLS{refOrder}"; + if (!this.Plugin.ConfigInfo.ChannelMarkers.TryGetValue(id, out var marker)) { + marker = string.Empty; + } + + ImGui.TextUnformatted("Chat marker"); + ImGui.SetNextItemWidth(-1); + if (ImGui.InputTextWithHint("##marker", hint, ref marker, 16)) { + anyChanged = true; + if (string.IsNullOrWhiteSpace(marker)) { + this.Plugin.ConfigInfo.ChannelMarkers.Remove(id); + } else { + this.Plugin.ConfigInfo.ChannelMarkers[id] = marker; + } + } + + // ImGui.Spacing(); + // + // ImGui.TextUnformatted("Output channel"); + // ImGui.SetNextItemWidth(-1); + // + // var contained = this.Plugin.ConfigInfo.ChannelChannels.TryGetValue(id, out var output); + // var preview = contained ? $"{output}" : "Default"; + // + // if (ImGui.BeginCombo("##output-channel", preview)) { + // if (ImGui.Selectable("Default", !contained)) { + // this.Plugin.ConfigInfo.ChannelChannels.Remove(id); + // anyChanged = true; + // } + // + // foreach (var channel in Enum.GetValues()) { + // if (ImGui.Selectable($"{channel}", contained && output == channel)) { + // this.Plugin.ConfigInfo.ChannelChannels[id] = channel; + // anyChanged = true; + // } + // } + // + // ImGui.EndCombo(); + // } + + ImGui.PopID(); + } + } + } + + private void DrawInviteWindow() { + if (this.InviteInfo == null) { + return; + } + + var (name, world) = this.InviteInfo.Value; + + var open = true; + if (!ImGui.Begin($"Invite: {name}###ec-linkshell-invite", ref open, ImGuiWindowFlags.AlwaysAutoResize)) { + if (!open) { + this.InviteInfo = null; + } + + ImGui.End(); + return; + } + + if (!open) { + this.InviteInfo = null; + } + + if (ImGui.IsWindowAppearing()) { + ImGui.SetWindowPos(ImGui.GetMousePos()); + } + + var preview = this._inviteId == null ? "Choose a linkshell" : "???"; + if (this._inviteId != null && this.Plugin.ConfigInfo.Channels.TryGetValue(this._inviteId.Value, out var selectedInfo)) { + preview = selectedInfo.Name; + } + + if (ImGui.BeginCombo("##ec-linkshell-invite-linkshell", preview)) { + foreach (var (id, _) in this.Plugin.Client.Channels) { + if (!this.Plugin.Client.ChannelRanks.TryGetValue(id, out var rank) || rank < Rank.Moderator) { + continue; + } + + if (!this.Plugin.ConfigInfo.Channels.TryGetValue(id, out var info)) { + continue; + } + + if (ImGui.Selectable($"{info.Name}##{id}", id == this._inviteId)) { + this._inviteId = id; + } + } + + ImGui.EndCombo(); + } + + if (ImGui.Button("Invite") && this._inviteId != null) { + var id = this._inviteId.Value; + this._inviteId = null; + + Task.Run(async () => await this.Plugin.Client.InviteToast(name, world, id)); + this.InviteInfo = null; + } + + ImGui.End(); + } + + private void DrawRegistrationPanel() { + if (this.Plugin.LocalPlayer is not { } player) { + return; + } + + var state = this.Plugin.Client.Status; + if (state == Client.State.NotAuthenticated) { + if (this.Plugin.ConfigInfo.Key != null) { + ImGui.TextUnformatted("Please wait..."); + } else { + if (ImGui.Button($"Register {player.Name}") && !this._busy) { + this._busy = true; + Task.Run(async () => { + var challenge = await this.Plugin.Client.GetChallenge(); + await this._challengeChannel.Writer.WriteAsync(challenge); + }).ContinueWith(_ => this._busy = false); + } + + ImGui.PushTextWrapPos(); + ImGui.TextUnformatted("ExtraChat is a third-party service that allows for functionally unlimited extra linkshells that work across data centres."); + ImGui.TextUnformatted("In order to use ExtraChat, characters must be registered and verified using their Lodestone profile."); + ImGui.TextUnformatted("ExtraChat stores your character's name, home world, and Lodestone ID, as well as what linkshells your character is a part of and has been invited to."); + ImGui.TextUnformatted("Messages and linkshell names are end-to-end encrypted; the server cannot decrypt them and does not store messages."); + ImGui.TextUnformatted("In the event of a legal subpoena, ExtraChat will provide any information available to the legal system."); + ImGui.PopTextWrapPos(); + } + } + + if (state == Client.State.RetrievingChallenge) { + ImGui.TextUnformatted("Waiting..."); + } + + if (state == Client.State.WaitingForVerification) { + ImGui.PushTextWrapPos(); + if (this._challenge == null) { + ImGui.TextUnformatted("Waiting for verification but no challenge present. This is a bug."); + } else { + ImGui.TextUnformatted("Copy the challenge below and save it in your Lodestone profile. After saving, click the button below to verify. After successfully verifying, you can delete the challenge from your profile if desired."); + + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##challenge", ref this._challenge, (uint) this._challenge.Length, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.ReadOnly); + + if (ImGui.Button("Copy")) { + ImGui.SetClipboardText(this._challenge); + } + + ImGui.SameLine(); + + if (ImGui.Button("Open profile")) { + Process.Start(new ProcessStartInfo { + FileName = "https://na.finalfantasyxiv.com/lodestone/my/setting/profile/", + UseShellExecute = true, + }); + } + + ImGui.SameLine(); + + if (ImGui.Button("Verify") && !this._busy) { + this._busy = true; + Task.Run(async () => { + var key = await this.Plugin.Client.Register(); + this.Plugin.ConfigInfo.Key = key; + this.Plugin.SaveConfig(); + await this.Plugin.Client.AuthenticateAndList(); + }).ContinueWith(_ => this._busy = false); + } + } + + ImGui.PopTextWrapPos(); + } + } + + private Guid _selectedChannel = Guid.Empty; + private string _inviteName = string.Empty; + private ushort _inviteWorld; + private string _rename = string.Empty; + + private void DrawList() { + ImGui.PushFont(UiBuilder.IconFont); + + var syncButton = ImGui.CalcTextSize(FontAwesomeIcon.Sync.ToIconString()).X + + ImGui.GetStyle().FramePadding.X * 2; + // PluginLog.Log($"syncButton: {syncButton}"); + var addButton = ImGui.CalcTextSize(FontAwesomeIcon.Plus.ToIconString()).X + + ImGui.GetStyle().FramePadding.X * 2; + // PluginLog.Log($"addButton: {addButton}"); + var syncOffset = ImGui.GetContentRegionAvail().X - syncButton; + var addOffset = ImGui.GetContentRegionAvail().X - syncButton - ImGui.GetStyle().ItemSpacing.X - addButton; + ImGui.SameLine(syncOffset); + + if (ImGui.Button(FontAwesomeIcon.Sync.ToIconString())) { + Task.Run(async () => await this.Plugin.Client.ListAll()); + } + + ImGui.SameLine(addOffset); + + if (ImGui.Button(FontAwesomeIcon.Plus.ToIconString())) { + ImGui.OpenPopup("create-channel-popup"); + } + + ImGui.PopFont(); + + if (ImGui.BeginPopup("create-channel-popup")) { + ImGui.TextUnformatted("Create a new ExtraChat Linkshell"); + + ImGui.SetNextItemWidth(350 * ImGuiHelpers.GlobalScale); + ImGui.InputTextWithHint("##linkshell-name", "Linkshell name", ref this._createName, 64); + + if (ImGui.IsWindowAppearing()) { + ImGui.SetKeyboardFocusHere(); + } + + if (!string.IsNullOrWhiteSpace(this._createName) && ImGui.Button("Create") && !this._busy) { + this._busy = true; + var name = this._createName; + Task.Run(async () => await this.Plugin.Client.Create(name)) + .ContinueWith(_ => this._busy = false); + ImGui.CloseCurrentPopup(); + this._createName = string.Empty; + } + + ImGui.EndPopup(); + } + + if (ImGui.BeginTable("ecls-list", 2, ImGuiTableFlags.Resizable | ImGuiTableFlags.SizingFixedFit)) { + ImGui.TableSetupColumn("##channels", ImGuiTableColumnFlags.None); + ImGui.TableSetupColumn("##members", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableNextRow(); + + var channelOrder = this.Plugin.ConfigInfo.ChannelOrder.ToDictionary( + entry => entry.Value, + entry => entry.Key + ); + + var orderedChannels = this.Plugin.Client.Channels.Keys + .OrderBy(id => channelOrder.ContainsKey(id) ? channelOrder[id] : int.MaxValue) + .Concat(this.Plugin.Client.InvitedChannels.Keys); + + var childSize = new Vector2( + -1, + ImGui.GetContentRegionAvail().Y + - ImGui.GetStyle().WindowPadding.Y + - ImGui.GetStyle().ItemSpacing.Y + ); + + if (ImGui.TableSetColumnIndex(0)) { + if (ImGui.BeginChild("channel-list", childSize)) { + foreach (var id in orderedChannels) { + this.Plugin.ConfigInfo.Channels.TryGetValue(id, out var info); + var name = info?.Name ?? "???"; + + var order = "?"; + if (channelOrder.TryGetValue(id, out var o)) { + order = (o + 1).ToString(); + } + + if (!this.Plugin.Client.ChannelRanks.TryGetValue(id, out var rank)) { + rank = Rank.Member; + } + + if (ImGui.Selectable($"{order}. {rank.Symbol()}{name}###{id}", this._selectedChannel == id)) { + this._selectedChannel = id; + + Task.Run(async () => await this.Plugin.Client.ListMembers(id)); + } + + if (ImGui.BeginPopupContextItem()) { + var invited = this.Plugin.Client.InvitedChannels.ContainsKey(id); + if (invited) { + if (ImGui.Selectable("Accept invite")) { + Task.Run(async () => await this.Plugin.Client.Join(id)); + } + + if (ImGuiUtil.SelectableConfirm("Decline invite")) { + Task.Run(async () => await this.Plugin.Client.Leave(id)); + } + } else { + if (ImGuiUtil.SelectableConfirm("Leave")) { + Task.Run(async () => await this.Plugin.Client.Leave(id)); + } + + if (rank == Rank.Admin) { + if (ImGuiUtil.SelectableConfirm("Disband")) { + Task.Run(async () => { + if (await this.Plugin.Client.Disband(id) is { } error) { + this.Plugin.ShowError($"Could not disband \"{name}\": {error}"); + } + }); + } + } + + if (rank == Rank.Admin && info != null && ImGui.BeginMenu($"Rename##{id}-rename")) { + if (ImGui.IsWindowAppearing()) { + this._rename = string.Empty; + } + + ImGui.SetNextItemWidth(350 * ImGuiHelpers.GlobalScale); + ImGui.InputTextWithHint($"##{id}-rename-input", "New name", ref this._rename, 64); + + if (ImGui.IsWindowAppearing()) { + ImGui.SetKeyboardFocusHere(); + } + + if (ImGui.Button($"Rename##{id}-rename-button") && !string.IsNullOrWhiteSpace(this._rename)) { + var newName = SecretBox.Encrypt(info.SharedSecret, Encoding.UTF8.GetBytes(this._rename)); + Task.Run(async () => await this.Plugin.Client.UpdateToast(id, new UpdateKind.Name(newName))); + ImGui.CloseCurrentPopup(); + } + + ImGui.EndMenu(); + } + + if (ImGui.BeginMenu($"Invite##{id}-invite")) { + if (ImGui.IsWindowAppearing()) { + this._inviteName = string.Empty; + this._inviteWorld = 0; + } + + ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale); + ImGui.InputTextWithHint("##invite-name", "Name", ref this._inviteName, 32); + + ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale); + var preview = this._inviteWorld == 0 ? "World" : WorldUtil.WorldName(this._inviteWorld); + if (ImGui.BeginCombo("##invite-world", preview)) { + foreach (var (dc, worlds) in this._worlds) { + ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]); + ImGui.TextUnformatted(dc); + ImGui.PopStyleColor(); + ImGui.Separator(); + + foreach (var world in worlds) { + if (ImGui.Selectable(world.Name.RawString, this._inviteWorld == world.RowId)) { + this._inviteWorld = (ushort) world.RowId; + } + } + + ImGui.Spacing(); + } + + ImGui.EndCombo(); + } + + if (ImGui.Button($"Invite##{id}-invite-button") && !string.IsNullOrWhiteSpace(this._inviteName) && this._inviteWorld != 0) { + var inviteName = this._inviteName; + var inviteWorld = this._inviteWorld; + + Task.Run(async () => await this.Plugin.Client.InviteToast(inviteName, inviteWorld, id)); + } + + ImGui.EndMenu(); + } + + ImGui.Separator(); + + if (ImGui.BeginMenu("Change number")) { + ImGui.SetNextItemWidth(150 * ImGuiHelpers.GlobalScale); + channelOrder.TryGetValue(id, out var refOrder); + var old = refOrder; + refOrder += 1; + if (ImGui.InputInt($"##{id}-order", ref refOrder)) { + refOrder = Math.Max(1, refOrder) - 1; + + if (this.Plugin.ConfigInfo.ChannelOrder.TryGetValue(refOrder, out var other) && other != id) { + // another channel already has this number, so swap + this.Plugin.ConfigInfo.ChannelOrder[old] = other; + } else { + this.Plugin.ConfigInfo.ChannelOrder.Remove(old); + } + + this.Plugin.ConfigInfo.ChannelOrder[refOrder] = id; + this.Plugin.SaveConfig(); + this.Plugin.Commands.ReregisterAll(); + } + + ImGui.EndMenu(); + } + + if (info == null) { + if (ImGui.Selectable("Request secrets")) { + Task.Run(async () => await this.Plugin.Client.RequestSecrets(id)); + } + } + } + + ImGui.EndPopup(); + } + } + + ImGui.EndChild(); + } + } + + if (ImGui.TableSetColumnIndex(1) && this._selectedChannel != Guid.Empty) { + void DrawInfo() { + if (!this.Plugin.Client.TryGetChannel(this._selectedChannel, out var channel)) { + return; + } + + Vector4 disabledColour; + unsafe { + disabledColour = *ImGui.GetStyleColorVec4(ImGuiCol.TextDisabled); + } + + if (!this.Plugin.Client.ChannelRanks.TryGetValue(this._selectedChannel, out var rank)) { + rank = Rank.Member; + } + + foreach (var member in channel.Members) { + if (!member.Online) { + ImGui.PushStyleColor(ImGuiCol.Text, disabledColour); + } + + try { + ImGui.TextUnformatted($"{member.Rank.Symbol()}{member.Name}{CrossWorld}{WorldUtil.WorldName(member.World)}"); + } finally { + if (!member.Online) { + ImGui.PopStyleColor(); + } + } + + if (ImGui.BeginPopupContextItem($"{this._selectedChannel}-{member.Name}@{member.World}-context")) { + if (rank == Rank.Admin) { + if (member.Rank is not (Rank.Admin or Rank.Invited)) { + if (ImGuiUtil.SelectableConfirm("Promote to admin", tooltip: "This will demote you to moderator.")) { + Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Admin)); + } + } + + if (member.Rank == Rank.Moderator && ImGuiUtil.SelectableConfirm("Demote")) { + Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Member)); + } + + if (member.Rank == Rank.Member && ImGuiUtil.SelectableConfirm("Promote to moderator")) { + Task.Run(async () => await this.Plugin.Client.Promote(this._selectedChannel, member.Name, member.World, Rank.Moderator)); + } + } + + if (rank >= Rank.Moderator) { + var canKick = member.Rank < rank && member.Rank != Rank.Invited; + if (canKick && ImGuiUtil.SelectableConfirm("Kick")) { + Task.Run(async () => { + if (await this.Plugin.Client.Kick(this._selectedChannel, member.Name, member.World) is { } error) { + this.Plugin.ShowError($"Could not kick {member.Name}: {error}"); + } + }); + } + + if (member.Rank == Rank.Invited && ImGuiUtil.SelectableConfirm("Cancel invite")) { + Task.Run(async () => await this.Plugin.Client.Kick(this._selectedChannel, member.Name, member.World)); + } + } + + if (rank == Rank.Invited && member.Rank == Rank.Invited) { + if (member.Name == this.Plugin.LocalPlayer?.Name.TextValue && member.World == this.Plugin.LocalPlayer?.HomeWorld.Id) { + if (ImGui.Selectable("Accept invite")) { + Task.Run(async () => await this.Plugin.Client.Join(this._selectedChannel)); + } + + if (ImGuiUtil.SelectableConfirm("Decline invite")) { + Task.Run(async () => await this.Plugin.Client.Leave(this._selectedChannel)); + } + } + } + + + ImGui.EndPopup(); + } + } + } + + if (ImGui.BeginChild("channel-info", childSize)) { + DrawInfo(); + ImGui.EndChild(); + } + } + + ImGui.EndTable(); + } + } +} diff --git a/client/ExtraChat/Util/ColourUtil.cs b/client/ExtraChat/Util/ColourUtil.cs new file mode 100644 index 0000000..5d6088f --- /dev/null +++ b/client/ExtraChat/Util/ColourUtil.cs @@ -0,0 +1,118 @@ +using System.Numerics; + +namespace ExtraChat.Util; + +internal static class ColourUtil { + internal static uint RgbaToAbgr(uint rgba) { + return (rgba >> 24) // red + | ((rgba & 0x0000ff00) << 8) // blue + | ((rgba & 0x00ff0000) >> 8) // green + | ((rgba & 0x000000ff) << 24); // alpha + } + + internal static (double, double, double, double) ExplodeRgba(uint rgba) { + // separate RGBA values + var r = (byte) ((rgba >> 24) & 0xff); + var g = (byte) ((rgba >> 16) & 0xff); + var b = (byte) ((rgba >> 8) & 0xff); + var a = (byte) (rgba & 0xff); + + // convert RGBA to floats + var rf = r / 255d; + var gf = g / 255d; + var bf = b / 255d; + var af = a / 255d; + + return (rf, gf, bf, af); + } + + internal static Vector4 RgbaToHsl(uint rgba) { + var (rf, gf, bf, af) = ExplodeRgba(rgba); + + // determine hue + var max = Math.Max(rf, Math.Max(gf, bf)); + var min = Math.Min(rf, Math.Min(gf, bf)); + var chroma = max - min; + var hPrime = 0d; + if (chroma == 0) { + hPrime = 0d; + } else if (Math.Abs(rf - max) < 0.0001) { + hPrime = ((gf - bf) / chroma) % 6; + } else if (Math.Abs(gf - max) < 0.0001) { + hPrime = 2 + (bf - rf) / chroma; + } else if (Math.Abs(bf - max) < 0.0001) { + hPrime = 4 + (rf - gf) / chroma; + } + + var h = hPrime * 60f; + + // determine lightness + var l = (min + max) / 2f; + + // determine saturation + double s; + if (l is 0 or 1) { + s = 0d; + } else { + s = chroma / (1 - Math.Abs(2 * l - 1)); + } + + return new Vector4((float) h, (float) s, (float) l, (float) af); + } + + internal static Vector4 RgbaToHsv(uint rgba) { + var (rf, gf, bf, af) = ExplodeRgba(rgba); + + // determine hue + var max = Math.Max(rf, Math.Max(gf, bf)); + var min = Math.Min(rf, Math.Min(gf, bf)); + var chroma = max - min; + var hPrime = 0d; + if (chroma == 0) { + hPrime = 0d; + } else if (Math.Abs(rf - max) < 0.0001) { + hPrime = ((gf - bf) / chroma) % 6; + } else if (Math.Abs(gf - max) < 0.0001) { + hPrime = 2 + (bf - rf) / chroma; + } else if (Math.Abs(bf - max) < 0.0001) { + hPrime = 4 + (rf - gf) / chroma; + } + + var h = hPrime * 60f; + + // determine lightness + var v = max; + + // determine saturation + double s; + if (v is 0) { + s = 0d; + } else { + s = chroma / v; + } + + return new Vector4((float) h, (float) s, (float) v, (float) af); + } + + internal static double Luma(uint rgba) { + var (r, g, b, _) = ExplodeRgba(rgba); + return 0.2627 * r + 0.6780 * g + 0.0593 * b; + } + + internal static (int, int, int) Step(uint rgba) { + var (r, g, b, _) = ExplodeRgba(rgba); + var lum = Math.Sqrt(0.241 * r + 0.691 * g + 0.068 * b); + var hsv = RgbaToHsv(rgba); + const int reps = 8; + var h2 = (int) (hsv.X * reps); + var lum2 = (int) (lum * reps); + var v2 = (int) (hsv.Z * reps); + + if (h2 % 2 == 1) { + v2 = reps - v2; + lum2 = reps - lum2; + } + + return (h2, lum2, v2); + } +} diff --git a/client/ExtraChat/Util/ImGuiUtil.cs b/client/ExtraChat/Util/ImGuiUtil.cs new file mode 100644 index 0000000..a856626 --- /dev/null +++ b/client/ExtraChat/Util/ImGuiUtil.cs @@ -0,0 +1,72 @@ +using System.Text; +using Dalamud.Interface; +using ImGuiNET; + +namespace ExtraChat.Util; + +internal static class ImGuiUtil { + internal static bool IconButton(FontAwesomeIcon icon, string? id = null, string? tooltip = null) { + var label = icon.ToIconString(); + if (id != null) { + label += $"##{id}"; + } + + ImGui.PushFont(UiBuilder.IconFont); + var ret = ImGui.Button(label); + ImGui.PopFont(); + + if (tooltip != null && ImGui.IsItemHovered()) { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(tooltip); + ImGui.EndTooltip(); + } + + return ret; + } + + internal static bool SelectableConfirm(string label, ConfirmKey keys = ConfirmKey.Ctrl, string? tooltip = null) { + var selectable = ImGui.Selectable(label); + var hovered = ImGui.IsItemHovered(); + + var confirmHeld = true; + var mods = hovered ? new StringBuilder() : null; + foreach (var key in Enum.GetValues()) { + if (!keys.HasFlag(key)) { + continue; + } + + if (hovered) { + if (mods!.Length != 0) { + mods.Append('+'); + } + + mods.Append(key.ToString()); + } + + var held = key switch { + ConfirmKey.Ctrl => ImGui.GetIO().KeyCtrl, + ConfirmKey.Alt => ImGui.GetIO().KeyAlt, + ConfirmKey.Shift => ImGui.GetIO().KeyShift, + _ => false, + }; + confirmHeld &= held; + } + + if (!confirmHeld && hovered) { + ImGui.BeginTooltip(); + var explainer = $"Hold {mods} to enable this option."; + var tip = tooltip == null ? explainer : $"{tooltip}\n{explainer}"; + ImGui.TextUnformatted(tip); + ImGui.EndTooltip(); + } + + return selectable && confirmHeld; + } +} + +[Flags] +internal enum ConfirmKey { + Ctrl = 1 << 0, + Alt = 1 << 1, + Shift = 1 << 2, +} diff --git a/client/ExtraChat/Util/SecretBox.cs b/client/ExtraChat/Util/SecretBox.cs new file mode 100644 index 0000000..10662fa --- /dev/null +++ b/client/ExtraChat/Util/SecretBox.cs @@ -0,0 +1,20 @@ +using ASodium; + +namespace ExtraChat.Util; + +internal static class SecretBox { + internal static byte[] Encrypt(byte[] key, byte[] bytes) { + var nonce = SodiumSecretBoxXChaCha20Poly1305.GenerateNonce(); + var ciphertext = SodiumSecretBoxXChaCha20Poly1305.Create(bytes, nonce, key); + return nonce.Concat(ciphertext); + } + + internal static byte[] Decrypt(byte[] key, byte[] bytes) { + var nonceLength = SodiumSecretBoxXChaCha20Poly1305.GetNonceBytesLength(); + + var nonce = bytes[..nonceLength]; + var ciphertext = bytes[nonceLength..]; + + return SodiumSecretBoxXChaCha20Poly1305.Open(ciphertext, nonce, key); + } +} diff --git a/client/ExtraChat/Util/WorldUtil.cs b/client/ExtraChat/Util/WorldUtil.cs new file mode 100644 index 0000000..deade5d --- /dev/null +++ b/client/ExtraChat/Util/WorldUtil.cs @@ -0,0 +1,29 @@ +using Dalamud.Data; +using Lumina.Excel.GeneratedSheets; + +namespace ExtraChat.Util; + +internal static class WorldUtil { + private static readonly Dictionary WorldNames = new(); + + internal static void Initialise(DataManager data) { + WorldNames.Clear(); + + var worlds = data.GetExcelSheet(); + if (worlds == null) { + return; + } + + foreach (var world in worlds) { + if (!world.IsPublic) { + continue; + } + + WorldNames[(ushort) world.RowId] = world.Name.RawString; + } + } + + internal static string WorldName(ushort id) { + return WorldNames.TryGetValue(id, out var name) ? name : "???"; + } +} diff --git a/server/.gitignore b/server/.gitignore new file mode 100755 index 0000000..bd7b9d8 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,7 @@ +/target +/config.toml +/.env +/database.sqlite +/database.sqlite-shm +/database.sqlite-wal +/extrachat.log diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100755 index 0000000..8d580d9 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,2348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom 0.2.7", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704" + +[[package]] +name = "atoi" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" + +[[package]] +name = "bumpalo" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "serde", + "time", + "winapi", +] + +[[package]] +name = "clipboard-win" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3e1238132dc01f081e1cbb9dace14e5ef4c3a51ee244bd982275fb514605db" +dependencies = [ + "error-code", + "str-buf", + "winapi", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cpufeatures" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff" + +[[package]] +name = "crossbeam-queue" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "crypto-common" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5999502d32b9c48d492abe66392408144895020ec4709e549e840799f3bb74c0" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfae75de57f2b2e85e8768c3ea840fd159c8f33e2b6522c7835b7abac81be16e" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dtoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" + +[[package]] +name = "dtoa-short" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03329ae10e79ede66c9ce4dc930aa8599043b0743008548680f25b91502d6" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a68a4904193147e0a8dec3314640e6db742afd5f6e634f428a6af230d9b3591" + +[[package]] +name = "either" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" + +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "error-code" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] + +[[package]] +name = "event-listener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" + +[[package]] +name = "extra-chat-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "fern", + "futures-util", + "hex", + "lazy_static", + "lodestone-scraper", + "log", + "parking_lot 0.12.1", + "prefixed-api-key", + "rand 0.8.5", + "regex", + "rmp-serde", + "rustyline", + "serde", + "serde_bytes", + "serde_repr", + "sha3", + "sqlx", + "tokio", + "tokio-tungstenite", + "toml", + "uuid", +] + +[[package]] +name = "fd-lock" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11dcc7e4d79a8c89b9ab4c6f5c30b1fc4a83c420792da3542fd31179ed5f517" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys", +] + +[[package]] +name = "fern" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdd7b0849075e79ee9a1836df22c717d1eba30451796fdc631b04565dd11e2a" +dependencies = [ + "log", +] + +[[package]] +name = "ffxiv_types" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7a29aa9d99731170ba7bbe3c02f15bdd92bb5be3f192f897f66ffbbffe176" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "flume" +version = "0.10.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ceeb589a3157cac0ab8cc585feb749bd2cea5cb55a6ee802ad72d9fd38303da" +dependencies = [ + "futures-core", + "futures-sink", + "pin-project", + "spin 0.9.3", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" + +[[package]] +name = "futures-executor" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.11.2", +] + +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" + +[[package]] +name = "futures-task" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" + +[[package]] +name = "futures-util" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "h2" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "http" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.2", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42dc3c131584288d375f2d07f822b0cb012d8c6fb899a5b9fdb3cb7eb9b6004f" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa 1.0.2", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" +dependencies = [ + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c3f4eff5495aee4c0399d7b6a0dc2b6e81be84242ffbfcf253ebacccc1d0cb" + +[[package]] +name = "ipnet" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" + +[[package]] +name = "itertools" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" + +[[package]] +name = "js-sys" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b7d56ba4a8344d6be9729995e6b06f928af29998cdf79fe390cbf6b1fee838" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" + +[[package]] +name = "libsqlite3-sys" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898745e570c7d0453cc1fbc4a701eb6c662ed54e8fec8b7d14be137ebeeb9d14" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d2456c373231a208ad294c33dc5bff30051eafd954cd4caae83a712b12854d" + +[[package]] +name = "lock_api" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lodestone-parser" +version = "1.0.0" +source = "git+https://git.annaclemens.io/ascclemens/lodestone-parser.git#7e88d7d1c34ffc6cff385356a909058c87569870" +dependencies = [ + "chrono", + "cssparser", + "ffxiv_types", + "lazy_static", + "scraper", + "serde", + "thiserror", + "url", +] + +[[package]] +name = "lodestone-scraper" +version = "1.0.0" +source = "git+https://git.annaclemens.io/ascclemens/lodestone-scraper.git#368fcfdb8c2bbd05c4eaa2cf7719112f4f3a3bae" +dependencies = [ + "ffxiv_types", + "lazy_static", + "lodestone-parser", + "reqwest", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f866317acbd3a240710c63f065ffb1e4fd466259045ccb504130b7f668f35c6" +dependencies = [ + "bitflags", + "cc", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.3", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + +[[package]] +name = "paste" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78203e83c48cffbe01e4a2d35d566ca4de445d79a85372fc64e378bfc812a260" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710faf75e1b33345361201d36d04e98ac1ed8909151a017ed384700836104c74" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prefixed-api-key" +version = "0.1.0" +source = "git+https://git.annaclemens.io/ascclemens/prefixed-api-key.git#60d5041959a376ec639fca5fdc636b91a11973a8" +dependencies = [ + "bs58", + "rand 0.8.5", + "thiserror", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + +[[package]] +name = "proc-macro2" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom 0.2.7", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "redox_syscall" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + +[[package]] +name = "reqwest" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75aa69a3f06bbcc66ede33af2af253c6f7a86b1ca0033f60c580a27074fbf92" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "lazy_static", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rmp" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44519172358fd6d58656c86ab8e7fbc9e1490c3e8f14d35ed78ca0dd07403c9f" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25786b0d276110195fa3d6f3f31299900cf71dfbd6c28450f3f58a0e7f7a347e" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.35.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51cc38aa10f6bbb377ed28197aa052aa4e2b762c22be9d3153d01822587e787" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.20.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" +dependencies = [ + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7522c9de787ff061458fe9a829dc790a3f5b22dc571694fc5883f448b94d9a9" +dependencies = [ + "base64", +] + +[[package]] +name = "rustyline" +version = "9.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7826789c0e25614b03e5a54a0717a86f9ff6e6e5247f92b369472869320039" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "scopeguard", + "smallvec", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "winapi", +] + +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "scraper" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5684396b456f3eb69ceeb34d1b5cb1a2f6acf7ca4452131efa3ba0ee2c2d0a70" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "matches", + "selectors", + "smallvec", + "tendril", +] + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", + "thin-slice", +] + +[[package]] +name = "semver" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2333e6df6d6598f2b1974829f853c2b4c5f4a6e503c10af918081aa6f8564e1" + +[[package]] +name = "serde" +version = "1.0.138" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1578c6245786b9d168c5447eeacfb96856573ca56c9d68fdcf394be134882a47" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212e73464ebcde48d723aa02eb270ba62eff38a9b732df31f33f1b4e145f3a54" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.138" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023e9b1467aef8a10fb88f25611870ada9800ef7e22afce356bb0d2387b6f27c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" +dependencies = [ + "itoa 1.0.2", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ad84e47328a31223de7fed7a4f5087f2d6ddfe586cf3ca25b7a165bc0a5aed" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.2", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha-1" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881bf8156c87b6301fc5ca6b27f11eeb2761224c7081e69b409d5a1951a70c86" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + +[[package]] +name = "slab" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" + +[[package]] +name = "smallvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" + +[[package]] +name = "socket2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c530c2b0d0bf8b69304b39fe2001993e267461948b890cd037d8ad4293fa1a0d" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlformat" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f82cbe94f41641d6c410ded25bbf5097c240cefdf8e3b06d04198d0a96af6a4" +dependencies = [ + "sqlx-core", + "sqlx-macros", +] + +[[package]] +name = "sqlx-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b69bf218860335ddda60d6ce85ee39f6cf6e5630e300e19757d1de15886a093" +dependencies = [ + "ahash", + "atoi", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "hashlink", + "hex", + "indexmap", + "itoa 1.0.2", + "libc", + "libsqlite3-sys", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "sha2", + "smallvec", + "sqlformat", + "sqlx-rt", + "stringprep", + "thiserror", + "tokio-stream", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40c63177cf23d356b159b60acd27c54af7423f1736988502e36bae9a712118f" +dependencies = [ + "dotenv", + "either", + "heck", + "once_cell", + "proc-macro2", + "quote", + "sha2", + "sqlx-core", + "sqlx-rt", + "syn", + "url", +] + +[[package]] +name = "sqlx-rt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874e93a365a598dc3dadb197565952cb143ae4aa716f7bcc933a8d836f6bf89f" +dependencies = [ + "once_cell", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "str-buf" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" + +[[package]] +name = "string_cache" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213494b7a2b503146286049378ce02b482200519accc31872ee8be91fa820a08" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot 0.12.1", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "syn" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + +[[package]] +name = "thiserror" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51a52ed6686dd62c320f9b89299e9dfb46f730c7a48e635c19f21d116cb1439" +dependencies = [ + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "once_cell", + "pin-project-lite", + "socket2", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-macros" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls", + "tokio", + "webpki", +] + +[[package]] +name = "tokio-stream" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06cda1232a49558c46f8a504d5b93101d42c0bf7f911f12a105ba48168f821ae" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7358be39f2f274f322d2aaed611acc57f382e8eb1e5b48cb9ae30933495ce7" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "tungstenite" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96a2dea40e7570482f28eb57afbe42d97551905da6a9400acc5c328d24004f5" +dependencies = [ + "base64", + "byteorder", + "bytes", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha-1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-ident" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" + +[[package]] +name = "unicode-normalization" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" + +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" + +[[package]] +name = "uuid" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" +dependencies = [ + "getrandom 0.2.7", + "serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de9a9cec1733468a8c657e57fa2413d2ae2c0129b95e87c5b72b8ace4d13f31f" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be" + +[[package]] +name = "web-sys" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d8de8415c823c8abd270ad483c6feeac771fad964890779f9a8cb24fbbc1bf" +dependencies = [ + "webpki", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..bd6252e --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "extra-chat-server" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1" +chrono = "0.4" +fern = "0.6" +futures-util = "0.3" +hex = "0.4" +lazy_static = "1" +lodestone-scraper = { git = "https://git.annaclemens.io/ascclemens/lodestone-scraper.git" } +log = "0.4" +parking_lot = "0.12" +prefixed-api-key = { git = "https://git.annaclemens.io/ascclemens/prefixed-api-key.git" } +rand = "0.8" +regex = "1" +rmp-serde = "1" +rustyline = { version = "9", default-features = false } +serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" +serde_repr = "0.1" +sha3 = "0.10" +#sodiumoxide = "0.2" +sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "sqlite", "chrono"] } +tokio-tungstenite = "0.17" +toml = "0.5" +uuid = { version = "1", features = ["serde", "v4"] } + +[dependencies.tokio] +version = "1" +features = ["rt-multi-thread", "macros", "sync"] diff --git a/server/config.example.toml b/server/config.example.toml new file mode 100644 index 0000000..04252dd --- /dev/null +++ b/server/config.example.toml @@ -0,0 +1,5 @@ +[server] +address = '0.0.0.0:8080' + +[database] +path = './database.sqlite' diff --git a/server/migrations/.gitkeep b/server/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/migrations/1_initial_schema.sql b/server/migrations/1_initial_schema.sql new file mode 100644 index 0000000..f436d63 --- /dev/null +++ b/server/migrations/1_initial_schema.sql @@ -0,0 +1,45 @@ +create table users +( + lodestone_id unsigned bigint not null primary key, + name text not null, + world text not null, + key_short text not null, + key_hash text not null +); + +create table verifications +( + lodestone_id unsigned bigint not null primary key, + challenge text not null, + created_at timestamp not null default current_timestamp +); + +create table channels +( + id text not null primary key, + name blob not null +); + +create table user_channels +( + lodestone_id unsigned bigint not null references users (lodestone_id) on delete cascade, + channel_id text not null references channels (id) on delete cascade, + rank tinyint not null, + + primary key (lodestone_id, channel_id) +); + +create index user_channels_lodestone_id_idx on user_channels (lodestone_id); +create index user_channels_channel_id_idx on user_channels (channel_id); + +create table channel_invites +( + channel_id text not null references channels (id) on delete cascade, + invited unsigned bigint not null references users (lodestone_id) on delete cascade, + inviter unsigned bigint not null references users (lodestone_id) on delete cascade, + + primary key (channel_id, invited) +); + +create index channel_invites_channel_id_idx on channel_invites (channel_id); +create index channel_invites_channel_id_invited_idx on channel_invites (channel_id, invited); diff --git a/server/migrations/2_caching.sql b/server/migrations/2_caching.sql new file mode 100644 index 0000000..11cc242 --- /dev/null +++ b/server/migrations/2_caching.sql @@ -0,0 +1,3 @@ +-- add a column so we can cache login lodestone requests +alter table users + add column last_updated timestamp not null default 0; diff --git a/server/migrations/3_additional_indexes.sql b/server/migrations/3_additional_indexes.sql new file mode 100644 index 0000000..3902b05 --- /dev/null +++ b/server/migrations/3_additional_indexes.sql @@ -0,0 +1,3 @@ +create index users_name_world_idx on users (name, world); +create index users_key_short_key_hash_idx on users (key_short, key_hash); +create index channel_invites_invited_idx on channel_invites (invited); diff --git a/server/src/handlers/authenticate.rs b/server/src/handlers/authenticate.rs new file mode 100644 index 0000000..a27ecdf --- /dev/null +++ b/server/src/handlers/authenticate.rs @@ -0,0 +1,86 @@ +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::Context; +use chrono::{Duration, Utc}; +use lodestone_scraper::LodestoneScraper; +use log::trace; +use tokio::sync::RwLock; + +use crate::{AuthenticateRequest, AuthenticateResponse, ClientState, State, User, util, World, WsStream}; + +pub async fn authenticate(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: AuthenticateRequest) -> anyhow::Result<()> { + if client_state.read().await.user.is_some() { + util::send(conn, number, AuthenticateResponse::error("already logged in")).await?; + return Ok(()); + } + + let key = prefixed_api_key::parse(&*req.key) + .context("could not parse key")?; + let hash = util::hash_key(&key); + let user = sqlx::query!( + // language=sqlite + "select * from users where key_short = ? and key_hash = ?", + key.short_token, + hash, + ) + .fetch_optional(&state.read().await.db) + .await + .context("could not query database for user")?; + let mut user = match user { + Some(u) => u, + None => { + util::send(conn, number, AuthenticateResponse::error("invalid key")).await?; + return Ok(()); + } + }; + + if Utc::now().naive_utc().signed_duration_since(user.last_updated) >= Duration::hours(2) { + let info = LodestoneScraper::default() + .character(user.lodestone_id as u64) + .await + .context("could not get character info")?; + let world_name = info.world.as_str(); + + user.name = info.name.clone(); + user.world = world_name.to_string(); + + sqlx::query!( + // language=sqlite + "update users set name = ?, world = ?, last_updated = current_timestamp where lodestone_id = ?", + info.name, + world_name, + user.lodestone_id, + ) + .execute(&state.read().await.db) + .await + .context("could not update user")?; + } + + let world = World::from_str(&user.world).map_err(|_| anyhow::anyhow!("invalid world in db"))?; + + trace!(" [authenticate] before user write"); + let mut c_state = client_state.write().await; + c_state.user = Some(User { + lodestone_id: user.lodestone_id as u64, + name: user.name.clone(), + world, + hash, + }); + + c_state.pk = req.pk.into_inner(); + + // release lock asap + drop(c_state); + trace!(" [authenticate] after user write"); + + trace!(" [authenticate] before state write 1"); + state.write().await.clients.insert(user.lodestone_id as u64, Arc::clone(&client_state)); + trace!(" [authenticate] before state write 2"); + state.write().await.ids.insert((user.name, util::id_from_world(world)), user.lodestone_id as u64); + trace!(" [authenticate] after state writes"); + + util::send(conn, number, AuthenticateResponse::success()).await?; + + Ok(()) +} diff --git a/server/src/handlers/create.rs b/server/src/handlers/create.rs new file mode 100644 index 0000000..d16223c --- /dev/null +++ b/server/src/handlers/create.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::{ClientState, ErrorResponse, State, WsStream}; +use crate::types::protocol::{CreateRequest, CreateResponse}; +use crate::types::protocol::channel::{Channel, Rank}; + +pub async fn create(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: CreateRequest) -> Result<()> { + let id = Uuid::new_v4(); + let id_str = id.as_simple().to_string(); + + sqlx::query!( + // language=sqlite + "insert into channels (id, name) values (?, ?)", + id_str, + req.name, + ) + .execute(&state.read().await.db) + .await + .context("could not create channel")?; + + let lodestone_id = client_state.read().await.user.as_ref().map(|u| u.lodestone_id as i64).unwrap_or(0); + if lodestone_id == 0 { + // should not be possible + return Ok(()); + } + + let rank = Rank::Admin.as_u8(); + sqlx::query!( + // language=sqlite + "insert into user_channels (lodestone_id, channel_id, rank) values (?, ?, ?)", + lodestone_id, + id_str, + rank, + ) + .execute(&state.read().await.db) + .await + .context("could not add user to channel")?; + + let channel = match Channel::get(&state, id).await? { + Some(c) => c, + None => { + return crate::util::send(conn, number, ErrorResponse::new(None, "could not get newly-created channel")).await; + } + }; + + crate::util::send(conn, number, CreateResponse { + channel, + }).await?; + + Ok(()) +} diff --git a/server/src/handlers/disband.rs b/server/src/handlers/disband.rs new file mode 100644 index 0000000..d1a3713 --- /dev/null +++ b/server/src/handlers/disband.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, Rank, State, WsStream}; +use crate::types::protocol::{DisbandRequest, DisbandResponse}; +use crate::util::send; + +pub async fn disband(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: DisbandRequest) -> Result<()> { + match client_state.read().await.get_rank(req.channel, &state).await? { + Some(rank) if rank == Rank::Admin => {} + _ => return send(conn, number, ErrorResponse::new(req.channel, "not in channel/not enough permissions")).await, + } + + crate::util::send_to_all(&state, req.channel, 0, DisbandResponse { + channel: req.channel, + }).await?; + + let channel_id_str = req.channel.as_simple().to_string(); + sqlx::query!( + // language=sqlite + "delete from channels where id = ?", + channel_id_str, + ) + .execute(&state.read().await.db) + .await + .context("could not disband channel")?; + + send(conn, number, DisbandResponse { + channel: req.channel, + }).await +} diff --git a/server/src/handlers/invite.rs b/server/src/handlers/invite.rs new file mode 100644 index 0000000..0b6993f --- /dev/null +++ b/server/src/handlers/invite.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, ResponseContainer, State, WsStream}; +use crate::types::protocol::{InvitedResponse, InviteRequest, InviteResponse, MemberChangeKind, MemberChangeResponse, ResponseKind}; +use crate::types::protocol::channel::{Channel, Rank}; + +pub async fn invite(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: InviteRequest) -> Result<()> { + let user = match &client_state.read().await.user { + Some(u) => u.clone(), + None => return Ok(()), + }; + let lodestone_id = user.lodestone_id as i64; + + let rank = match client_state.read().await.get_rank(req.channel, &state).await? { + Some(r) => r, + None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, "not in channel")).await, + }; + + if rank < Rank::Moderator { + return crate::util::send(conn, number, ErrorResponse::new(req.channel, "not enough permissions to invite")).await; + } + + let target_id = match state.read().await.ids.get(&(req.name.clone(), req.world)) { + Some(id) => *id, + None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, "user not online")).await, + }; + let target_id_i = target_id as i64; + + if target_id_i == lodestone_id { + return crate::util::send(conn, number, ErrorResponse::new(req.channel, "cannot invite self")).await; + } + + let channel_id = req.channel.as_simple().to_string(); + // check for existing membership + let membership = sqlx::query!( + // language=sqlite + "select count(*) as count from user_channels where channel_id = ? and lodestone_id = ?", + channel_id, + target_id_i, + ) + .fetch_one(&state.read().await.db) + .await + .context("could not query database for membership")?; + + if membership.count > 0 { + return crate::util::send(conn, number, ErrorResponse::new(req.channel, "already in channel")).await; + } + + // check for existing invite + let invite = sqlx::query!( + // language=sqlite + "select count(*) as count from channel_invites where channel_id = ? and invited = ?", + channel_id, + target_id_i, + ) + .fetch_one(&state.read().await.db) + .await + .context("could not query database for invite")?; + + if invite.count > 0 { + return crate::util::send(conn, number, ErrorResponse::new(req.channel, "already invited")).await; + } + + crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse { + channel: req.channel, + name: req.name.clone(), + world: req.world, + kind: MemberChangeKind::Invite { + inviter: user.name, + inviter_world: crate::util::id_from_world(user.world), + }, + }).await?; + + sqlx::query!( + // language=sqlite + "insert into channel_invites (channel_id, invited, inviter) values (?, ?, ?)", + channel_id, + target_id_i, + lodestone_id, + ) + .execute(&state.read().await.db) + .await + .context("could not add invite")?; + + // inviter's info + let pk = client_state.read().await.pk.clone(); + let (name, world) = match &client_state.read().await.user { + Some(c) => (c.name.clone(), c.world), + None => return Ok(()), + }; + + // send invite to invitee + match state.read().await.clients.get(&target_id) { + Some(c) => { + let channel = Channel::get(&state, req.channel) + .await + .context("could not get channel")? + .context("no such channel")?; + c.read().await.tx.send(ResponseContainer { + number: 0, + kind: ResponseKind::Invited(InvitedResponse { + channel, + name, + world: crate::util::id_from_world(world), + pk: pk.into(), + encrypted_secret: req.encrypted_secret, + }), + }).await?; + } + None => return crate::util::send(conn, number, ErrorResponse::new(req.channel, "user not online")).await, + } + + crate::util::send(conn, number, InviteResponse { + channel: req.channel, + name: req.name, + world: req.world, + }).await +} diff --git a/server/src/handlers/join.rs b/server/src/handlers/join.rs new file mode 100644 index 0000000..89320d7 --- /dev/null +++ b/server/src/handlers/join.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, State, WsStream}; +use crate::types::protocol::{JoinRequest, JoinResponse, MemberChangeKind, MemberChangeResponse}; +use crate::types::protocol::channel::{Channel, Rank}; +use crate::util::send; + +pub async fn join(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: JoinRequest) -> Result<()> { + let user = match &client_state.read().await.user { + Some(user) => user.clone(), + None => return Ok(()), + }; + let lodestone_id = user.lodestone_id as i64; + + let channel_id = req.channel.as_simple().to_string(); + let invite = sqlx::query!( + // language=sqlite + "delete from channel_invites where channel_id = ? and invited = ? returning *", + channel_id, + lodestone_id, + ) + .fetch_optional(&state.read().await.db) + .await + .context("failed to fetch invite")?; + + if invite.is_none() { + return send(conn, number, ErrorResponse::new(req.channel, "you were not invited to that channel")).await; + } + + crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse { + channel: req.channel, + name: user.name, + world: crate::util::id_from_world(user.world), + kind: MemberChangeKind::Join, + }).await?; + + let rank = Rank::Member.as_u8(); + sqlx::query!( + // language=sqlite + "insert into user_channels (lodestone_id, channel_id, rank) values (?, ?, ?)", + lodestone_id, + channel_id, + rank, + ) + .execute(&state.read().await.db) + .await + .context("failed to add user to channel")?; + + let channel = Channel::get(&state, req.channel) + .await + .context("failed to get channel")? + .context("no such channel")?; + + send(conn, number, JoinResponse { + channel, + }).await +} diff --git a/server/src/handlers/kick.rs b/server/src/handlers/kick.rs new file mode 100644 index 0000000..6389f5d --- /dev/null +++ b/server/src/handlers/kick.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, State, WsStream}; +use crate::types::protocol::{KickRequest, KickResponse, MemberChangeKind, MemberChangeResponse}; +use crate::types::protocol::channel::Rank; +use crate::util::send; + +pub async fn kick(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: KickRequest) -> Result<()> { + let user = match &client_state.read().await.user { + Some(user) => user.clone(), + None => return Ok(()), + }; + + let rank = match client_state.read().await.get_rank(req.channel, &state).await? { + Some(rank) if rank >= Rank::Moderator => rank, + _ => return send(conn, number, ErrorResponse::new(req.channel, "not in channel/not enough permissions")).await, + }; + + let target_id = match state.read().await.get_id(&state, &req.name, req.world).await { + Some(id) => id, + None => return send(conn, number, ErrorResponse::new(req.channel, "user not found")).await, + }; + let target_id_i = target_id as i64; + + let channel_id_str = req.channel.as_simple().to_string(); + let target_rank: Option = sqlx::query!( + // language=sqlite + "select rank from user_channels where channel_id = ? and lodestone_id = ?", + channel_id_str, + target_id_i, + ) + .fetch_optional(&state.read().await.db) + .await + .context("could not query database for rank")? + .map(|row| (row.rank as u8).into()); + + match target_rank { + Some(target) if target >= rank => { + return send(conn, number, ErrorResponse::new(req.channel, "cannot kick someone of equal or higher rank")).await; + } + None if !crate::util::is_invited(&state, req.channel, target_id).await? => { + return send(conn, number, ErrorResponse::new(req.channel, "user not in channel")).await; + } + _ => {} + } + + let is_invited = target_rank.is_none(); + + let kind = if is_invited { + MemberChangeKind::InviteCancel { + canceler: user.name, + canceler_world: crate::util::id_from_world(user.world), + } + } else { + MemberChangeKind::Kick { + kicker: user.name, + kicker_world: crate::util::id_from_world(user.world), + } + }; + + crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse { + channel: req.channel, + name: req.name.clone(), + world: req.world, + kind, + }).await?; + + if is_invited { + sqlx::query!( + // language=sqlite + "delete from channel_invites where channel_id = ? and invited = ?", + channel_id_str, + target_id_i, + ) + .execute(&state.read().await.db) + .await + .context("could not delete invite")?; + } else { + sqlx::query!( + // language=sqlite + "delete from user_channels where channel_id = ? and lodestone_id = ?", + channel_id_str, + target_id_i, + ) + .execute(&state.read().await.db) + .await + .context("could not kick user")?; + } + + send(conn, number, KickResponse { + channel: req.channel, + name: req.name.clone(), + world: req.world, + }).await +} diff --git a/server/src/handlers/leave.rs b/server/src/handlers/leave.rs new file mode 100644 index 0000000..f792bd9 --- /dev/null +++ b/server/src/handlers/leave.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, Rank, State, types::protocol::{ + LeaveRequest, + LeaveResponse, +}, util::send, WsStream}; +use crate::types::protocol::{MemberChangeKind, MemberChangeResponse}; + +pub async fn leave(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: LeaveRequest) -> Result<()> { + let user = match &client_state.read().await.user { + Some(user) => user.clone(), + None => return Ok(()), + }; + let lodestone_id = user.lodestone_id as i64; + + let channel_id = req.channel.as_simple().to_string(); + let rank = match client_state.read().await.get_rank(req.channel, &state).await? { + Some(rank) => rank, + None => { + let is_invited = sqlx::query!( + // language=sqlite + "select count(*) as count from channel_invites where channel_id = ? and invited = ?", + channel_id, + lodestone_id, + ) + .fetch_one(&state.read().await.db) + .await + .context("could not get channel members")? + .count > 0; + + if is_invited { + Rank::Invited + } else { + return send(conn, number, ErrorResponse::new(req.channel, "not in that channel")).await; + } + } + }; + + let is_decline = rank == Rank::Invited; + + let users: i32 = sqlx::query!( + // language=sqlite + "select count(*) as count from user_channels where channel_id = ?", + channel_id, + ) + .fetch_one(&state.read().await.db) + .await + .context("failed to get user count")? + .count; + + // if the leaving user is an admin and there's more than one user, + // the admin must promote someone before they can leave + if users > 1 && rank == Rank::Admin { + return send(conn, number, LeaveResponse::error(req.channel, "you must promote someone to admin before leaving")).await; + } + + // if there's only one user and this isn't an invite decline, we can + // handle all the logic just with cascade deletes + if users == 1 && !is_decline { + sqlx::query!( + // language=sqlite + "delete from channels where id = ?", + channel_id, + ) + .execute(&state.read().await.db) + .await + .context("failed to delete channel")?; + + return send(conn, number, LeaveResponse::success(req.channel)).await; + } + + let kind = if is_decline { + sqlx::query!( + // language=sqlite + "delete from channel_invites where channel_id = ? and invited = ?", + channel_id, + lodestone_id, + ) + .execute(&state.read().await.db) + .await + .context("failed to remove invite")?; + + MemberChangeKind::InviteDecline + } else { + sqlx::query!( + // language=sqlite + "delete from user_channels where lodestone_id = ? and channel_id = ?", + lodestone_id, + channel_id, + ) + .execute(&state.read().await.db) + .await + .context("failed to remove user from channel")?; + + MemberChangeKind::Leave + }; + + crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse { + channel: req.channel, + name: user.name, + world: crate::util::id_from_world(user.world), + kind, + }).await?; + + send(conn, number, LeaveResponse::success(req.channel)).await +} diff --git a/server/src/handlers/list.rs b/server/src/handlers/list.rs new file mode 100644 index 0000000..9c148ef --- /dev/null +++ b/server/src/handlers/list.rs @@ -0,0 +1,159 @@ +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::{ClientState, State, types::protocol::{ + channel::{ + Channel, + ChannelMember, + Rank, + SimpleChannel, + }, + ListRequest, + ListResponse, +}, util::send, World, WsStream}; +use crate::util::RawMember; + +pub async fn list(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: ListRequest) -> Result<()> { + let lodestone_id = match &client_state.read().await.user { + Some(u) => u.lodestone_id, + None => return Ok(()), + }; + + let resp = match req { + ListRequest::All => ListResponse::All { + channels: get_full_channels(lodestone_id, &state).await?, + invites: get_full_invites(lodestone_id, &state).await?, + }, + ListRequest::Channels => ListResponse::Channels(get_channels(lodestone_id, &state).await?), + ListRequest::Members(id) => ListResponse::Members { + id, + members: get_members(lodestone_id, &state, id).await?, + }, + ListRequest::Invites => ListResponse::Invites(get_invites(lodestone_id, &state).await?), + }; + + send(conn, number, resp).await +} + +async fn ids_to_channels(ids: &[&str], state: &RwLock) -> Vec { + let mut channels = Vec::with_capacity(ids.len()); + for id in ids { + let id = match Uuid::from_str(id) { + Ok(id) => id, + Err(_) => continue, + }; + + let channel = match Channel::get(state, id).await { + Ok(Some(channel)) => channel, + _ => continue, + }; + + channels.push(channel); + } + + channels +} + +async fn get_full_channels(lodestone_id: u64, state: &RwLock) -> Result> { + let lodestone_id_i = lodestone_id as i64; + let channel_ids = sqlx::query!( + // language=sqlite + "select channel_id from user_channels where lodestone_id = ?", + lodestone_id_i, + ) + .fetch_all(&state.read().await.db) + .await + .context("failed to fetch channel ids")?; + + let ids: Vec<&str> = channel_ids + .iter() + .map(|id| id.channel_id.as_str()) + .collect(); + Ok(ids_to_channels(&ids, state).await) +} + +async fn get_full_invites(lodestone_id: u64, state: &RwLock) -> Result> { + let lodestone_id_i = lodestone_id as i64; + let channel_ids = sqlx::query!( + // language=sqlite + "select channel_id from channel_invites where invited = ?", + lodestone_id_i, + ) + .fetch_all(&state.read().await.db) + .await + .context("failed to fetch channel ids")?; + + let ids: Vec<&str> = channel_ids + .iter() + .map(|id| id.channel_id.as_str()) + .collect(); + Ok(ids_to_channels(&ids, state).await) +} + +async fn get_channels(lodestone_id: u64, state: &RwLock) -> Result> { + SimpleChannel::get_all_for_user(state, lodestone_id) + .await + .context("could not get channels for user") +} + +async fn get_members(lodestone_id: u64, state: &RwLock, channel_id: Uuid) -> Result> { + let lodestone_id_i = lodestone_id as i64; + + let channel_id_str = channel_id.as_simple().to_string(); + let users: Vec = sqlx::query_as!( + RawMember, + // language=sqlite + "select users.lodestone_id, users.name, users.world, user_channels.rank from user_channels inner join users on users.lodestone_id = user_channels.lodestone_id where user_channels.channel_id = ?", + channel_id_str, + ) + .fetch_all(&state.read().await.db) + .await + .context("failed to get members")?; + + let invited: Vec = sqlx::query_as!( + RawMember, + // language=sqlite + "select users.lodestone_id, users.name, users.world, cast(0 as int) as rank from channel_invites inner join users on users.lodestone_id = channel_invites.invited where channel_invites.channel_id = ?", + channel_id_str, + ) + .fetch_all(&state.read().await.db) + .await + .context("failed to get invited members")?; + + let mut found = false; + let mut members = Vec::with_capacity(users.len()); + for user in users.into_iter().chain(invited.into_iter()) { + if user.lodestone_id == lodestone_id_i { + found = true; + } + + let world = match World::from_str(&user.world) { + Ok(world) => world, + Err(_) => continue, + }; + + let online = state.read().await.clients.contains_key(&(user.lodestone_id as u64)); + members.push(ChannelMember { + name: user.name, + world: crate::util::id_from_world(world), + rank: Rank::from_u8(user.rank as u8), + online, + }); + } + + if !found { + anyhow::bail!("user not in channel"); + } + + Ok(members) +} + +async fn get_invites(lodestone_id: u64, state: &RwLock) -> Result> { + SimpleChannel::get_invites_for_user(state, lodestone_id) + .await + .context("could not get channels for user") +} diff --git a/server/src/handlers/message.rs b/server/src/handlers/message.rs new file mode 100644 index 0000000..cdfbea6 --- /dev/null +++ b/server/src/handlers/message.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, MessageRequest, MessageResponse, ResponseContainer, State, util, WsStream}; +use crate::types::protocol::ResponseKind; +use crate::util::send; + +pub async fn message(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: MessageRequest) -> Result<()> { + let (lodestone_id, sender, world) = match &client_state.read().await.user { + Some(u) => (u.lodestone_id, u.name.clone(), u.world), + None => return Ok(()), + }; + + let id = req.channel.as_simple().to_string(); + let members = sqlx::query!( + // language=sqlite + "select lodestone_id from user_channels where channel_id = ?", + id, + ) + .fetch_all(&state.read().await.db) + .await + .context("could not query database for members")?; + + let in_channel = members + .iter() + .any(|m| m.lodestone_id as u64 == lodestone_id); + if !in_channel { + return send(conn, number, ErrorResponse::new(req.channel, "not in channel")).await; + } + + state.read().await.messages_sent.fetch_add(1, Ordering::SeqCst); + + let resp = ResponseContainer { + number: 0, + kind: ResponseKind::Message(MessageResponse { + channel: req.channel, + sender, + world: util::id_from_world(world), + message: req.message, + }), + }; + + for member in members { + let client = match state.read().await.clients.get(&(member.lodestone_id as u64)).cloned() { + Some(c) => c, + None => continue, + }; + + client.read().await.tx.send(resp.clone()).await.ok(); + } + + Ok(()) +} diff --git a/server/src/handlers/mod.rs b/server/src/handlers/mod.rs new file mode 100644 index 0000000..3a9d931 --- /dev/null +++ b/server/src/handlers/mod.rs @@ -0,0 +1,38 @@ +pub use self::{ + authenticate::*, + create::*, + disband::*, + invite::*, + join::*, + kick::*, + leave::*, + list::*, + message::*, + ping::*, + promote::*, + public_key::*, + register::*, + secrets::*, + send_secrets::*, + update::*, + version::*, +}; + +pub mod authenticate; +pub mod create; +pub mod disband; +pub mod invite; +pub mod join; +pub mod kick; +pub mod leave; +pub mod list; +pub mod message; +pub mod ping; +pub mod promote; +pub mod public_key; +pub mod register; +pub mod secrets; +pub mod send_secrets; +pub mod update; +pub mod version; + diff --git a/server/src/handlers/ping.rs b/server/src/handlers/ping.rs new file mode 100644 index 0000000..1c6bd7c --- /dev/null +++ b/server/src/handlers/ping.rs @@ -0,0 +1,8 @@ +use anyhow::Result; + +use crate::WsStream; +use crate::types::protocol::PingResponse; + +pub async fn ping(conn: &mut WsStream, number: u32) -> Result<()> { + crate::util::send(conn, number, PingResponse {}).await +} diff --git a/server/src/handlers/promote.rs b/server/src/handlers/promote.rs new file mode 100644 index 0000000..c9ff214 --- /dev/null +++ b/server/src/handlers/promote.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, State, WsStream}; +use crate::types::protocol::{MemberChangeResponse, PromoteRequest, PromoteResponse}; +use crate::types::protocol::channel::Rank; +use crate::types::protocol::MemberChangeKind; +use crate::util::send; + +pub async fn promote(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: PromoteRequest) -> Result<()> { + let user = match &client_state.read().await.user { + Some(user) => user.clone(), + None => return Ok(()), + }; + let lodestone_id = user.lodestone_id; + let lodestone_id_i = lodestone_id as i64; + + let rank = match client_state.read().await.get_rank(req.channel, &state).await? { + Some(rank) if rank == Rank::Admin => rank, + _ => return send(conn, number, ErrorResponse::new(req.channel, "not in channel/not enough permissions")).await, + }; + + if req.rank == Rank::Invited { + return send(conn, number, ErrorResponse::new(req.channel, "cannot change rank to invited")).await; + } + + let target_id = match state.read().await.get_id(&state, &req.name, req.world).await { + Some(id) => id, + None => return send(conn, number, ErrorResponse::new(req.channel, "user not found")).await, + }; + let target_id_i = target_id as i64; + + if target_id == lodestone_id { + return send(conn, number, ErrorResponse::new(req.channel, "cannot change own rank")).await; + } + + let channel_id_str = req.channel.as_simple().to_string(); + let target_rank = sqlx::query!( + // language=sqlite + "select rank from user_channels where channel_id = ? and lodestone_id = ?", + channel_id_str, + target_id_i, + ) + .fetch_optional(&state.read().await.db) + .await + .context("could not query database for rank")?; + + match target_rank { + Some(target) if target.rank >= rank.as_u8() as i64 => { + return send(conn, number, ErrorResponse::new(req.channel, "cannot change rank of someone of equal or higher rank")).await; + } + None => return send(conn, number, ErrorResponse::new(req.channel, "user not in channel")).await, + _ => {} + } + + let swap = req.rank == Rank::Admin; + + // change the rank + let new_rank = req.rank.as_u8() as i64; + sqlx::query!( + // language=sqlite + "update user_channels set rank = ? where channel_id = ? and lodestone_id = ?", + new_rank, + channel_id_str, + target_id_i, + ) + .execute(&state.read().await.db) + .await + .context("could not update user rank")?; + + if swap { + // lower own rank + let new_rank = Rank::Moderator.as_u8() as i64; + sqlx::query!( + // language=sqlite + "update user_channels set rank = ? where channel_id = ? and lodestone_id = ?", + new_rank, + channel_id_str, + lodestone_id_i, + ) + .execute(&state.read().await.db) + .await + .context("could not update user rank")?; + + crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse { + channel: req.channel, + name: user.name, + world: crate::util::id_from_world(user.world), + kind: MemberChangeKind::Promote { + rank: Rank::Moderator, + }, + }).await?; + } + + crate::util::send_to_all(&state, req.channel, 0, MemberChangeResponse { + channel: req.channel, + name: req.name.clone(), + world: req.world, + kind: MemberChangeKind::Promote { + rank: req.rank, + }, + }).await?; + + send(conn, number, PromoteResponse { + channel: req.channel, + name: req.name, + world: req.world, + rank: req.rank, + }).await +} diff --git a/server/src/handlers/public_key.rs b/server/src/handlers/public_key.rs new file mode 100644 index 0000000..fa34648 --- /dev/null +++ b/server/src/handlers/public_key.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use anyhow::Result; +use tokio::sync::RwLock; + +use crate::{State, WsStream}; +use crate::types::protocol::{PublicKeyRequest, PublicKeyResponse}; +use crate::util::redacted::Redacted; + +pub async fn public_key(state: Arc>, conn: &mut WsStream, number: u32, req: PublicKeyRequest) -> Result<()> { + let id = match state.read().await.ids.get(&(req.name.clone(), req.world)) { + Some(id) => *id, + None => { + crate::util::send(conn, number, PublicKeyResponse { + name: req.name, + world: req.world, + pk: None, + }).await?; + return Ok(()); + } + }; + + let pk = match state.read().await.clients.get(&id) { + Some(client) => Some(client.read().await.pk.clone()), + None => None, + }; + crate::util::send(conn, number, PublicKeyResponse { + name: req.name, + world: req.world, + pk: pk.map(Redacted), + }).await?; + + Ok(()) +} diff --git a/server/src/handlers/register.rs b/server/src/handlers/register.rs new file mode 100644 index 0000000..287eb0c --- /dev/null +++ b/server/src/handlers/register.rs @@ -0,0 +1,135 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use lodestone_scraper::LodestoneScraper; +use rand::RngCore; +use tokio::sync::RwLock; + +use crate::{ClientState, RegisterRequest, RegisterResponse, State, util::{hash_key, send, world_from_id}, WsStream}; + +pub async fn register(state: Arc>, _client_state: Arc>, conn: &mut WsStream, number: u32, req: RegisterRequest) -> Result<()> { + let scraper = LodestoneScraper::default(); + + let world = world_from_id(req.world) + .context("invalid world id")?; + + // look up character + let character = scraper.character_search() + .name(&req.name) + .world(world) + .send() + .await? + .results + .into_iter() + .find(|c| c.name == req.name && Some(c.world) == world_from_id(req.world)) + .context("could not find character")?; + let lodestone_id = character.id as i64; + + // get challenge + let challenge: Option<_> = sqlx::query!( + // language=sqlite + "select challenge, created_at from verifications where lodestone_id = ?", + lodestone_id, + ) + .fetch_optional(&state.read().await.db) + .await + .context("could not query database for verification")?; + + if !req.challenge_completed || challenge.is_none() { + let generate = match &challenge { + Some(r) if Utc::now().signed_duration_since(DateTime::::from_utc(r.created_at, Utc)) > Duration::minutes(5) => { + // set up a challenge if one hasn't been set up in the last five minutes + true + } + Some(_) => { + // challenge already exists, send back existing one + false + } + None => true, + }; + + let challenge = match &challenge { + None | Some(_) if generate => { + let mut rand_bytes = [0; 32]; + rand::thread_rng().fill_bytes(&mut rand_bytes); + let challenge = hex::encode(&rand_bytes); + + sqlx::query!( + // language=sqlite + "delete from verifications where lodestone_id = ?", + lodestone_id, + ) + .execute(&state.read().await.db) + .await?; + + sqlx::query!( + // language=sqlite + "insert into verifications (lodestone_id, challenge) values (?, ?)", + lodestone_id, + challenge, + ) + .execute(&state.read().await.db) + .await?; + + challenge + } + Some(r) => r.challenge.clone(), + None => unreachable!(), + }; + + send(conn, number, RegisterResponse::Challenge { + challenge, + }).await?; + return Ok(()); + } + + // verify challenge + let challenge = match challenge { + Some(c) => c, + // should not be possible + None => return Ok(()), + }; + + let chara_info = scraper.character(character.id) + .await + .context("could not get character info")?; + let verified = chara_info.profile_text.contains(&challenge.challenge); + + if !verified { + send(conn, number, RegisterResponse::Failure).await?; + return Ok(()); + } + + sqlx::query!( + // language=sqlite + "delete from verifications where lodestone_id = ?", + lodestone_id, + ) + .execute(&state.read().await.db) + .await + .context("could not remove verification")?; + + let key = prefixed_api_key::generate("extrachat", None); + let hash = hash_key(&key); + + let world_name = character.world.as_str(); + sqlx::query!( + // language=sqlite + "insert or replace into users (lodestone_id, name, world, key_short, key_hash, last_updated) values (?, ?, ?, ?, ?, current_timestamp)", + lodestone_id, + character.name, + world_name, + key.short_token, + hash, + ) + .execute(&state.read().await.db) + .await + .context("could not insert user")?; + + send(conn, number, RegisterResponse::Success { + key: key.to_string().into(), + }).await?; + + Ok(()) +} diff --git a/server/src/handlers/secrets.rs b/server/src/handlers/secrets.rs new file mode 100644 index 0000000..adc2669 --- /dev/null +++ b/server/src/handlers/secrets.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use anyhow::Result; +use rand::seq::SliceRandom; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::{ClientState, ErrorResponse, ResponseContainer, State, WsStream}; +use crate::types::protocol::{ResponseKind, SecretsRequest, SendSecretsResponse}; +use crate::util::send; + +#[derive(Clone)] +pub struct SecretsRequestInfo { + pub lodestone_id: u64, + pub channel_id: Uuid, + pub number: u32, +} + +pub async fn secrets(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: SecretsRequest) -> Result<()> { + if client_state.read().await.get_rank_invite(req.channel, &state).await?.is_none() { + return send(conn, number, ErrorResponse::new(req.channel, "not in that channel")).await; + } + + let lodestone_id = match client_state.read().await.lodestone_id() { + Some(lodestone_id) => lodestone_id, + None => return Ok(()), + }; + + let all_members = crate::util::get_raw_members(&state, req.channel).await? + .into_iter() + .chain(crate::util::get_raw_invited_members(&state, req.channel).await?.into_iter()); + let mut members = Vec::new(); + for member in all_members { + let id = member.lodestone_id as u64; + if id != lodestone_id && state.read().await.clients.contains_key(&id) { + members.push(member); + } + } + + if members.is_empty() { + return send(conn, number, ErrorResponse::new(req.channel, "no other online members")).await; + } + + // because I am lazy + // ask 10% of the online members for their secrets + // take the first one + + let mut amount = (members.len() as f32 / 10.0).round() as usize; + if amount == 0 { + amount = 1; + } + + let members: Vec<_> = members.choose_multiple(&mut rand::thread_rng(), amount).collect(); + if members.is_empty() { + return send(conn, number, ErrorResponse::new(req.channel, "no online members found")).await; + } + + let request_id = Uuid::new_v4(); + state.write().await.secrets_requests.insert(request_id, SecretsRequestInfo { + lodestone_id, + channel_id: req.channel, + number, + }); + + let pk = client_state.read().await.pk.clone(); + + for member in members { + let target_client = match state.read().await.clients.get(&(member.lodestone_id as u64)).cloned() { + Some(client) => client, + None => continue, + }; + + target_client.read().await.tx.send(ResponseContainer { + number: 0, + kind: ResponseKind::SendSecrets(SendSecretsResponse { + channel: req.channel, + request_id, + pk: pk.clone().into(), + }), + }).await?; + } + + Ok(()) +} diff --git a/server/src/handlers/send_secrets.rs b/server/src/handlers/send_secrets.rs new file mode 100644 index 0000000..d34577d --- /dev/null +++ b/server/src/handlers/send_secrets.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, ResponseContainer, State, WsStream}; +use crate::types::protocol::{ResponseKind, SecretsResponse, SendSecretsRequest}; +use crate::util::send; + +pub async fn send_secrets(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: SendSecretsRequest) -> Result<()> { + let encrypted = match req.encrypted_shared_secret { + Some(encrypted) if !encrypted.is_empty() => encrypted, + _ => return Ok(()), + }; + + let info = match state.read().await.secrets_requests.get(&req.request_id).cloned() { + Some(info) => info, + None => return Ok(()), + }; + + if client_state.read().await.get_rank_invite(info.channel_id, &state).await?.is_none() { + return send(conn, number, ErrorResponse::new(info.channel_id, "not in that channel")).await; + } + + state.write().await.secrets_requests.remove(&req.request_id); + + let requester = match state.read().await.clients.get(&info.lodestone_id).cloned() { + Some(requester) => requester, + None => return Ok(()), + }; + + requester.read().await.tx.send(ResponseContainer { + number: info.number, + kind: ResponseKind::Secrets(SecretsResponse { + channel: info.channel_id, + pk: client_state.read().await.pk.clone().into(), + encrypted_shared_secret: encrypted, + }), + }).await.context("failed to send secrets response")?; + + Ok(()) +} diff --git a/server/src/handlers/update.rs b/server/src/handlers/update.rs new file mode 100644 index 0000000..24cdcf6 --- /dev/null +++ b/server/src/handlers/update.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::sync::RwLock; + +use crate::{ClientState, ErrorResponse, Rank, State, WsStream}; +use crate::types::protocol::{UpdatedResponse, UpdateKind, UpdateRequest, UpdateResponse}; +use crate::util::send; + +pub async fn update(state: Arc>, client_state: Arc>, conn: &mut WsStream, number: u32, req: UpdateRequest) -> Result<()> { + match client_state.read().await.get_rank(req.channel, &state).await? { + Some(rank) if rank == Rank::Admin => {} + _ => return send(conn, number, ErrorResponse::new(req.channel, "not in that channel")).await, + } + + let channel_id_str = req.channel.as_simple().to_string(); + match &req.kind { + UpdateKind::Name(name) => { + sqlx::query!( + // language=sqlite + "update channels set name = ? where id = ?", + name, + channel_id_str, + ) + .execute(&state.read().await.db) + .await + .context("could not update name")?; + } + } + + crate::util::send_to_all(&state, req.channel, 0, UpdatedResponse { + channel: req.channel, + kind: req.kind, + }).await?; + + send(conn, number, UpdateResponse { + channel: req.channel, + }).await +} diff --git a/server/src/handlers/version.rs b/server/src/handlers/version.rs new file mode 100644 index 0000000..95c78d7 --- /dev/null +++ b/server/src/handlers/version.rs @@ -0,0 +1,24 @@ +use anyhow::Result; + +use crate::{ + ErrorResponse, + types::protocol::{ + VersionRequest, + VersionResponse, + }, + util::send, + WsStream, +}; + +pub async fn version(conn: &mut WsStream, number: u32, req: VersionRequest) -> Result { + if req.version != 1 { + send(conn, number, ErrorResponse::new(None, "unsupported version")).await?; + return Ok(false); + } + + send(conn, number, VersionResponse { + version: 1, + }).await?; + + Ok(true) +} diff --git a/server/src/logging.rs b/server/src/logging.rs new file mode 100644 index 0000000..bafd69f --- /dev/null +++ b/server/src/logging.rs @@ -0,0 +1,47 @@ +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use log::{Level, LevelFilter}; +use parking_lot::RwLock; +use regex::Regex; + +lazy_static! { + pub static ref LOG_LEVEL: RwLock = RwLock::new(Level::Info); + static ref KEY_REGEX: Regex = Regex::new(r#"extrachat_[1-9A-HJ-NP-Za-km-z]+_[1-9A-HJ-NP-Za-km-z]+"#).unwrap(); +} + +pub fn setup() -> Result<()> { + fern::Dispatch::new() + .filter(|metadata| { + match metadata.target() { + "extra_chat_server" | "sqlx" => true, + x if x.starts_with("extra_chat_server::") => true, + x if x.starts_with("sqlx::") => true, + _ => false, + } + }) + .format(|out, message, record| { + let message = format!("{}", message); + let message = KEY_REGEX.replace_all(&message, "[redacted]"); + + out.finish(format_args!( + "[{}][{}][{}:{}] {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z"), + record.level(), + record.file().unwrap_or("?"), + record.line().unwrap_or(0), + message, + )) + }) + .chain(fern::Dispatch::new() + .filter(|meta| { + meta.level() <= *LOG_LEVEL.read() + }) + .chain(std::io::stdout()) + ) + .chain(fern::Dispatch::new() + .level(LevelFilter::Trace) + .chain(fern::log_file("extrachat.log")?) + ) + .apply() + .context("could not set up logging facility") +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..bfb9a3f --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,443 @@ +#![feature(try_blocks)] + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use lodestone_scraper::lodestone_parser::ffxiv_types::World; +use log::{debug, error, info, Level, LevelFilter, warn}; +use sha3::Digest; +use sqlx::{ConnectOptions, Executor, Pool, Sqlite}; +use sqlx::migrate::Migrator; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use tokio::{ + net::{TcpListener, TcpStream}, +}; +use tokio::sync::mpsc::Sender; +use tokio::sync::RwLock; +use tokio_tungstenite::{ + tungstenite::Message as WsMessage, + WebSocketStream, +}; +use uuid::Uuid; + +use crate::{ + types::{ + protocol::{ + MessageRequest, + MessageResponse, + RegisterRequest, + RegisterResponse, + RequestContainer, + RequestKind, + ResponseContainer, + }, + user::User, + }, +}; +use crate::handlers::SecretsRequestInfo; +use crate::types::config::Config; +use crate::types::protocol::{AnnounceResponse, AuthenticateRequest, AuthenticateResponse, ErrorResponse, ResponseKind}; +use crate::types::protocol::channel::Rank; + +pub mod types; +pub mod handlers; +pub mod util; +pub mod updater; +pub mod logging; + +pub type WsStream = WebSocketStream; + +pub struct State { + pub db: Pool, + pub clients: HashMap>>, + pub ids: HashMap<(String, u16), u64>, + pub secrets_requests: HashMap, + pub messages_sent: AtomicU64, +} + +impl State { + pub async fn announce(&self, msg: impl Into) { + let msg = msg.into(); + + for client in self.clients.values() { + client.read().await.tx.send(ResponseContainer { + number: 0, + kind: ResponseKind::Announce(AnnounceResponse::new(&msg)), + }).await.ok(); + } + } + + pub async fn get_id(&self, state: &RwLock, name: &str, world: u16) -> Option { + // if they're logged in, grab the id the easy way + if let Some(id) = self.ids.get(&(name.to_string(), world)).copied() { + return Some(id); + } + + let world_name = util::world_from_id(world)?.as_str(); + let id = sqlx::query!( + // language=sqlite + "select lodestone_id from users where name = ? and world = ?", + name, + world_name, + ) + .fetch_optional(&state.read().await.db) + .await + .ok()?; + + id.map(|id| id.lodestone_id as u64) + } +} + +static MIGRATOR: Migrator = sqlx::migrate!(); + +#[tokio::main] +async fn main() -> Result<()> { + logging::setup()?; + + // get config + let config_path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string()); + let config_toml = std::fs::read_to_string(config_path) + .context("couldn't read config file")?; + let config: Config = toml::from_str(&config_toml) + .context("couldn't parse config file")?; + + // set up database pool + let mut options = SqliteConnectOptions::new(); + options.log_statements(LevelFilter::Debug); + + let pool = SqlitePoolOptions::new() + .after_connect(|conn, _| Box::pin(async move { + conn.execute( + // language=sqlite + "PRAGMA foreign_keys = ON;" + ).await?; + Ok(()) + })) + .connect_with(options.filename(&config.database.path)) + .await + .context("could not connect to database")?; + MIGRATOR.run(&pool) + .await + .context("could not run database migrations")?; + + // set up server + let server = TcpListener::bind(&config.server.address).await?; + let state = Arc::new(RwLock::new(State { + db: pool, + clients: Default::default(), + ids: Default::default(), + secrets_requests: Default::default(), + messages_sent: AtomicU64::default(), + })); + + info!("Listening on ws://{}/", server.local_addr()?); + + let (quit_tx, mut quit_rx) = tokio::sync::mpsc::channel(1); + let (announce_tx, mut announce_rx) = tokio::sync::mpsc::channel(1); + + std::thread::spawn(move || { + let mut editor = rustyline::Editor::<()>::new(); + for line in editor.iter("> ") { + let line = match line { + Ok(l) => l, + Err(rustyline::error::ReadlineError::Interrupted) => { + quit_tx.blocking_send(()).ok(); + return; + } + Err(e) => { + error!("error reading input: {:#?}", e); + continue; + } + }; + + let command: Vec<_> = line.splitn(2, ' ').collect(); + match command[0] { + "exit" | "quit" => { + quit_tx.blocking_send(()).ok(); + return; + } + "announce" => { + if command.len() == 2 { + let msg = command[1].to_string(); + announce_tx.blocking_send(msg).ok(); + } else { + info!("usage: announce "); + } + } + "log" | "level" => { + if command.len() == 2 { + match Level::from_str(command[1]) { + Ok(level) => *logging::LOG_LEVEL.write() = level, + Err(_) => warn!("invalid log level"), + } + } else { + info!("usage: log "); + } + } + "" => {} + x => warn!("unknown command: {}", x), + } + } + }); + + { + let state = Arc::clone(&state); + tokio::task::spawn(async move { + loop { + info!( + "Clients: {}, messages sent: {}", + state.read().await.clients.len(), + state.read().await.messages_sent.load(Ordering::SeqCst), + ); + tokio::time::sleep(Duration::from_secs(60)).await; + } + }); + } + + updater::spawn(Arc::clone(&state)); + + loop { + let res: Result<()> = try { + tokio::select! { + accept = server.accept() => { + let (sock, _addr) = accept?; + let state = Arc::clone(&state); + tokio::task::spawn(async move { + let conn = match tokio_tungstenite::accept_async(sock).await { + Ok(c) => c, + Err(e) => { + error!("client error: {:?}", e); + return; + } + }; + + if let Err(e) = client_loop(state, conn).await { + error!("client error: {}", e); + } + }); + } + _ = quit_rx.recv() => { + break; + } + msg = announce_rx.recv() => { + if let Some(msg) = msg { + state.read().await.announce(msg).await; + } + } + } + }; + + if let Err(e) = res { + error!("server error: {}", e); + } + } + + info!("quitting"); + Ok(()) +} + +pub struct ClientState { + user: Option, + tx: Sender, + pk: Vec, +} + +impl ClientState { + pub fn lodestone_id(&self) -> Option { + self.user.as_ref().map(|u| u.lodestone_id) + } + + pub async fn in_channel(&self, channel_id: Uuid, state: &RwLock) -> Result { + let user = match &self.user { + Some(user) => user, + None => return Ok(false), + }; + + let channel_id_str = channel_id.as_simple().to_string(); + let id = user.lodestone_id as i64; + let members = sqlx::query!( + // language=sqlite + "select count(*) as count from user_channels where channel_id = ? and lodestone_id = ?", + channel_id_str, + id, + ) + .fetch_one(&state.read().await.db) + .await + .context("could not get count")?; + + Ok(members.count > 0) + } + + pub async fn get_rank(&self, channel_id: Uuid, state: &RwLock) -> Result> { + let user = match &self.user { + Some(user) => user, + None => return Ok(None), + }; + + let channel_id_str = channel_id.as_simple().to_string(); + let id = user.lodestone_id as i64; + let rank = sqlx::query!( + // language=sqlite + "select rank from user_channels where channel_id = ? and lodestone_id = ?", + channel_id_str, + id, + ) + .fetch_optional(&state.read().await.db) + .await + .context("could not get rank")?; + + Ok(rank.map(|rank| Rank::from_u8(rank.rank as u8))) + } + + pub async fn get_rank_invite(&self, channel_id: Uuid, state: &RwLock) -> Result> { + if let Some(rank) = self.get_rank(channel_id, state).await? { + return Ok(Some(rank)); + } + + let user = match &self.user { + Some(user) => user, + None => return Ok(None), + }; + + let channel_id_str = channel_id.as_simple().to_string(); + let id = user.lodestone_id as i64; + let count = sqlx::query!( + // language=sqlite + "select count(*) as count from channel_invites where channel_id = ? and invited = ?", + channel_id_str, + id, + ) + .fetch_one(&state.read().await.db) + .await + .context("could not get count")? + .count; + + if count > 0 { + Ok(Some(Rank::Invited)) + } else { + Ok(None) + } + } +} + +async fn client_loop(state: Arc>, mut conn: WsStream) -> Result<()> { + let (tx, mut rx) = tokio::sync::mpsc::channel(10); + + let client_state = Arc::new(RwLock::new(ClientState { + user: None, + tx, + pk: Default::default(), + })); + + loop { + let res: Result<()> = try { + tokio::select! { + msg = rx.recv() => { + if let Some(msg) = msg { + let encoded = rmp_serde::to_vec(&msg)?; + conn.send(WsMessage::Binary(encoded)).await?; + } + } + msg = conn.next() => { + // match &msg { + // Some(Ok(WsMessage::Pong(_))) => {}, + // _ => debug!("{:?}", msg), + // } + + match msg { + Some(Ok(WsMessage::Binary(msg))) => { + let msg: RequestContainer = rmp_serde::from_slice(&msg)?; + debug!("{:#?}", msg); + + let logged_in = client_state.read().await.user.is_some(); + + match msg.kind { + RequestKind::Ping(_) => { + crate::handlers::ping(&mut conn, msg.number).await?; + } + RequestKind::Version(req) => { + if !crate::handlers::version(&mut conn, msg.number, req).await? { + break; + } + } + RequestKind::Register(req) => { + crate::handlers::register(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Authenticate(req) => { + crate::handlers::authenticate(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Create(req) if logged_in => { + crate::handlers::create(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::PublicKey(req) if logged_in => { + crate::handlers::public_key(Arc::clone(&state), &mut conn, msg.number, req).await?; + } + RequestKind::Invite(req) if logged_in => { + crate::handlers::invite(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Join(req) if logged_in => { + crate::handlers::join(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Message(req) if logged_in => { + crate::handlers::message(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::List(req) if logged_in => { + crate::handlers::list(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Leave(req) if logged_in => { + crate::handlers::leave(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Promote(req) if logged_in => { + crate::handlers::promote(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Kick(req) if logged_in => { + crate::handlers::kick(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Disband(req) if logged_in => { + crate::handlers::disband(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Update(req) if logged_in => { + crate::handlers::update(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::Secrets(req) if logged_in => { + crate::handlers::secrets(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + RequestKind::SendSecrets(req) if logged_in => { + crate::handlers::send_secrets(Arc::clone(&state), Arc::clone(&client_state), &mut conn, msg.number, req).await?; + } + _ if !logged_in => { + util::send(&mut conn, msg.number, ErrorResponse::new(None, "not logged in")).await?; + } + _ => { + util::send(&mut conn, msg.number, ErrorResponse::new(None, "not yet implemented")).await?; + } + } + } + None | Some(Ok(WsMessage::Close(_))) | Some(Err(_)) => { + debug!("break"); + break; + } + _ => {} + } + } + } + }; + + if let Err(e) = res { + error!("error in client loop: {:#?}", e); + break; + } + } + + if let Some(user) = &client_state.read().await.user { + state.write().await.clients.remove(&user.lodestone_id); + state.write().await.ids.remove(&(user.name.clone(), util::id_from_world(user.world))); + } + + Ok(()) +} diff --git a/server/src/types/config.rs b/server/src/types/config.rs new file mode 100644 index 0000000..8277995 --- /dev/null +++ b/server/src/types/config.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Config { + pub server: Server, + pub database: Database, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Server { + pub address: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Database { + pub path: String, +} diff --git a/server/src/types/mod.rs b/server/src/types/mod.rs new file mode 100644 index 0000000..ce74f07 --- /dev/null +++ b/server/src/types/mod.rs @@ -0,0 +1,3 @@ +pub mod protocol; +pub mod user; +pub mod config; diff --git a/server/src/types/protocol/announce.rs b/server/src/types/protocol/announce.rs new file mode 100644 index 0000000..04f5e28 --- /dev/null +++ b/server/src/types/protocol/announce.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnnounceResponse { + pub announcement: String, +} + +impl AnnounceResponse { + pub fn new(announcement: impl Into) -> Self { + Self { + announcement: announcement.into(), + } + } +} diff --git a/server/src/types/protocol/authenticate.rs b/server/src/types/protocol/authenticate.rs new file mode 100644 index 0000000..fa71ace --- /dev/null +++ b/server/src/types/protocol/authenticate.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use crate::util::redacted::Redacted; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthenticateRequest { + pub key: Redacted, + #[serde(with = "serde_bytes")] + pub pk: Redacted>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthenticateResponse { + pub error: Option, +} + +impl AuthenticateResponse { + pub fn success() -> Self { + Self { + error: None, + } + } + + pub fn error(error: impl Into) -> Self { + Self { + error: Some(error.into()), + } + } +} diff --git a/server/src/types/protocol/channel.rs b/server/src/types/protocol/channel.rs new file mode 100644 index 0000000..aa898dc --- /dev/null +++ b/server/src/types/protocol/channel.rs @@ -0,0 +1,180 @@ +use std::str::FromStr; + +use anyhow::{Context, Result}; +use futures_util::StreamExt; +use lodestone_scraper::lodestone_parser::ffxiv_types::World; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::State; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Channel { + pub id: Uuid, + #[serde(with = "serde_bytes")] + pub name: Vec, + pub members: Vec, +} + +impl Channel { + pub async fn get(state: &RwLock, id: Uuid) -> Result> { + let id_str = id.as_simple().to_string(); + let raw_channel = sqlx::query!( + // language=sqlite + "select * from channels where id = ?", + id_str, + ) + .fetch_optional(&state.read().await.db) + .await + .context("could not get channel info")?; + + let raw_channel = match raw_channel { + Some(channel) => channel, + None => return Ok(None), + }; + + let members: Vec<_> = futures_util::stream::iter(crate::util::get_raw_members(state, id).await? + .into_iter() + .chain(crate::util::get_raw_invited_members(state, id).await?.into_iter())) + .then(|member| async move { + ChannelMember { + name: member.name, + world: World::from_str(&member.world).map(crate::util::id_from_world).unwrap_or(0), + rank: Rank::from_u8(member.rank as u8), + online: state.read().await.clients.contains_key(&(member.lodestone_id as u64)), + } + }) + .collect() + .await; + + let id = Uuid::from_str(&raw_channel.id) + .context("invalid channel id")?; + + Ok(Some(Self { + id, + name: raw_channel.name, + members, + })) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelMember { + pub name: String, + pub world: u16, + pub rank: Rank, + pub online: bool, +} + +#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +#[repr(u8)] +pub enum Rank { + Invited = 0, + Member = 1, + Moderator = 2, + Admin = 3, +} + +impl Rank { + pub fn from_u8(u: u8) -> Self { + match u { + 0 => Self::Invited, + 1 => Self::Member, + 2 => Self::Moderator, + 3 => Self::Admin, + _ => Rank::Member, + } + } + + pub fn as_u8(self) -> u8 { + match self { + Self::Invited => 0, + Self::Member => 1, + Self::Moderator => 2, + Self::Admin => 3, + } + } +} + +impl From for Rank { + fn from(u: u8) -> Self { + Rank::from_u8(u) + } +} + +impl From for u8 { + fn from(r: Rank) -> Self { + r.as_u8() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimpleChannel { + pub id: Uuid, + #[serde(with = "serde_bytes")] + pub name: Vec, + pub rank: Rank, +} + +impl SimpleChannel { + pub async fn get_all_for_user(state: &RwLock, lodestone_id: u64) -> Result> { + let lodestone_id_i = lodestone_id as i64; + + let all_channels = sqlx::query!( + // language=sqlite + "select channels.*, user_channels.rank from user_channels inner join channels on user_channels.channel_id = channels.id where user_channels.lodestone_id = ?", + lodestone_id_i, + ) + .fetch_all(&state.read().await.db) + .await + .context("could not get channels")?; + + let mut channels = Vec::with_capacity(all_channels.len()); + for channel in all_channels { + let id = match Uuid::from_str(&channel.id) { + Ok(u) => u, + Err(_) => continue, + }; + + channels.push(Self { + id, + name: channel.name, + rank: Rank::from_u8(channel.rank as u8), + }); + } + + Ok(channels) + } + + pub async fn get_invites_for_user(state: &RwLock, lodestone_id: u64) -> Result> { + let lodestone_id_i = lodestone_id as i64; + + let all_channels = sqlx::query!( + // language=sqlite + "select channels.* from channel_invites inner join channels on channel_invites.channel_id = channels.id where channel_invites.invited = ?", + lodestone_id_i, + ) + .fetch_all(&state.read().await.db) + .await + .context("could not get channels")?; + + let mut channels = Vec::with_capacity(all_channels.len()); + for channel in all_channels { + let id = match Uuid::from_str(&channel.id) { + Ok(u) => u, + Err(_) => continue, + }; + + channels.push(Self { + id, + name: channel.name, + rank: Rank::Member, + }); + } + + Ok(channels) + } +} diff --git a/server/src/types/protocol/container.rs b/server/src/types/protocol/container.rs new file mode 100644 index 0000000..ff2bbb2 --- /dev/null +++ b/server/src/types/protocol/container.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::protocol::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestContainer { + pub number: u32, + pub kind: RequestKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestKind { + Ping(PingRequest), + Version(VersionRequest), + Register(RegisterRequest), + Authenticate(AuthenticateRequest), + Message(MessageRequest), + Create(CreateRequest), + Disband(DisbandRequest), + Invite(InviteRequest), + Join(JoinRequest), + Leave(LeaveRequest), + Kick(KickRequest), + List(ListRequest), + Promote(PromoteRequest), + Update(UpdateRequest), + PublicKey(PublicKeyRequest), + Secrets(SecretsRequest), + SendSecrets(SendSecretsRequest), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseContainer { + pub number: u32, + pub kind: ResponseKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseKind { + Ping(PingResponse), + Version(VersionResponse), + Register(RegisterResponse), + Authenticate(AuthenticateResponse), + Message(MessageResponse), + Error(ErrorResponse), + Create(CreateResponse), + Disband(DisbandResponse), + Invite(InviteResponse), + Invited(InvitedResponse), + Join(JoinResponse), + Leave(LeaveResponse), + Kick(KickResponse), + List(ListResponse), + Promote(PromoteResponse), + Update(UpdateResponse), + Updated(UpdatedResponse), + PublicKey(PublicKeyResponse), + MemberChange(MemberChangeResponse), + Secrets(SecretsResponse), + SendSecrets(SendSecretsResponse), + Announce(AnnounceResponse), +} + +macro_rules! request_container { + ($name:ident, $request:ty) => { + impl From<$request> for RequestKind { + fn from(request: $request) -> Self { + RequestKind::$name(request) + } + } + }; +} + +request_container!(Ping, PingRequest); +request_container!(Version, VersionRequest); +request_container!(Register, RegisterRequest); +request_container!(Authenticate, AuthenticateRequest); +request_container!(Message, MessageRequest); +request_container!(Create, CreateRequest); +request_container!(Disband, DisbandRequest); +request_container!(Invite, InviteRequest); +request_container!(Join, JoinRequest); +request_container!(Leave, LeaveRequest); +request_container!(Kick, KickRequest); +request_container!(List, ListRequest); +request_container!(Promote, PromoteRequest); +request_container!(Update, UpdateRequest); +request_container!(PublicKey, PublicKeyRequest); +request_container!(Secrets, SecretsRequest); +request_container!(SendSecrets, SendSecretsRequest); + +macro_rules! response_container { + ($name:ident, $response:ty) => { + impl From<$response> for ResponseKind { + fn from(response: $response) -> Self { + ResponseKind::$name(response) + } + } + }; +} + +response_container!(Ping, PingResponse); +response_container!(Version, VersionResponse); +response_container!(Register, RegisterResponse); +response_container!(Authenticate, AuthenticateResponse); +response_container!(Message, MessageResponse); +response_container!(Error, ErrorResponse); +response_container!(Create, CreateResponse); +response_container!(Disband, DisbandResponse); +response_container!(Invite, InviteResponse); +response_container!(Invited, InvitedResponse); +response_container!(Join, JoinResponse); +response_container!(Leave, LeaveResponse); +response_container!(Kick, KickResponse); +response_container!(List, ListResponse); +response_container!(Promote, PromoteResponse); +response_container!(Update, UpdateResponse); +response_container!(Updated, UpdatedResponse); +response_container!(PublicKey, PublicKeyResponse); +response_container!(MemberChange, MemberChangeResponse); +response_container!(Secrets, SecretsResponse); +response_container!(SendSecrets, SendSecretsResponse); +response_container!(Announce, AnnounceResponse); diff --git a/server/src/types/protocol/create.rs b/server/src/types/protocol/create.rs new file mode 100644 index 0000000..617579a --- /dev/null +++ b/server/src/types/protocol/create.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::protocol::channel::Channel; +use crate::util::redacted::Redacted; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRequest { + #[serde(with = "serde_bytes")] + pub name: Redacted>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateResponse { + pub channel: Channel, +} diff --git a/server/src/types/protocol/disband.rs b/server/src/types/protocol/disband.rs new file mode 100644 index 0000000..d1573f3 --- /dev/null +++ b/server/src/types/protocol/disband.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DisbandRequest { + pub channel: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DisbandResponse { + pub channel: Uuid, +} diff --git a/server/src/types/protocol/error.rs b/server/src/types/protocol/error.rs new file mode 100644 index 0000000..e336d48 --- /dev/null +++ b/server/src/types/protocol/error.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorResponse { + pub channel: Option, + pub error: String, +} + +impl ErrorResponse { + pub fn new(channel: impl Into>, error: impl Into) -> Self { + ErrorResponse { + channel: channel.into(), + error: error.into(), + } + } +} diff --git a/server/src/types/protocol/invite.rs b/server/src/types/protocol/invite.rs new file mode 100644 index 0000000..eaa971f --- /dev/null +++ b/server/src/types/protocol/invite.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::types::protocol::channel::Channel; +use crate::util::redacted::Redacted; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InviteRequest { + pub channel: Uuid, + pub name: String, + pub world: u16, + #[serde(with = "serde_bytes")] + pub encrypted_secret: Redacted>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InviteResponse { + pub channel: Uuid, + pub name: String, + pub world: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvitedResponse { + pub channel: Channel, + pub name: String, + pub world: u16, + #[serde(with = "serde_bytes")] + pub pk: Redacted>, + #[serde(with = "serde_bytes")] + pub encrypted_secret: Redacted>, +} diff --git a/server/src/types/protocol/join.rs b/server/src/types/protocol/join.rs new file mode 100644 index 0000000..dd7191f --- /dev/null +++ b/server/src/types/protocol/join.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use crate::types::protocol::channel::Channel; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinRequest { + pub channel: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinResponse { + pub channel: Channel, +} diff --git a/server/src/types/protocol/kick.rs b/server/src/types/protocol/kick.rs new file mode 100644 index 0000000..0e5375a --- /dev/null +++ b/server/src/types/protocol/kick.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KickRequest { + pub channel: Uuid, + pub name: String, + pub world: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KickResponse { + pub channel: Uuid, + pub name: String, + pub world: u16, +} diff --git a/server/src/types/protocol/leave.rs b/server/src/types/protocol/leave.rs new file mode 100644 index 0000000..747fe5a --- /dev/null +++ b/server/src/types/protocol/leave.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeaveRequest { + pub channel: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeaveResponse { + pub channel: Uuid, + pub error: Option, +} + +impl LeaveResponse { + pub fn success(channel: Uuid) -> Self { + LeaveResponse { + channel, + error: None, + } + } + + pub fn error(channel: Uuid, error: impl Into) -> Self { + LeaveResponse { + channel, + error: Some(error.into()), + } + } +} diff --git a/server/src/types/protocol/list.rs b/server/src/types/protocol/list.rs new file mode 100644 index 0000000..6e01bc8 --- /dev/null +++ b/server/src/types/protocol/list.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::types::protocol::channel::{Channel, ChannelMember, SimpleChannel}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ListRequest { + All, + Channels, + Members(Uuid), + Invites, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ListResponse { + All { + channels: Vec, + invites: Vec, + }, + Channels(Vec), + Members { + id: Uuid, + members: Vec, + }, + Invites(Vec), +} diff --git a/server/src/types/protocol/member_change.rs b/server/src/types/protocol/member_change.rs new file mode 100644 index 0000000..7e53e34 --- /dev/null +++ b/server/src/types/protocol/member_change.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::Rank; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemberChangeResponse { + pub channel: Uuid, + pub name: String, + pub world: u16, + pub kind: MemberChangeKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemberChangeKind { + Invite { + inviter: String, + inviter_world: u16, + }, + InviteDecline, + InviteCancel { + canceler: String, + canceler_world: u16, + }, + Join, + Leave, + Promote { + rank: Rank, + }, + Kick { + kicker: String, + kicker_world: u16, + }, +} diff --git a/server/src/types/protocol/message.rs b/server/src/types/protocol/message.rs new file mode 100644 index 0000000..c4c68f3 --- /dev/null +++ b/server/src/types/protocol/message.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use crate::util::redacted::Redacted; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageRequest { + pub channel: Uuid, + #[serde(with = "serde_bytes")] + pub message: Redacted>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageResponse { + pub channel: Uuid, + pub sender: String, + pub world: u16, + #[serde(with = "serde_bytes")] + pub message: Redacted>, +} diff --git a/server/src/types/protocol/mod.rs b/server/src/types/protocol/mod.rs new file mode 100644 index 0000000..bd09f91 --- /dev/null +++ b/server/src/types/protocol/mod.rs @@ -0,0 +1,45 @@ +pub mod announce; +pub mod authenticate; +pub mod container; +pub mod create; +pub mod disband; +pub mod error; +pub mod invite; +pub mod join; +pub mod kick; +pub mod leave; +pub mod list; +pub mod member_change; +pub mod message; +pub mod ping; +pub mod promote; +pub mod public_key; +pub mod register; +pub mod secrets; +pub mod update; +pub mod version; + +pub mod channel; + +pub use self::{ + announce::*, + authenticate::*, + container::*, + create::*, + disband::*, + error::*, + invite::*, + join::*, + kick::*, + leave::*, + list::*, + member_change::*, + message::*, + ping::*, + promote::*, + public_key::*, + register::*, + secrets::*, + update::*, + version::*, +}; diff --git a/server/src/types/protocol/ping.rs b/server/src/types/protocol/ping.rs new file mode 100644 index 0000000..a031b85 --- /dev/null +++ b/server/src/types/protocol/ping.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PingRequest { +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PingResponse { +} diff --git a/server/src/types/protocol/promote.rs b/server/src/types/protocol/promote.rs new file mode 100644 index 0000000..1dfeba7 --- /dev/null +++ b/server/src/types/protocol/promote.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use crate::types::protocol::channel::Rank; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromoteRequest { + pub channel: Uuid, + pub name: String, + pub world: u16, + pub rank: Rank, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromoteResponse { + pub channel: Uuid, + pub name: String, + pub world: u16, + pub rank: Rank, +} diff --git a/server/src/types/protocol/public_key.rs b/server/src/types/protocol/public_key.rs new file mode 100644 index 0000000..f6706c1 --- /dev/null +++ b/server/src/types/protocol/public_key.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use crate::util::redacted::Redacted; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicKeyRequest { + pub name: String, + pub world: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicKeyResponse { + pub name: String, + pub world: u16, + #[serde(with = "serde_bytes")] + pub pk: Option>>, +} diff --git a/server/src/types/protocol/register.rs b/server/src/types/protocol/register.rs new file mode 100644 index 0000000..c887de2 --- /dev/null +++ b/server/src/types/protocol/register.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use crate::util::redacted::Redacted; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterRequest { + pub name: String, + pub world: u16, + pub challenge_completed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RegisterResponse { + Challenge { + challenge: String, + }, + Failure, + Success { + key: Redacted, + }, +} diff --git a/server/src/types/protocol/secrets.rs b/server/src/types/protocol/secrets.rs new file mode 100644 index 0000000..1921d4b --- /dev/null +++ b/server/src/types/protocol/secrets.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use crate::util::redacted::Redacted; + +/// A user sends this request if they have lost their +/// shared secret for a channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretsRequest { + pub channel: Uuid, +} + +/// When the server has received the shared secret from +/// another member, this response is sent to the initial +/// user requesting the secret. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretsResponse { + pub channel: Uuid, + #[serde(with = "serde_bytes")] + pub pk: Redacted>, + #[serde(with = "serde_bytes")] + pub encrypted_shared_secret: Redacted>, +} + +/// This response is sent to a random, online member of +/// the channel that the user has requested the secret +/// for. The server will wait a predetermined amount of +/// time for the user to respond with a `SendSecretsResponse` +/// before trying a different member. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendSecretsResponse { + pub channel: Uuid, + pub request_id: Uuid, + #[serde(with = "serde_bytes")] + pub pk: Redacted>, +} + +/// Clients send this request to the server after having +/// been asked to send a secret. The client may or may not +/// have the secret, so the `encrypted_shared_secret` field +/// is optional. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendSecretsRequest { + pub request_id: Uuid, + #[serde(with = "serde_bytes")] + pub encrypted_shared_secret: Option>>, +} diff --git a/server/src/types/protocol/update.rs b/server/src/types/protocol/update.rs new file mode 100644 index 0000000..4638b62 --- /dev/null +++ b/server/src/types/protocol/update.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use crate::util::redacted::Redacted; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateRequest { + pub channel: Uuid, + pub kind: UpdateKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UpdateKind { + Name(#[serde(with = "serde_bytes")] Redacted>), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateResponse { + pub channel: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdatedResponse { + pub channel: Uuid, + pub kind: UpdateKind, +} diff --git a/server/src/types/protocol/version.rs b/server/src/types/protocol/version.rs new file mode 100644 index 0000000..155c0ca --- /dev/null +++ b/server/src/types/protocol/version.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionRequest { + pub version: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionResponse { + pub version: u32, +} diff --git a/server/src/types/user.rs b/server/src/types/user.rs new file mode 100644 index 0000000..64e79ae --- /dev/null +++ b/server/src/types/user.rs @@ -0,0 +1,9 @@ +use lodestone_scraper::lodestone_parser::ffxiv_types::World; + +#[derive(Debug, Clone)] +pub struct User { + pub lodestone_id: u64, + pub name: String, + pub world: World, + pub hash: String, +} diff --git a/server/src/updater.rs b/server/src/updater.rs new file mode 100644 index 0000000..8773b2d --- /dev/null +++ b/server/src/updater.rs @@ -0,0 +1,86 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use lodestone_scraper::LodestoneScraper; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use log::{error, info, trace}; + +use crate::State; + +pub fn spawn(state: Arc>) -> JoinHandle<()> { + tokio::task::spawn(async move { + let lodestone = LodestoneScraper::default(); + + loop { + match inner(&state, &lodestone).await { + Ok(results) => { + let successful = results.values().filter(|result| result.is_ok()).count(); + info!("Updated {}/{} characters", successful, results.len()); + for (id, result) in results { + if let Err(e) = result { + error!("error updating user {}: {:?}", id, e); + } + } + } + Err(e) => { + error!("error updating users: {:?}", e); + } + } + + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + } + }) +} + +async fn inner(state: &RwLock, lodestone: &LodestoneScraper) -> Result>> { + let users = sqlx::query!( + // language=sqlite + "select * from users where (julianday(current_timestamp) - julianday(last_updated)) * 24 >= 2", + ) + .fetch_all(&state.read().await.db) + .await + .context("could not query database for users")?; + + let mut results = HashMap::with_capacity(users.len()); + for user in users { + results.insert(user.lodestone_id as u32, update(state, lodestone, user.lodestone_id).await); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + + Ok(results) +} + +async fn update(state: &RwLock, lodestone: &LodestoneScraper, lodestone_id: i64) -> Result<()> { + let info = lodestone + .character(lodestone_id as u64) + .await + .context("could not get character info")?; + let world_name = info.world.as_str(); + + sqlx::query!( + // language=sqlite + "update users set name = ?, world = ?, last_updated = current_timestamp where lodestone_id = ?", + info.name, + world_name, + lodestone_id, + ) + .execute(&state.read().await.db) + .await + .context("could not update user")?; + + trace!(" [updater] before state read"); + let client_state = state.read().await.clients.get(&(lodestone_id as u64)).cloned(); + trace!(" [updater] after state read"); + if let Some(user) = client_state { + trace!(" [updater] before user write"); + if let Some(user) = user.write().await.user.as_mut() { + user.name = info.name.clone(); + user.world = info.world; + } + trace!(" [updater] after user write"); + } + + Ok(()) +} diff --git a/server/src/util.rs b/server/src/util.rs new file mode 100644 index 0000000..21b1563 --- /dev/null +++ b/server/src/util.rs @@ -0,0 +1,261 @@ +use anyhow::{Context, Result}; +use futures_util::SinkExt; +use prefixed_api_key::ApiKey; +use sha3::Sha3_256; +use tokio::sync::RwLock; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use uuid::Uuid; + +use crate::{Digest, ResponseContainer, State, types::protocol::ResponseKind, World, WsStream}; + +pub mod redacted; + +pub async fn send(conn: &mut WsStream, number: u32, msg: impl Into) -> Result<()> { + let container = ResponseContainer { + number, + kind: msg.into(), + }; + + conn.send(WsMessage::Binary(rmp_serde::to_vec(&container)?)).await?; + Ok(()) +} + +pub async fn send_to_all(state: &RwLock, channel_id: Uuid, number: u32, msg: impl Into) -> Result<()> { + let members = get_raw_members(state, channel_id).await? + .into_iter() + .chain(get_raw_invited_members(state, channel_id).await?.into_iter()); + + let resp = ResponseContainer { + number, + kind: msg.into(), + }; + for member in members { + if let Some(client) = state.read().await.clients.get(&(member.lodestone_id as u64)) { + client.read().await.tx.send(resp.clone()).await.ok(); + } + } + + Ok(()) +} + +#[derive(Debug)] +pub struct RawMember { + pub lodestone_id: i64, + pub name: String, + pub world: String, + pub rank: i64, +} + +pub async fn get_raw_members(state: &RwLock, channel: Uuid) -> Result> { + let id = channel.as_simple().to_string(); + sqlx::query_as!( + RawMember, + // language=sqlite + "select users.lodestone_id, users.name, users.world, user_channels.rank from user_channels inner join users on users.lodestone_id = user_channels.lodestone_id where user_channels.channel_id = ?", + id, + ) + .fetch_all(&state.read().await.db) + .await + .context("could not get channel members") +} + +pub async fn get_raw_invited_members(state: &RwLock, channel: Uuid) -> Result> { + let id = channel.as_simple().to_string(); + sqlx::query_as!( + RawMember, + // language=sqlite + "select users.lodestone_id, users.name, users.world, cast(0 as int) as rank from channel_invites inner join users on users.lodestone_id = channel_invites.invited where channel_invites.channel_id = ?", + id, + ) + .fetch_all(&state.read().await.db) + .await + .context("could not get channel members") +} + +pub async fn is_invited(state: &RwLock, channel: Uuid, id: u64) -> Result { + let channel_id = channel.as_simple().to_string(); + let id = id as i64; + sqlx::query!( + // language=sqlite + "select count(*) as count from channel_invites where channel_id = ? and invited = ?", + channel_id, + id, + ) + .fetch_one(&state.read().await.db) + .await + .context("could not get channel members") + .map(|x| x.count > 0) +} + +pub fn hash_key(key: &ApiKey) -> String { + let mut hasher = Sha3_256::new(); + hasher.update(&key.long_bytes); + hex::encode(&hasher.finalize()[..]) +} + +pub fn id_from_world(world: World) -> u16 { + match world { + World::Ravana => 21, + World::Bismarck => 22, + World::Asura => 23, + World::Belias => 24, + World::Pandaemonium => 28, + World::Shinryu => 29, + World::Unicorn => 30, + World::Yojimbo => 31, + World::Zeromus => 32, + World::Twintania => 33, + World::Brynhildr => 34, + World::Famfrit => 35, + World::Lich => 36, + World::Mateus => 37, + World::Omega => 39, + World::Jenova => 40, + World::Zalera => 41, + World::Zodiark => 42, + World::Alexander => 43, + World::Anima => 44, + World::Carbuncle => 45, + World::Fenrir => 46, + World::Hades => 47, + World::Ixion => 48, + World::Kujata => 49, + World::Typhon => 50, + World::Ultima => 51, + World::Valefor => 52, + World::Exodus => 53, + World::Faerie => 54, + World::Lamia => 55, + World::Phoenix => 56, + World::Siren => 57, + World::Garuda => 58, + World::Ifrit => 59, + World::Ramuh => 60, + World::Titan => 61, + World::Diabolos => 62, + World::Gilgamesh => 63, + World::Leviathan => 64, + World::Midgardsormr => 65, + World::Odin => 66, + World::Shiva => 67, + World::Atomos => 68, + World::Bahamut => 69, + World::Chocobo => 70, + World::Moogle => 71, + World::Tonberry => 72, + World::Adamantoise => 73, + World::Coeurl => 74, + World::Malboro => 75, + World::Tiamat => 76, + World::Ultros => 77, + World::Behemoth => 78, + World::Cactuar => 79, + World::Cerberus => 80, + World::Goblin => 81, + World::Mandragora => 82, + World::Louisoix => 83, + World::Spriggan => 85, + World::Sephirot => 86, + World::Sophia => 87, + World::Zurvan => 88, + World::Aegis => 90, + World::Balmung => 91, + World::Durandal => 92, + World::Excalibur => 93, + World::Gungnir => 94, + World::Hyperion => 95, + World::Masamune => 96, + World::Ragnarok => 97, + World::Ridill => 98, + World::Sargatanas => 99, + World::Sagittarius => 400, + World::Phantom => 401, + World::Alpha => 402, + World::Raiden => 403, + } +} + +pub fn world_from_id(id: u16) -> Option { + let world = match id { + 21 => World::Ravana, + 22 => World::Bismarck, + 23 => World::Asura, + 24 => World::Belias, + 28 => World::Pandaemonium, + 29 => World::Shinryu, + 30 => World::Unicorn, + 31 => World::Yojimbo, + 32 => World::Zeromus, + 33 => World::Twintania, + 34 => World::Brynhildr, + 35 => World::Famfrit, + 36 => World::Lich, + 37 => World::Mateus, + 39 => World::Omega, + 40 => World::Jenova, + 41 => World::Zalera, + 42 => World::Zodiark, + 43 => World::Alexander, + 44 => World::Anima, + 45 => World::Carbuncle, + 46 => World::Fenrir, + 47 => World::Hades, + 48 => World::Ixion, + 49 => World::Kujata, + 50 => World::Typhon, + 51 => World::Ultima, + 52 => World::Valefor, + 53 => World::Exodus, + 54 => World::Faerie, + 55 => World::Lamia, + 56 => World::Phoenix, + 57 => World::Siren, + 58 => World::Garuda, + 59 => World::Ifrit, + 60 => World::Ramuh, + 61 => World::Titan, + 62 => World::Diabolos, + 63 => World::Gilgamesh, + 64 => World::Leviathan, + 65 => World::Midgardsormr, + 66 => World::Odin, + 67 => World::Shiva, + 68 => World::Atomos, + 69 => World::Bahamut, + 70 => World::Chocobo, + 71 => World::Moogle, + 72 => World::Tonberry, + 73 => World::Adamantoise, + 74 => World::Coeurl, + 75 => World::Malboro, + 76 => World::Tiamat, + 77 => World::Ultros, + 78 => World::Behemoth, + 79 => World::Cactuar, + 80 => World::Cerberus, + 81 => World::Goblin, + 82 => World::Mandragora, + 83 => World::Louisoix, + 85 => World::Spriggan, + 86 => World::Sephirot, + 87 => World::Sophia, + 88 => World::Zurvan, + 90 => World::Aegis, + 91 => World::Balmung, + 92 => World::Durandal, + 93 => World::Excalibur, + 94 => World::Gungnir, + 95 => World::Hyperion, + 96 => World::Masamune, + 97 => World::Ragnarok, + 98 => World::Ridill, + 99 => World::Sargatanas, + 400 => World::Sagittarius, + 401 => World::Phantom, + 402 => World::Alpha, + 403 => World::Raiden, + _ => return None, + }; + + Some(world) +} diff --git a/server/src/util/redacted.rs b/server/src/util/redacted.rs new file mode 100644 index 0000000..d4f25db --- /dev/null +++ b/server/src/util/redacted.rs @@ -0,0 +1,112 @@ +use std::fmt::{Debug, Display, Formatter}; +use std::ops::{Deref, DerefMut}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sqlx::{Database, Encode, Type}; +use sqlx::database::HasArguments; +use sqlx::encode::IsNull; + +#[repr(transparent)] +pub struct Redacted(pub T); + +impl Redacted { + pub fn into_inner(self) -> T { + self.0 + } + + pub fn as_inner(&self) -> &T { + &self.0 + } +} + +impl Display for Redacted { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "[redacted]") + } +} + +impl Debug for Redacted { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "[redacted]") + } +} + +impl Deref for Redacted { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Redacted { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Clone for Redacted { + fn clone(&self) -> Self { + Redacted(self.0.clone()) + } +} + +impl Copy for Redacted {} + +impl From for Redacted { + fn from(t: T) -> Self { + Self(t) + } +} + +impl Serialize for Redacted { + fn serialize(&self, serializer: S) -> Result where S: Serializer { + self.0.serialize(serializer) + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Redacted { + fn deserialize(deserializer: D) -> Result where D: Deserializer<'de> { + T::deserialize(deserializer).map(Redacted) + } +} + +impl serde_bytes::Serialize for Redacted { + fn serialize(&self, serializer: S) -> Result where S: Serializer { + serde_bytes::Serialize::serialize(&self.0, serializer) + } +} + +impl<'de, T: serde_bytes::Deserialize<'de>> serde_bytes::Deserialize<'de> for Redacted { + fn deserialize(deserializer: D) -> Result where D: Deserializer<'de> { + serde_bytes::Deserialize::deserialize(deserializer).map(Redacted) + } +} + +impl<'q, DB: Database, T: Encode<'q, DB>> Encode<'q, DB> for Redacted { + fn encode(self, buf: &mut >::ArgumentBuffer) -> IsNull where Self: Sized { + self.0.encode(buf) + } + + fn encode_by_ref(&self, buf: &mut >::ArgumentBuffer) -> IsNull { + self.0.encode_by_ref(buf) + } + + fn produces(&self) -> Option { + self.0.produces() + } + + fn size_hint(&self) -> usize { + self.0.size_hint() + } +} + +impl> Type for Redacted { + fn type_info() -> DB::TypeInfo { + T::type_info() + } + + fn compatible(ty: &DB::TypeInfo) -> bool { + T::compatible(ty) + } +}