Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/writing-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ implements `ILookupPlugin`: `Lookup` gives what searches its site for a name and
first, each as a name and a version. The server does the asking; the PC compares. One installed plugin is asked, the
first that is on.

### A check for a person

A site may put a check before its pages that only a person in a browser can pass: a picture of characters to type, say.
A plugin never answers one, and Ferry gives it no way to. It says the check is there, by throwing a `StageException`
whose `Check` is a `PersonCheck`: the page that shows the check (https) and the name of the cookie the site sets once it
is passed.

Where a person is, on the Browser page, Ferry opens that page as it is in a browser window of its own, reached through
the server, so the site sees the address its pages are then read from. The person passes the check; Ferry types nothing.
When the cookie is there the window closes. A plugin that implements `ICheckedPlugin` then hears of it (`Passed`: the
cookie's value and the browser's user agent), and whatever was asked is asked once more. Every such plugin hears of
every pass, so each keeps only those of its own sites, in memory.

Where no person is (a watched album read while the PC is off, a file in the queue) the failure stands as the plugin
worded it, so the message should say what to do. It needs the server's SSH to pass connections on (`AllowTcpForwarding`,
which is on unless a host turned it off), and Microsoft Edge WebView2 on the PC.

Reference `Ferry.Core` with `Private="false"`, as the sample does. Ferry has its own, and a package doesn't carry one.

Setting names start with `FERRY_`. `PluginApi.Version` is the contract's number; a package says which it was built
Expand Down
4 changes: 2 additions & 2 deletions src/Ferry.Agent/AgentCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ public static async Task<int> RunAsync(AgentPaths paths, CancellationToken cance
// at once, so a stop never finds the lock held and an older agent's pid in the file
await File.WriteAllTextAsync(paths.PidFile, Environment.ProcessId.ToString(CultureInfo.InvariantCulture), cancel);
#if SERVER_AGENT
var (plugins, browsing, lookup) = ServerPlugins.Load(paths);
await using var host = AgentHost.Start(paths, plugins: plugins, browsing: browsing, lookup: lookup);
var (plugins, browsing, lookup, passed) = ServerPlugins.Load(paths);
await using var host = AgentHost.Start(paths, plugins: plugins, browsing: browsing, lookup: lookup, passed: passed);
#else
await using var host = AgentHost.Start(paths);
#endif
Expand Down
9 changes: 6 additions & 3 deletions src/Ferry.Agent/ServerPlugins.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ namespace Ferry.Agent;
internal static class ServerPlugins
{
/// <summary>Load every package in the agent's plugins folder, and add those that can be to what the agent downloads with.</summary>
/// <returns>each package, loaded or not, for the PC to hear of; the first of them with a site to browse, and the first that looks titles up, if any</returns>
public static (List<AgentPlugin> Plugins, AgentBrowsing? Browsing, AgentLookup? Lookup) Load(AgentPaths paths)
/// <returns>each package, loaded or not, for the PC to hear of; the first of them with a site to browse, and the first that looks
/// titles up, if any; and what tells those whose sites ask for a person's check that one was passed, if any</returns>
public static (List<AgentPlugin> Plugins, AgentBrowsing? Browsing, AgentLookup? Lookup, Action<Core.PassedCheck>? Passed) Load(AgentPaths paths)
{
var plugins = new List<AgentPlugin>();
Action<Core.PassedCheck>? passed = null;
AgentBrowsing? browsing = null;
AgentLookup? lookup = null;
foreach (var installed in InstalledPlugins.Load(paths.PluginsFolder, AgentHost.Log))
Expand All @@ -28,6 +30,7 @@ public static (List<AgentPlugin> Plugins, AgentBrowsing? Browsing, AgentLookup?
browsing = Browsing(plugin, site);
else if (installed.Browsing is not null)
AgentHost.Log($"plugin {installed.Id} has a site to browse, and {browsing!.Name}'s is browsed already: only one is, for now");
passed += installed.Passed;
if (installed.Lookup is { } make && lookup is null)
lookup = new AgentLookup(plugin.Name, plugin.IsOn, make);
else if (installed.Lookup is not null)
Expand All @@ -36,7 +39,7 @@ public static (List<AgentPlugin> Plugins, AgentBrowsing? Browsing, AgentLookup?
}
plugins.Add(new AgentPlugin(installed.Id, installed.Version, installed.Sha256, problem));
}
return (plugins, browsing, lookup);
return (plugins, browsing, lookup, passed);
}

private static AgentBrowsing Browsing(SitePlugin plugin, SiteBrowsing site) =>
Expand Down
4 changes: 4 additions & 0 deletions src/Ferry.App/Services/AppServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ private AppServices(DispatcherQueue ui, AppPaths paths, IReadOnlyDictionary<stri
Queue = new QueueHost(this, ui);
Speed = new QueueSpeed(Queue, ui);
Updater = new Updater(this, ui);
Checks = new PersonChecks(this, ui);
Browse = new BrowseHost(this, ui);
PluginUpdates = new PluginUpdates(this);
}
Expand Down Expand Up @@ -93,6 +94,9 @@ public static AppServices Decoy(DispatcherQueue ui, AppServices real)
/// <summary>The page reader and album watcher, for the Browser page, while an installed plugin has a site to browse.</summary>
public BrowseHost Browse { get; }

/// <summary>Shows a person a site's check that only a person can pass, when the browsed site asks for one.</summary>
public PersonChecks Checks { get; }

/// <summary>Which of the browsed site's files were downloaded here, or null when that can't be kept.</summary>
public Ferry.Core.Browse.DownloadedFiles? Downloaded { get; private set; }

Expand Down
2 changes: 1 addition & 1 deletion src/Ferry.App/Services/BrowseHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ private async Task UseRemoteAsync(RemoteQueue? remote)
if (_remote is { } previous)
previous.ConnectionChanged -= OnConnectionChanged;
_remote = remote;
_remoteBrowser = remote is null ? null : new RemotePageBrowser(remote);
_remoteBrowser = remote is null ? null : new RemotePageBrowser(remote, _app.Checks.PassAsync);
_remoteWatcher = remote is null ? null : new RemoteAlbumWatcher(remote);
if (remote is null)
return;
Expand Down
189 changes: 189 additions & 0 deletions src/Ferry.App/Services/PersonChecks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using System.Runtime.InteropServices;
using Ferry.Core;
using Ferry.Core.Http;
using Ferry.Plugins.Server;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;

namespace Ferry.App.Services;

/// <summary>
/// A site's check that only a person can pass (<see cref="PersonCheck"/>), shown to the person: the site's own page in a
/// browser window, reached through the server so the site sees the address its pages are then read from. Ferry types
/// nothing and answers nothing. When the site has set its cookie, the window closes and the cookie goes to the server.
/// One window at a time: whoever else waited for the same check gets the same answer.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1001", Justification = "It lives as long as the session, as the hosts beside it do.")]
public sealed class PersonChecks(AppServices app, DispatcherQueue ui)
{
// a check given up on isn't shown again for this long: an album's pictures would each ask
private static readonly TimeSpan LeaveAlone = TimeSpan.FromSeconds(30);

private readonly SemaphoreSlim _oneAtATime = new(1, 1);

// the last answer per site and cookie, with when it came; used inside the gate only
private readonly Dictionary<string, (PassedCheck? Pass, long At)> _last = new(StringComparer.OrdinalIgnoreCase);

/// <summary>What passing <paramref name="check"/> earned, or null when the person closed the window, or it can't be shown.</summary>
/// <exception cref="StageException">the server can't be reached for it, or doesn't pass connections on (<see cref="Stage.Server"/>)</exception>
public async Task<PassedCheck?> PassAsync(PersonCheck check, CancellationToken cancel)
{
if (app.IsDecoy || !check.IsUsable)
return null;
var asked = Environment.TickCount64;
await _oneAtATime.WaitAsync(cancel);
try
{
var key = $"{check.Page.Host}\n{check.Cookie}";
if (_last.TryGetValue(key, out var last)
&& (last.At >= asked || (last.Pass is null && Environment.TickCount64 - last.At < LeaveAlone.TotalMilliseconds)))
return last.Pass;
PassedCheck? pass = null;
try
{
pass = await ShowAsync(check, cancel);
}
finally
{
_last[key] = (pass, Environment.TickCount64);
}
return pass;
}
finally
{
_oneAtATime.Release();
}
}

private async Task<PassedCheck?> ShowAsync(PersonCheck check, CancellationToken cancel)
{
await using var connection = await ServerConnection.OpenAsync(app.Settings, app.Secrets, cancel);
await using var tunnel = await connection.OpenTunnelAsync(check.Page.Host, cancel);
var where = app.Queue.ServerDisplay ?? "your server";
return await OnUiAsync(() => CheckWindow.ShowAsync(check, tunnel.Port, where, cancel));
}

private Task<T> OnUiAsync<T>(Func<Task<T>> work)
{
if (ui.HasThreadAccess)
return work();
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!ui.TryEnqueue(async () =>
{
try
{
done.TrySetResult(await work());
}
catch (OperationCanceledException e)
{
done.TrySetCanceled(e.CancellationToken);
}
#pragma warning disable CA1031 // whatever it is goes to whoever asked, not to the dispatcher
catch (Exception e)
#pragma warning restore CA1031
{
done.TrySetException(e);
}
}))
done.TrySetException(new StageException(Stage.Server, "Ferry is closing, so the check can't be shown."));
return done.Task;
}
}

/// <summary>The window a check is shown in: the site's page and a line saying what it is. It has its own browser profile, removed after.</summary>
internal static class CheckWindow
{
public static async Task<PassedCheck?> ShowAsync(PersonCheck check, int proxyPort, string where, CancellationToken cancel)
{
var profile = Directory.CreateTempSubdirectory("ferry_check_").FullName;
var done = new TaskCompletionSource<PassedCheck?>(TaskCreationOptions.RunContinuationsAsynchronously);
var view = new WebView2();
var note = new TextBlock
{
Text = $"{check.Page.Host} wants to know a person is there. This is its own page, opened through {where}. " +
"Ferry carries on by itself once you have passed; close the window to give up.",
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(16, 12, 16, 12),
};
var layout = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition() } };
layout.Children.Add(note);
Grid.SetRow(view, 1);
layout.Children.Add(view);
var window = new Window { Title = $"{check.Page.Host}: a check for a person", Content = layout };
window.AppWindow.Resize(new Windows.Graphics.SizeInt32(560, 680));
var closed = false;
window.Closed += (_, _) =>
{
closed = true;
done.TrySetResult(null);
};
await using var stop = cancel.Register(() => done.TrySetCanceled(cancel));
try
{
// socks5, not socks4: the name is looked up by the server, as it will be when the pages are read there
var options = new CoreWebView2EnvironmentOptions
{
AdditionalBrowserArguments = string.Create(System.Globalization.CultureInfo.InvariantCulture, $"--proxy-server=socks5://127.0.0.1:{proxyPort}"),
};
var environment = await CoreWebView2Environment.CreateWithOptionsAsync("", profile, options);
window.Activate();
await view.EnsureCoreWebView2Async(environment);
var core = view.CoreWebView2;
core.Settings.AreDevToolsEnabled = false;
core.Settings.IsPasswordAutosaveEnabled = false;
core.Settings.IsGeneralAutofillEnabled = false;
// the window is for this one site's check: nothing else opens in it
core.NewWindowRequested += (_, e) => e.Handled = true;
core.NavigationStarting += (_, e) =>
e.Cancel = !Uri.TryCreate(e.Uri, UriKind.Absolute, out var to) || !HttpFetcher.IsSameSite(to, check.Page);
core.NavigationCompleted += async (_, _) =>
{
try
{
// the site's page reloads itself once it has set its cookie: that is how a pass is seen
var cookies = await core.CookieManager.GetCookiesAsync(check.Page.AbsoluteUri);
if (cookies.FirstOrDefault(cookie => cookie.Name == check.Cookie) is { Value.Length: > 0 } cookie)
done.TrySetResult(new PassedCheck(check.Page, check.Cookie, cookie.Value, core.Settings.UserAgent));
}
catch (Exception e) when (e is COMException or InvalidOperationException or ObjectDisposedException)
{
// the window closed meanwhile
}
};
core.Navigate(check.Page.AbsoluteUri);
return await done.Task;
}
catch (Exception e) when (e is COMException or FileNotFoundException or InvalidOperationException)
{
throw new StageException(Stage.Server,
$"The check of {check.Page.Host} couldn't be shown: {e.Message} It needs Microsoft Edge WebView2, which comes with Windows 11.", inner: e);
}
finally
{
view.Close();
if (!closed)
window.Close();
_ = RemoveAsync(profile);
}
}

/// <summary>The browser lets go of its profile a moment after its window: tried a few times, then left to Windows' own clearing of temp.</summary>
private static async Task RemoveAsync(string profile)
{
for (var tries = 0; tries < 5; tries++)
{
await Task.Delay(TimeSpan.FromSeconds(2));
try
{
Directory.Delete(profile, recursive: true);
return;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// still held
}
}
}
}
20 changes: 20 additions & 0 deletions src/Ferry.Core/PersonCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Ferry.Core;

/// <summary>
/// A check a site put in the way that only a person in a browser can pass: a picture of characters to type, say. Ferry
/// never answers one itself. It shows the person the site's own page, and what the site then gives their browser is used.
/// </summary>
/// <param name="Page">the page that shows the check, https only; it is opened as it is</param>
/// <param name="Cookie">the name of the cookie the site sets on <paramref name="Page"/>'s host once the check is passed</param>
public sealed record PersonCheck(Uri Page, string Cookie)
{
/// <summary>Whether Ferry will show it: a web page over https, and a cookie with a name.</summary>
public bool IsUsable => Page is { IsAbsoluteUri: true } && Page.Scheme == Uri.UriSchemeHttps && Cookie.Length is > 0 and <= 128;
}

/// <summary>A <see cref="PersonCheck"/> a person passed.</summary>
/// <param name="Page">the check's page</param>
/// <param name="Cookie">the cookie's name, as the check gave it</param>
/// <param name="Value">what the site set it to</param>
/// <param name="UserAgent">the browser that passed: a site may take the cookie from no other</param>
public sealed record PassedCheck(Uri Page, string Cookie, string Value, string UserAgent);
14 changes: 14 additions & 0 deletions src/Ferry.Core/Plugins/IFerryPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ public interface ILookupPlugin : IFerryPlugin
ITitleLookup Lookup(PluginContext context);
}

/// <summary>
/// A plugin whose site may ask for a check only a person can pass. It says so by throwing a <see cref="StageException"/>
/// with its <see cref="StageException.Check"/> set; when a person at the PC has passed it, this hears of it, and whoever
/// asked asks once more.
/// </summary>
public interface ICheckedPlugin : IFerryPlugin
{
/// <summary>
/// A person passed a check. Every plugin of this kind hears of every pass, so each takes only those of its own
/// sites. It is kept in memory only.
/// </summary>
void Passed(PassedCheck pass);
}

/// <summary>Searches a site for a title.</summary>
public interface ITitleLookup
{
Expand Down
6 changes: 6 additions & 0 deletions src/Ferry.Core/StageException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ public sealed class StageException(Stage stage, string message, bool isRetryable
public TimeSpan? RetryAfter { get; } = retryAfter;
public HttpStatusCode? Status { get; } = status;

/// <summary>
/// The site answered with a check only a person in a browser can pass, and this is it. Where there is a person to
/// ask, Ferry shows them the page and hands what they earned to the plugin (<see cref="Plugins.ICheckedPlugin"/>).
/// </summary>
public PersonCheck? Check { get; init; }

/// <summary>The server is overloaded or limiting how often it is asked (503, 429): fewer requests, more slowly.</summary>
public bool IsBusy => Status is HttpStatusCode.ServiceUnavailable or HttpStatusCode.TooManyRequests;
}
Loading
Loading