Skip to content

Commit

Permalink
Implement privacy policy support (#195)
Browse files Browse the repository at this point in the history
* Implement privacy policy support

Servers can now provide privacy policy information via their info API. If provided, users will be prompted to accept the policy while being presented with a link to open it.

Accepted privacy policies are stored in the launcher's settings database. Servers can report changes in their privacy policy version, in which case the user will be re-prompted with a different message.

Fixes #194

* Deny -> Decline
  • Loading branch information
PJB3005 authored Dec 8, 2024
1 parent ed386b8 commit fb7e9c8
Show file tree
Hide file tree
Showing 9 changed files with 280 additions and 44 deletions.
6 changes: 6 additions & 0 deletions SS14.Launcher/Assets/Locale/en-US/text.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ connecting-update-status-loading-into-db = Storing assets in database…
connecting-update-status-loading-content-bundle = Loading content bundle…
connecting-update-status-unknown = You shouldn't see this
connecting-privacy-policy-text = This server requires that you accept its privacy policy before connecting.
connecting-privacy-policy-text-version-changed = This server has updated its privacy policy since the last time you played. You must accept the new version before connecting.
connecting-privacy-policy-view = View privacy policy
connecting-privacy-policy-accept = Accept (continue)
connecting-privacy-policy-decline = Decline (disconnect)
## Strings for the "direct connect" dialog window.

direct-connect-title = Direct Connect…
Expand Down
22 changes: 22 additions & 0 deletions SS14.Launcher/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Threading;
using System.Threading.Tasks;
using Mono.Unix;
using Serilog;
using TerraFX.Interop.Windows;

namespace SS14.Launcher;
Expand Down Expand Up @@ -88,6 +89,27 @@ await Task.Run(async () =>
}, cancel);
}

/// <summary>
/// Open a URI provided by a game server in the user's browser. Refuse to open anything other than http/https.
/// </summary>
/// <param name="uri">The URI to open.</param>
public static void SafeOpenServerUri(string uri)
{
if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsedUri))
{
Log.Error("Unable to parse URI in server-provided link: {Link}", uri);
return;
}

if (parsedUri.Scheme is not ("http" or "https"))
{
Log.Error("Refusing to open server-provided link {Link}, only http/https are allowed", parsedUri);
return;
}

OpenUri(parsedUri.ToString());
}

public static void OpenUri(Uri uri)
{
OpenUri(uri.ToString());
Expand Down
102 changes: 102 additions & 0 deletions SS14.Launcher/Models/Connector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public class Connector : ReactiveObject
private bool _clientExitedBadly;
private readonly HttpClient _http;

private TaskCompletionSource<PrivacyPolicyAcceptResult>? _acceptPrivacyPolicyTcs;
private ServerPrivacyPolicyInfo? _serverPrivacyPolicyInfo;
private bool _privacyPolicyDifferentVersion;

public Connector()
{
_updater = Locator.Current.GetRequiredService<Updater>();
Expand All @@ -59,6 +63,13 @@ public bool ClientExitedBadly
private set => this.RaiseAndSetIfChanged(ref _clientExitedBadly, value);
}

public ServerPrivacyPolicyInfo? PrivacyPolicyInfo => _serverPrivacyPolicyInfo;
public bool PrivacyPolicyDifferentVersion
{
get => _privacyPolicyDifferentVersion;
private set => this.RaiseAndSetIfChanged(ref _privacyPolicyDifferentVersion, value);
}

public async void Connect(string address, CancellationToken cancel = default)
{
try
Expand All @@ -75,6 +86,10 @@ public async void Connect(string address, CancellationToken cancel = default)
Log.Information(e, "Cancelled connect");
Status = ConnectionStatus.Cancelled;
}
finally
{
Cleanup();
}
}

public async void LaunchContentBundle(IStorageFile file, CancellationToken cancel = default)
Expand All @@ -95,6 +110,10 @@ public async void LaunchContentBundle(IStorageFile file, CancellationToken cance
Log.Information(e, "Cancelled launch");
Status = ConnectionStatus.Cancelled;
}
finally
{
Cleanup();
}
}

private async Task ConnectInternalAsync(string address, CancellationToken cancel)
Expand All @@ -103,6 +122,8 @@ private async Task ConnectInternalAsync(string address, CancellationToken cancel

var (info, parsedAddr, infoAddr) = await GetServerInfoAsync(address, cancel);

await HandlePrivacyPolicyAsync(info, cancel);

// Run update.
Status = ConnectionStatus.Updating;

Expand All @@ -113,6 +134,80 @@ private async Task ConnectInternalAsync(string address, CancellationToken cancel
await LaunchClientWrap(installation, info, info.BuildInformation, connectAddress, parsedAddr, false, cancel);
}

private async Task HandlePrivacyPolicyAsync(ServerInfo info, CancellationToken cancel)
{
if (info.PrivacyPolicy == null)
{
// Server has no privacy policy configured, nothing to do.
return;
}

var identifier = info.PrivacyPolicy.Identifier;
var version = info.PrivacyPolicy.Version;

if (_cfg.HasAcceptedPrivacyPolicy(identifier, out var acceptedVersion))
{
if (version == acceptedVersion)
{
Log.Debug(
"User has previously accepted privacy policy {Identifier} with version {Version}",
identifier,
acceptedVersion);

// User has previously accepted privacy policy, update last connected time in DB at least.
_cfg.UpdateConnectedToPrivacyPolicy(identifier);
_cfg.CommitConfig();
return;
}
else
{
Log.Debug("User previously accepted privacy policy but version has changed!");
PrivacyPolicyDifferentVersion = true;
}
}

// Ask user for privacy policy acceptance by waiting here.
Log.Debug("Prompting user for privacy policy acceptance: {Identifer} version {Version}", identifier, version);
_serverPrivacyPolicyInfo = info.PrivacyPolicy;
_acceptPrivacyPolicyTcs = new TaskCompletionSource<PrivacyPolicyAcceptResult>();

Status = ConnectionStatus.AwaitingPrivacyPolicyAcceptance;
var result = await _acceptPrivacyPolicyTcs.Task.WaitAsync(cancel);

if (result == PrivacyPolicyAcceptResult.Accepted)
{
// Yippee they're ok with it.
Log.Debug("User accepted privacy policy");
_cfg.AcceptPrivacyPolicy(identifier, version);
_cfg.CommitConfig();
return;
}

// They're not ok with it. Just throw cancellation so the code cleans up I guess.
// We could just have the connection screen treat "deny" as a cancellation op directly,
// but that would make the logs less clear.
Log.Information("User denied privacy policy, cancelling connection attempt!");
throw new OperationCanceledException();
}

public void ConfirmPrivacyPolicy(PrivacyPolicyAcceptResult result)
{
if (_acceptPrivacyPolicyTcs == null)
{
Log.Error("_acceptPrivacyPolicyTcs is null???");
return;
}

_acceptPrivacyPolicyTcs.SetResult(result);
}

private void Cleanup()
{
_serverPrivacyPolicyInfo = null;
_acceptPrivacyPolicyTcs = null;
PrivacyPolicyDifferentVersion = default;
}

private async Task LaunchContentBundleInternal(IStorageFile file, CancellationToken cancel)
{
Status = ConnectionStatus.Updating;
Expand Down Expand Up @@ -668,6 +763,7 @@ public enum ConnectionStatus
Updating,
UpdateError,
Connecting,
AwaitingPrivacyPolicyAcceptance,
ConnectionFailed,
StartingClient,
ClientRunning,
Expand Down Expand Up @@ -710,3 +806,9 @@ public sealed record ContentBundleBaseBuild(
[property: JsonPropertyName("manifest_url")] string? ManifestUrl,
[property: JsonPropertyName("manifest_hash")] string? ManifestHash
);

public enum PrivacyPolicyAcceptResult
{
Denied,
Accepted,
}
46 changes: 46 additions & 0 deletions SS14.Launcher/Models/Data/DataManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ public sealed class DataManager : ReactiveObject
private readonly List<DbCommand> _dbCommandQueue = new();
private readonly SemaphoreSlim _dbWritingSemaphore = new(1);

// Privacy policy IDs accepted along with the last accepted version.
private readonly Dictionary<string, string> _acceptedPrivacyPolicies = new();

static DataManager()
{
SqlMapper.AddTypeHandler(new GuidTypeHandler());
Expand Down Expand Up @@ -216,6 +219,43 @@ public void SetHubs(List<Hub> hubs)
CommitConfig();
}

public bool HasAcceptedPrivacyPolicy(string privacyPolicy, [NotNullWhen(true)] out string? version)
{
return _acceptedPrivacyPolicies.TryGetValue(privacyPolicy, out version);
}

public void AcceptPrivacyPolicy(string privacyPolicy, string version)
{
if (_acceptedPrivacyPolicies.ContainsKey(privacyPolicy))
{
// Accepting new version
AddDbCommand(db => db.Execute("""
UPDATE AcceptedPrivacyPolicy
SET Version = @Version, LastConnected = DATETIME('now')
WHERE Identifier = @Identifier
""", new { Identifier = privacyPolicy, Version = version }));
}
else
{
// Accepting new privacy policy entirely.
AddDbCommand(db => db.Execute("""
INSERT OR REPLACE INTO AcceptedPrivacyPolicy (Identifier, Version, AcceptedTime, LastConnected)
VALUES (@Identifier, @Version, DATETIME('now'), DATETIME('now'))
""", new { Identifier = privacyPolicy, Version = version }));
}

_acceptedPrivacyPolicies[privacyPolicy] = version;
}

public void UpdateConnectedToPrivacyPolicy(string privacyPolicy)
{
AddDbCommand(db => db.Execute("""
UPDATE AcceptedPrivacyPolicy
SET LastConnected = DATETIME('now')
WHERE Version = @Version
""", new { Version = privacyPolicy }));
}

/// <summary>
/// Loads config file from disk, or resets the loaded config to default if the config doesn't exist on disk.
/// </summary>
Expand Down Expand Up @@ -293,6 +333,12 @@ private void LoadSqliteConfig(SqliteConnection sqliteConnection)
_filters.UnionWith(sqliteConnection.Query<ServerFilter>("SELECT Category, Data FROM ServerFilter"));
_hubs.AddRange(sqliteConnection.Query<Hub>("SELECT Address,Priority FROM Hub"));

foreach (var (identifier, version) in sqliteConnection.Query<(string, string)>(
"SELECT Identifier, Version FROM AcceptedPrivacyPolicy"))
{
_acceptedPrivacyPolicies[identifier] = version;
}

// Avoid DB commands from config load.
_dbCommandQueue.Clear();
}
Expand Down
17 changes: 17 additions & 0 deletions SS14.Launcher/Models/Data/Migrations/Script0007_PrivacyPolicy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Represents privacy policies that have been accepted by the user.
-- Each policy is stored with its server-unique identifier and version value.
CREATE TABLE AcceptedPrivacyPolicy(
-- The "identifier" field from the privacy_policy server info response.
Identifier TEXT NOT NULL PRIMARY KEY,

-- The "version" field from the privacy_policy server info response.
Version TEXT NOT NULL,

-- The time the user accepted the privacy policy for the first time.
AcceptedTime DATETIME NOT NULL,

-- The last time the user connected to a server using this privacy policy.
-- Intended to enable culling of this table for servers the user has not connected to in a long time,
-- though this is not currently implemented at the time of writing.
LastConnected DATETIME NOT NULL
);
9 changes: 9 additions & 0 deletions SS14.Launcher/Models/ServerInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public sealed class ServerInfo

[JsonPropertyName("desc")] public string? Desc { get; set; }
[JsonPropertyName("links")] public ServerInfoLink[]? Links { get; set; }

[JsonPropertyName("privacy_policy")] public ServerPrivacyPolicyInfo? PrivacyPolicy { get; set; }
}

public sealed record ServerInfoLink(string Name, string? Icon, string Url);
Expand Down Expand Up @@ -64,3 +66,10 @@ public enum AuthMode
Required = 1,
Disabled = 2
}

public sealed record ServerPrivacyPolicyInfo(
[property: JsonPropertyName("link")] string Link,
[property: JsonPropertyName("identifier")]
string Identifier,
[property: JsonPropertyName("version")]
string Version);
29 changes: 29 additions & 0 deletions SS14.Launcher/ViewModels/ConnectingViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public ConnectingViewModel(Connector connector, MainWindowViewModel windowVm, st
this.RaisePropertyChanged(nameof(StatusText));
this.RaisePropertyChanged(nameof(ProgressBarVisible));
this.RaisePropertyChanged(nameof(IsErrored));
this.RaisePropertyChanged(nameof(IsAskingPrivacyPolicy));

if (val == Connector.ConnectionStatus.ClientRunning
|| val == Connector.ConnectionStatus.Cancelled
Expand All @@ -92,6 +93,13 @@ public ConnectingViewModel(Connector connector, MainWindowViewModel windowVm, st
}
});

this.WhenAnyValue(x => x._connector.PrivacyPolicyDifferentVersion)
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(_ =>
{
this.RaisePropertyChanged(nameof(PrivacyPolicyText));
});

this.WhenAnyValue(x => x._connector.ClientExitedBadly)
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(_ =>
Expand Down Expand Up @@ -196,6 +204,12 @@ public string SpeedText
_ => ""
};

public bool IsAskingPrivacyPolicy => _connectorStatus == Connector.ConnectionStatus.AwaitingPrivacyPolicyAcceptance;

public string PrivacyPolicyText => _connector.PrivacyPolicyDifferentVersion
? _loc.GetString("connecting-privacy-policy-text-version-changed")
: _loc.GetString("connecting-privacy-policy-text");

public static void StartConnect(MainWindowViewModel windowVm, string address, string? givenReason = null)
{
var connector = new Connector();
Expand Down Expand Up @@ -239,6 +253,21 @@ public void Cancel()
_cancelSource.Cancel();
}

public void PrivacyPolicyView()
{
Helpers.SafeOpenServerUri(_connector.PrivacyPolicyInfo!.Link);
}

public void PrivacyPolicyAccept()
{
_connector.ConfirmPrivacyPolicy(PrivacyPolicyAcceptResult.Accepted);
}

public void PrivacyPolicyDeny()
{
_connector.ConfirmPrivacyPolicy(PrivacyPolicyAcceptResult.Denied);
}

public enum ConnectionType
{
Server,
Expand Down
Loading

0 comments on commit fb7e9c8

Please sign in to comment.