From afa3b00ce3d83cf2c56df583e0c32c813a52f80f Mon Sep 17 00:00:00 2001 From: Anna Date: Thu, 5 Sep 2024 12:05:56 -0400 Subject: [PATCH] feat: add support for web-based first-time setup --- Plugin.cs | 20 +++++++++- Server.cs | 77 ++++++++++++++++++++++++++++++++++++++ Ui/FirstTimeSetupWindow.cs | 30 +++++++++++---- Ui/PluginUi.cs | 2 +- Ui/Tabs/Settings.cs | 38 +++++++++++-------- 5 files changed, 141 insertions(+), 26 deletions(-) diff --git a/Plugin.cs b/Plugin.cs index 21d8fbc..45ab825 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -11,6 +11,7 @@ using Dalamud.IoC; using Dalamud.Plugin; using Dalamud.Plugin.Services; +using gfoidl.Base64; using Heliosphere.Exceptions; using Heliosphere.Model.Generated; using Heliosphere.Ui; @@ -100,6 +101,7 @@ public class Plugin : IDalamudPlugin { private Stopwatch LimitTimer { get; } = Stopwatch.StartNew(); internal bool IntegrityFailed { get; private set; } + internal string? FirstTimeSetupKey { get; private set; } internal ICache CoverImages { get; } = new ConcurrentLruBuilder() .WithConcurrencyLevel(1) @@ -262,7 +264,7 @@ public Plugin() { Task.Run(async () => await this.State.UpdatePackages()); if (this.Interface.Reason == PluginLoadReason.Installer && !this.Config.FirstTimeSetupComplete) { - this.PluginUi.FirstTimeSetupWindow.Visible = true; + this.DoFirstTimeSetup(); } } @@ -348,9 +350,25 @@ private void OpenFirstTimeSetup() { return; } + this.DoFirstTimeSetup(); + } + + internal void DoFirstTimeSetup() { + var bytes = new byte[8]; + Random.Shared.NextBytes(bytes); + var key = Base64.Url.Encode(bytes); + this.FirstTimeSetupKey = key; this.PluginUi.FirstTimeSetupWindow.Visible = true; } + internal void EndFirstTimeSetup() { + this.Config.FirstTimeSetupComplete = true; + this.SaveConfig(); + + this.FirstTimeSetupKey = null; + this.PluginUi.FirstTimeSetupWindow.Visible = false; + } + /// /// Attempt to add a download to the download queue. This can fail (and /// therefore return null) if a download for the same version is already in diff --git a/Server.cs b/Server.cs index 41c79fc..ba8607a 100644 --- a/Server.cs +++ b/Server.cs @@ -446,6 +446,65 @@ await this.Plugin.AddDownloadAsync(new DownloadTask { }; break; } + case "/first-time" when method == "post": { + if (this.Plugin.FirstTimeSetupKey is not { } key) { + statusCode = 400; + break; + } + + var info = ReadJson(req); + if (info == null) { + statusCode = 400; + break; + } + + // TODO: do we care about timing attacks + if (info.Key != key) { + statusCode = 401; + break; + } + + statusCode = 204; + + if (info.Options == null) { + statusCode = 200; + response = new { + Plugin.Version, + Options = new FirstTimeSetupConfigOptions { + AutoUpdate = this.Plugin.Config.AutoUpdate, + ShowPreviews = this.Plugin.Config.Penumbra.ShowImages, + ShowInPenumbra = this.Plugin.Config.OpenPenumbraAfterInstall, + TitlePrefix = this.Plugin.Config.TitlePrefix, + PenumbraFolder = this.Plugin.Config.PenumbraFolder, + OneClick = this.Plugin.Config.OneClick, + }, + }; + break; + } + + var oneClickWasEnabled = this.Plugin.Config.OneClick; + + this.Plugin.Config.AutoUpdate = info.Options.AutoUpdate; + this.Plugin.Config.Penumbra.ShowImages = info.Options.ShowPreviews; + this.Plugin.Config.OpenPenumbraAfterInstall = info.Options.ShowInPenumbra; + this.Plugin.Config.TitlePrefix = info.Options.TitlePrefix; + this.Plugin.Config.PenumbraFolder = info.Options.PenumbraFolder; + this.Plugin.Config.OneClick = info.Options.OneClick; + + if (!oneClickWasEnabled && info.Options.OneClick) { + // one-click was enabled + var password = this.Plugin.PluginUi.Settings.GenerateOneClickKey(); + statusCode = 200; + response = new { + OneClickPassword = password, + }; + } + + // NOTE: this calls saveconfig + this.Plugin.EndFirstTimeSetup(); + + break; + } default: { if (method == "options") { statusCode = 200; @@ -620,3 +679,21 @@ internal class InstallInfo { public Guid VersionId { get; set; } public string? DownloadCode { get; set; } } + +[Serializable] +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +internal class FirstTimeSetup { + public string Key { get; set; } + public FirstTimeSetupConfigOptions? Options { get; set; } +} + +[Serializable] +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +internal class FirstTimeSetupConfigOptions { + public bool AutoUpdate { get; set; } + public bool ShowPreviews { get; set; } + public string TitlePrefix { get; set; } + public string PenumbraFolder { get; set; } + public bool OneClick { get; set; } + public bool ShowInPenumbra { get; set; } +} diff --git a/Ui/FirstTimeSetupWindow.cs b/Ui/FirstTimeSetupWindow.cs index c380b63..9135c0a 100644 --- a/Ui/FirstTimeSetupWindow.cs +++ b/Ui/FirstTimeSetupWindow.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Heliosphere.Util; using ImGuiNET; @@ -25,18 +26,31 @@ private void Draw() { // TODO: SetNextWindowSize using var end = new OnDispose(ImGui.End); - if (!ImGui.Begin($"{Plugin.Name} first-time setup", ref this.Visible)) { + if (!ImGui.Begin($"{Plugin.Name} first-time setup")) { return; } - var anyChanged = false; - anyChanged |= ImGuiHelper.BooleanYesNo( - "Do you want mods to automatically update when you log in?", - ref this.Plugin.Config.AutoUpdate - ); + using var popTextWrapPos = new OnDispose(ImGui.PopTextWrapPos); + ImGui.PushTextWrapPos(); - if (anyChanged) { - this.Plugin.SaveConfig(); + ImGuiHelper.TextUnformattedCentred("Welcome to Heliosphere!", PluginUi.TitleSize); + + ImGui.Spacing(); + + ImGui.TextUnformatted("To get everything set up, open the first-time setup window by clicking the button below. It will open in your default web browser."); + + if (ImGuiHelper.CentredWideButton("Open first-time setup")) { + var url = new UriBuilder("https://heliosphere.app/setup") { + Fragment = this.Plugin.FirstTimeSetupKey, + }; + + Process.Start(new ProcessStartInfo(url.Uri.ToString()) { + UseShellExecute = true, + }); + } + + if (ImGui.SmallButton("Skip (not recommended)")) { + this.Plugin.EndFirstTimeSetup(); } } } diff --git a/Ui/PluginUi.cs b/Ui/PluginUi.cs index f03b7c5..61f0a99 100644 --- a/Ui/PluginUi.cs +++ b/Ui/PluginUi.cs @@ -27,7 +27,7 @@ internal enum Tab { private Manager Manager { get; } private DownloadHistory DownloadHistory { get; } private LatestUpdate LatestUpdate { get; } - private Settings Settings { get; } + internal Settings Settings { get; } internal DownloadStatusWindow StatusWindow { get; } internal BreakingChangeWindow BreakingChangeWindow { get; } internal FirstTimeSetupWindow FirstTimeSetupWindow { get; } diff --git a/Ui/Tabs/Settings.cs b/Ui/Tabs/Settings.cs index 0987a02..a71d017 100644 --- a/Ui/Tabs/Settings.cs +++ b/Ui/Tabs/Settings.cs @@ -202,24 +202,10 @@ void DrawLimitCombo(string title, string id, ref Configuration.SpeedLimit limit) ? "Generate code" : "Generate new code"; if (ImGui.Button(label)) { - var salt = new byte[8]; - Random.Shared.NextBytes(salt); - - var password = new byte[16]; - Random.Shared.NextBytes(password); - - var hash = new Argon2id(password) { - Iterations = 3, - MemorySize = 65536, - DegreeOfParallelism = 4, - Salt = salt, - }.GetBytes(128); - - this.Plugin.Config.OneClickSalt = salt; - this.Plugin.Config.OneClickHash = Base64.Default.Encode(hash); + var password = this.GenerateOneClickKey(); anyChanged = true; - ImGui.SetClipboardText(Base64.Default.Encode(password)); + ImGui.SetClipboardText(password); this.Plugin.NotificationManager.AddNotification(new Notification { Type = NotificationType.Info, Content = "Code copied to clipboard. Paste it on the Heliosphere website.", @@ -335,4 +321,24 @@ ref this.Plugin.Config.OneClickCollectionId this.Plugin.SaveConfig(); } } + + internal string GenerateOneClickKey() { + var salt = new byte[8]; + Random.Shared.NextBytes(salt); + + var password = new byte[16]; + Random.Shared.NextBytes(password); + + var hash = new Argon2id(password) { + Iterations = 3, + MemorySize = 65536, + DegreeOfParallelism = 4, + Salt = salt, + }.GetBytes(128); + + this.Plugin.Config.OneClickSalt = salt; + this.Plugin.Config.OneClickHash = Base64.Default.Encode(hash); + + return Base64.Default.Encode(password); + } }