Initial version

This commit is contained in:
Sithis 2026-10-15 05:46:35 +02:00
commit e5b6226a85
68 changed files with 7338 additions and 0 deletions

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description"
content="Send files and messages end-to-end encrypted between two browsers using a one-time code."/>
<meta name="color-scheme" content="light dark"/>
<meta name="theme-color" content="#1d2329"/>
<base href="/"/>
<ResourcePreloader/>
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["DataTransfer.styles.css"]"/>
<ImportMap nonce="@SecurityHeaders.GetNonce(HttpContext)"/>
<link rel="icon" type="image/svg+xml" href="@Assets["favicon.svg"]"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@code {
[CascadingParameter] private HttpContext? HttpContext { get; set; }
}

View file

@ -0,0 +1,67 @@
@inherits LayoutComponentBase
<div class="shell">
<header class="app-header">
<div class="app-header-inner">
@* A full reload (no enhanced navigation) disconnects from the server, which cancels any connection. *@
<a class="brand" href="" data-enhance-nav="false" title="Start over" data-testid="brand">
<span class="brand-mark"><Icon Name="transfer"/></span>
<span>DataTransfer</span>
</a>
<div class="header-meta">
<ServerLoad @rendermode="@(new InteractiveWebAssemblyRenderMode(false))"/>
<span class="header-note" title="End-to-end encrypted">
<Icon Name="lock"/>
<span class="header-note-text">End-to-end encrypted</span>
</span>
</div>
</div>
</header>
<main class="app-main">
@Body
</main>
<footer class="app-footer">
<div class="app-footer-inner">
<span>ECDH P-256 · AES-256-GCM · The server only relays encrypted data.</span>
<nav class="footer-links" aria-label="Legal and source code">
@foreach (var (href, label, icon) in LegalLinks)
{
<a href="@href" target="@(IsTransferPage ? "_blank" : null)" rel="@(IsTransferPage ? "noopener" : null)">
<Icon Name="@icon"/>
<span>@label</span>
</a>
}
<SourceCodeLink Target="@(IsTransferPage ? "_blank" : null)">
<Icon Name="code"/>
<span>Open source</span>
</SourceCodeLink>
</nav>
</div>
</footer>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss" title="Dismiss"><Icon Name="close"/></span>
</div>
@code {
private static readonly (string Href, string Label, string Icon)[] LegalLinks =
[
("imprint", "Imprint", "info"),
("privacy", "Privacy", "shield"),
("terms", "Terms of use", "file-text"),
("abuse", "Report abuse", "flag")
];
[Inject] private NavigationManager Navigation { get; set; } = null!;
// Leaving the transfer page would cancel the connection without the browser's leave confirmation (Blazor
// navigates within the page), so legal pages open in a new tab there.
private bool IsTransferPage => Navigation.ToBaseRelativePath(Navigation.Uri).Split('?', '#')[0].Length == 0;
}

View file

@ -0,0 +1,185 @@
.shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
background-color: var(--header-bg);
color: var(--header-text);
}
.app-header-inner,
.app-main,
.app-footer-inner {
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.app-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 3.25rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.625rem;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
color: inherit;
text-decoration: none;
border-radius: var(--radius);
}
.brand:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.brand-mark {
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius);
background-color: var(--primary);
color: #fff;
}
.brand-mark ::deep .icon {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2.25;
}
.header-meta {
display: flex;
align-items: center;
gap: 1.25rem;
min-width: 0;
}
.header-note {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--header-muted);
font-size: 0.8125rem;
white-space: nowrap;
}
.header-note ::deep .icon {
width: 0.875rem;
height: 0.875rem;
}
.app-main {
flex: 1;
padding-top: 1.75rem;
padding-bottom: 2.5rem;
}
.app-footer {
border-top: 1px solid var(--border);
}
.footer-links {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
width: 100%;
}
/* ::deep also reaches the link rendered by SourceCodeLink and the Icon components. */
.footer-links ::deep a {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
color: inherit;
}
.footer-links ::deep a:hover {
color: var(--text);
}
.footer-links ::deep .icon {
width: 0.8125rem;
height: 0.8125rem;
}
.app-footer-inner {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.25rem 1.5rem;
padding-top: 0.875rem;
padding-bottom: 0.875rem;
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 560px) {
.app-header-inner,
.app-main,
.app-footer-inner {
padding-left: 1rem;
padding-right: 1rem;
}
.app-main {
padding-top: 1.25rem;
}
.header-meta {
gap: 0.875rem;
}
/* Only the lock icon remains visible; the text stays available to screen readers. */
.header-note-text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
#blazor-error-ui {
color-scheme: light only;
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
padding: 0.75rem 3rem 0.75rem 1.25rem;
border-top: 1px solid #e3c27a;
background-color: #fcf1dc;
color: #4a3300;
font-size: 0.875rem;
box-shadow: 0 -2px 8px rgb(0 0 0 / 0.08);
}
#blazor-error-ui .reload {
margin-left: 0.5rem;
color: inherit;
font-weight: 600;
}
#blazor-error-ui .dismiss {
position: absolute;
top: 50%;
right: 1rem;
display: grid;
transform: translateY(-50%);
cursor: pointer;
}

View file

@ -0,0 +1,21 @@
@inject IOptions<LegalOptions> LegalOptions
<address class="legal-address">
<strong><ProtectedText Value="@Legal.OperatorName"/></strong><br/>
@foreach (var line in Legal.Address)
{
<ProtectedText Value="@line"/><br/>
}
E-mail: <ProtectedEmail Address="@Legal.Email"/>
@if (!string.IsNullOrWhiteSpace(Legal.Phone))
{
<br/>
@("Phone: ")<ProtectedText Value="@Legal.Phone"/>
}
</address>
@code {
private LegalOptions Legal => LegalOptions.Value;
}

View file

@ -0,0 +1,18 @@
using System.Text;
namespace DataTransfer.Components.Layout;
/// <summary>
/// Encoding for personal data (e-mail addresses, name, postal address) that the server writes into the HTML, so
/// harvesters that scan the HTML find nothing usable. wwwroot/DataTransfer.lib.module.js decodes it in the browser.
/// </summary>
public static class ProtectedData
{
// Reversed UTF-8 bytes in base64: no '@', no readable words and nothing that looks like an address.
public static string Encode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Array.Reverse(bytes);
return Convert.ToBase64String(bytes);
}
}

View file

@ -0,0 +1,12 @@
@* Renders an e-mail address only in encoded form (see ProtectedData), so address harvesters that scan the HTML don't
find it. wwwroot/DataTransfer.lib.module.js turns it into a normal mailto link in the browser. *@
<a class="protected-email" data-protected-email="@ProtectedData.Encode(Address)" data-subject="@Subject"
title="Enable JavaScript to show the e-mail address">…</a>
@code {
[Parameter] [EditorRequired] public string Address { get; set; } = string.Empty;
[Parameter] public string? Subject { get; set; }
}

View file

@ -0,0 +1,10 @@
@* Renders personal data such as the operator's name or postal address only in encoded form (see ProtectedData).
wwwroot/DataTransfer.lib.module.js replaces it with the plain text in the browser. *@
<span class="protected-text" data-protected-text="@ProtectedData.Encode(Value ?? string.Empty)"
title="Enable JavaScript to show this information">…</span>
@code {
[Parameter] [EditorRequired] public string? Value { get; set; }
}

View file

@ -0,0 +1,28 @@
@* Link to the public source code repository, shown as its address without the scheme. Renders nothing if no
Legal:SourceCodeUrl is configured. *@
@inject IOptions<LegalOptions> LegalOptions
@if (LegalOptions.Value.SourceCodeUri is { } uri)
{
<a href="@uri.AbsoluteUri" target="@Target" rel="noopener">
@if (ChildContent is not null)
{
@ChildContent
}
else
{
@Label(uri)
}
</a>
}
@code {
/// <summary>Optional link text; defaults to the repository address.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Target { get; set; }
private static string Label(Uri uri) => (uri.Host + uri.AbsolutePath).TrimEnd('/');
}

View file

@ -0,0 +1,75 @@
@page "/abuse"
@inject IOptions<LegalOptions> LegalOptions
<PageTitle>Report abuse · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Report abuse</h1>
<p class="legal-updated">
If you believe this service was used for illegal content, fraud, harassment, attacks or other misuse, please let
us know.
</p>
<h2>How to report</h2>
<p>
E-mail: <ProtectedEmail Address="@Legal.AbuseEmail" Subject="Abuse report"/>
</p>
<p>Please include as much of the following as you know:</p>
<ul>
<li>date and time of the incident, with time zone;</li>
<li>the connection code, if you know it;</li>
<li>a description of what happened;</li>
<li>how we can reach you for questions (optional; you can also report anonymously).</li>
</ul>
<p class="legal-note">
<strong>Never send us illegal material</strong>, in particular depictions of sexual abuse of children. Describe it
instead, and report it to the police as described below.
</p>
<h2>What we can do</h2>
<p>
Messages and files are encrypted end to end and aren't stored, so we can't view, recover or remove content that
was transmitted. We can end active connections, block network addresses and strengthen our protective measures,
and we cooperate with law enforcement authorities within our legal obligations. We review every report as soon as
possible.
</p>
<h2>Report criminal offences to the authorities</h2>
<ul>
<li>In an emergency, call the police: <strong>117</strong>.</li>
<li>
Illegal pornography, in particular depictions of sexual abuse of children: Federal Office of Police fedpol,
<a href="https://www.fedpol.admin.ch/en/reporting-illegal-pornography" rel="noopener">reporting form</a>.
</li>
<li>
Cyber incidents such as phishing, fraud or attacks: Federal Office for Cybersecurity (BACS),
<a href="https://www.report.ncsc.admin.ch/en/" rel="noopener">reporting form</a>.
</li>
<li>
Other offences: your cantonal police; see also
<a href="https://www.cybercrimepolice.ch" rel="noopener">cybercrimepolice.ch</a>.
</li>
</ul>
<h2>Security vulnerabilities</h2>
<p>
If you found a security vulnerability in this service, please report it confidentially to
<ProtectedEmail Address="@Legal.AbuseEmail" Subject="Security vulnerability"/> and give us reasonable
time to fix it before you publish it.
</p>
@if (Legal.SourceCodeUri is not null)
{
<p>DataTransfer is open source, so you can review the code at <SourceCodeLink/>.</p>
}
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
}

View file

@ -0,0 +1,31 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error · DataTransfer</PageTitle>
<div class="status-page">
<span class="status-page-code">ERROR</span>
<h1>Something went wrong</h1>
<p>An error occurred while processing your request. Please try again.</p>
@if (ShowRequestId)
{
<p>Request ID: <code>@RequestId</code></p>
}
<a class="btn btn-primary" href="">Back to start</a>
</div>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
[PersistentState] public string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized()
{
RequestId ??= Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
}

View file

@ -0,0 +1,68 @@
@page "/imprint"
@inject IOptions<LegalOptions> LegalOptions
<PageTitle>Imprint · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Imprint</h1>
<p class="legal-updated">Information about the operator of this website.</p>
<h2>Operator</h2>
<OperatorContact/>
@if (!string.IsNullOrWhiteSpace(Legal.CompanyId))
{
<h2>Enterprise identification number</h2>
<p>UID: @Legal.CompanyId</p>
}
@if (!string.IsNullOrWhiteSpace(Legal.ResponsiblePerson))
{
<h2>Responsible for the content</h2>
<p><ProtectedText Value="@Legal.ResponsiblePerson"/></p>
}
<h2>Abuse and security</h2>
<p>
To report misuse of this service or a security vulnerability, write to
<ProtectedEmail Address="@Legal.AbuseEmail"/> or see <a href="abuse">Report abuse</a>.
</p>
<h2>Disclaimer</h2>
<p>
We take great care in operating this website and the service, but we give no guarantee that the information is
accurate, complete or up to date, or that the service is available at all times. Liability is limited as set out
in the <a href="terms">terms of use</a>.
</p>
<p>
Links to third-party websites are outside our responsibility. We have no influence on their content and accept
no liability for it.
</p>
<h2>Copyright</h2>
<p>
The content and design of this website belong to the operator unless stated otherwise. Messages and files that
users transmit with the service remain theirs; we can't see them.
</p>
@if (Legal.SourceCodeUri is not null)
{
<h2>Source code</h2>
<p>
DataTransfer is free and open source under the BSD Zero Clause License (0BSD): anyone may read, review, use,
change and share the source code of this service, including the encryption, without conditions. It is
published at <SourceCodeLink/>.
</p>
}
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
}

View file

@ -0,0 +1,11 @@
@page "/not-found"
@layout MainLayout
<PageTitle>Not found · DataTransfer</PageTitle>
<div class="status-page">
<span class="status-page-code">404</span>
<h1>Page not found</h1>
<p>The page you are looking for does not exist.</p>
<a class="btn btn-primary" href="">Start a transfer</a>
</div>

View file

@ -0,0 +1,187 @@
@page "/privacy"
@using System.Globalization
@using DataTransfer.Client.Services
@inject IOptions<LegalOptions> LegalOptions
@inject IOptions<TransferOptions> TransferOptions
<PageTitle>Privacy policy · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Privacy policy</h1>
<p class="legal-updated">Last updated: @LastUpdated</p>
<p>
This privacy policy explains how <ProtectedText Value="@Legal.OperatorName"/> ("we") processes personal data when
you use DataTransfer on this website. It is based on the Swiss Federal Act on Data Protection (FADP). Where the
EU General Data Protection Regulation (GDPR) applies, section 10 applies in addition.
</p>
<h2>1. Controller</h2>
<OperatorContact/>
<p>For questions about data protection, write to <ProtectedEmail Address="@Legal.Email"/>.</p>
<h2>2. In short</h2>
<ul>
<li>Messages and files are encrypted in your browser before they are sent (end to end). We can't read them.</li>
<li>We don't store messages or files. The server only relays the encrypted data while both browsers are connected.</li>
<li>There is no account, no tracking, no analytics, no advertising and no content from third parties.</li>
<li>We use your IP address only briefly, to protect the service against misuse.</li>
</ul>
<h2>3. Data we process</h2>
<h3>3.1 Visiting the website</h3>
<p>
When you open the website, your browser transmits technical data that is needed to deliver it: IP address, date
and time, the requested address and information about your browser (user agent).
@if (Legal.AccessLogRetentionDays > 0)
{
@($"Our server records this data in access logs, which are deleted after {Legal.AccessLogRetentionDays} days.")
}
else
{
@("We don't keep access logs.")
}
</p>
<p>
A technically necessary cookie (<code>.AspNetCore.Antiforgery…</code>) protects against forged requests from
other websites. It contains a random value and no information about you, and it is deleted when you close the
browser. We don't use any other cookies, local storage or similar technologies.
</p>
<p>The page regularly loads the current server load (number of connections). This contains no personal data.</p>
<h3>3.2 Creating and joining a connection</h3>
<p>
To pair two browsers and relay data between them, the server keeps in its memory: a random connection code,
random technical identifiers of the two browsers, temporary public keys for the key exchange, the time of the
last activity and the amount of data relayed.
</p>
<p>
To protect the service against misuse, for example someone occupying all connections, the server counts
connections and connection attempts per network address. For this purpose it keeps your IP address (for IPv6,
its network prefix) in memory while you are connected and for a few minutes afterwards. It isn't written to disk.
The only exception: if a request contains proxy headers from an address that isn't a configured proxy, that
address may be written to the server log (at most once every ten minutes) so that we can detect configuration
errors.
</p>
<p>
Before a connection is created, your browser solves a small computing task (proof of work) to make it harder for
automated programs to create connections in bulk. The task is generated and checked by our server alone; no
third-party service is involved and no data about you or your device is collected for it.
</p>
<h3>3.3 Messages and files</h3>
<p>
Messages and files are encrypted end to end in your browser (key agreement with ECDH P-256, encryption with
AES-256-GCM). The keys never leave the two browsers. The server relays the encrypted data piece by piece to the
other browser without storing it and can't decrypt it. It only sees technical metadata such as the time and size
of the transmitted data.
</p>
<p>
Received messages and files are kept in the memory of the recipient's browser until a new connection is started
or the page is closed. Save files you want to keep.
</p>
<p>
Whether you share personal data in messages or files, your own or other people's, is your decision and your
responsibility.
</p>
<h2>4. Purposes</h2>
<ul>
<li>to provide the service and connect two browsers;</li>
<li>to keep the service secure and stable and to prevent misuse;</li>
<li>to comply with legal obligations.</li>
</ul>
<p>
We don't use your data for marketing or profiling, and we don't make automated individual decisions within the
meaning of Art. 21 FADP.
</p>
<h2>5. Retention</h2>
<ul>
<li>
Connection data is deleted from memory when the connection ends: unused codes after
@DisplayFormat.Duration(Transfer.JoinTimeout), connections without transfers after
@DisplayFormat.Duration(Transfer.InactivityTimeout), and every connection after
@DisplayFormat.Duration(Transfer.MaxConnectionLifetime) at the latest.
</li>
<li>The counters per network address are deleted from memory a few minutes after your last connection.</li>
<li>
@(Legal.AccessLogRetentionDays > 0
? $"Access logs are deleted after {Legal.AccessLogRetentionDays} days."
: "No access logs are kept.")
</li>
<li>Restarting the server deletes everything held in memory.</li>
</ul>
<h2>6. Recipients and processors</h2>
<p>
@if (Legal.IsSelfHosted)
{
@("We run the server ourselves; no hosting provider processes data on our behalf. Internet providers only transport the encrypted connections.")
}
else
{
@($"The service is hosted by {Legal.HostingProvider}, which processes data on our behalf (Art. 9 FADP) and is bound by contract to protect it.")
}
We disclose data to authorities only where we are legally obliged to. We don't sell data.
</p>
<h2>7. Disclosure abroad</h2>
<p>
The servers are located in @(Legal.HostingLocation). If data is processed outside Switzerland, this happens only
in countries the Federal Council recognises as providing adequate data protection (such as the EU and EEA
states), or with appropriate safeguards such as standard contractual clauses (Art. 16 FADP).
</p>
<h2>8. Data security</h2>
<p>
All connections to the website are encrypted (HTTPS). In addition, messages and files are encrypted end to end,
and the server limits connections and connection attempts to prevent misuse.
@if (Legal.SourceCodeUri is not null)
{
<text>DataTransfer is open source, so anyone can check how the encryption works and that the server can't read
your data: <SourceCodeLink/>.</text>
}
No system is completely secure,
however. Compare the safety code with the other person over another channel, for example by phone: only if both
codes match can you be sure that nobody intercepted the connection.
</p>
<h2>9. Your rights</h2>
<p>
Under the FADP you can request information about your personal data (Art. 25 FADP), have incorrect data
corrected, have data deleted, object to its processing and receive certain data in a common electronic format
(Art. 28 FADP). Send your request to <ProtectedEmail Address="@Legal.Email"/>. Because we don't store data
linked to a person and delete connection data after a short time, we usually can't attribute any data to you.
</p>
<p>
You can lodge a complaint with the Federal Data Protection and Information Commissioner (FDPIC),
Feldeggweg 1, 3003 Bern, <a href="https://www.edoeb.admin.ch" rel="noopener">www.edoeb.admin.ch</a>.
</p>
<h2>10. Visitors from the EU and EEA</h2>
<p>
Where the GDPR applies, we process data to provide the service you request (Art. 6 para. 1 let. b GDPR) and
based on our legitimate interest in a secure, stable service without misuse (Art. 6 para. 1 let. f GDPR). You
have the rights under Art. 15 to 21 GDPR and can lodge a complaint with the data protection authority in your
country.
</p>
<h2>11. Changes</h2>
<p>We may update this privacy policy. The version published on this website applies.</p>
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
private TransferOptions Transfer => TransferOptions.Value;
private string LastUpdated => Legal.LastUpdated.ToString("d MMMM yyyy", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,137 @@
@page "/terms"
@using System.Globalization
@using DataTransfer.Client.Services
@inject IOptions<LegalOptions> LegalOptions
@inject IOptions<TransferOptions> TransferOptions
<PageTitle>Terms of use · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Terms of use</h1>
<p class="legal-updated">Last updated: @LastUpdated</p>
<h2>1. Scope</h2>
<p>
These terms govern the use of the DataTransfer service on this website, operated by
<ProtectedText Value="@Legal.OperatorName"/> ("we"). By using the service, you accept these terms. If you don't
agree with them, don't use the service.
</p>
<h2>2. The service</h2>
<p>
DataTransfer connects two browsers with a one-time code and transmits messages and files between them, encrypted
end to end. The service is free of charge and needs no account. We relay the encrypted data but don't store it,
and we can neither read nor recover it.
</p>
@if (Legal.SourceCodeUri is not null)
{
<p>
DataTransfer is open-source software under the BSD Zero Clause License (0BSD); its source code is published
at <SourceCodeLink/>. These terms apply to the service operated on this website, the license to the source
code.
</p>
}
<h2>3. Limits</h2>
<p>To keep the service available for everyone, the following limits currently apply. We may change them at any time.</p>
<ul>
<li>
Files of up to @DisplayFormat.Bytes(TransferProtocol.MaxFileBytes) each; per connection and direction at most
@TransferProtocol.MaxItemsPerConnection.ToString("N0", CultureInfo.InvariantCulture) messages and files and
@DisplayFormat.Bytes(TransferProtocol.MaxFileBytesPerConnection) of files in total.
</li>
<li>
A new code expires after @DisplayFormat.Duration(Transfer.JoinTimeout) unless someone joins. A connection is
closed after @DisplayFormat.Duration(Transfer.InactivityTimeout) without transfers and after
@DisplayFormat.Duration(Transfer.MaxConnectionLifetime) at the latest.
</li>
<li>
At most @Transfer.MaxConnections connections at the same time, and a limited number of connections per
network.
</li>
</ul>
<h2>4. Permitted use</h2>
<p>You may use the service only lawfully and for its intended purpose. In particular, you must not:</p>
<ul>
<li>
transmit illegal content, such as prohibited pornography and in particular depictions of sexual acts involving
minors (Art. 197 Swiss Criminal Code), depictions of violence (Art. 135 SCC), discrimination and incitement to
hatred (Art. 261<sup>bis</sup> SCC), or content that infringes copyrights or other rights of third parties;
</li>
<li>
transmit malware or phishing, or use the service for fraud, harassment, threats or other harm to third
parties;
</li>
<li>
attack, overload or disrupt the service, circumvent its limits or security measures, or use it automatically
or on a mass scale.
</li>
</ul>
<h2>5. Your responsibility</h2>
<ul>
<li>You alone are responsible for the content you transmit and for having the rights to transmit it.</li>
<li>Compare the safety code with the other person over another channel before you send confidential content.</li>
<li>
Keep your own copies. Received files exist only in your browser until you save them, and transfers can be
interrupted at any time.
</li>
<li>Keep your device and browser secure; encryption can't protect data on a compromised device.</li>
</ul>
<h2>6. Measures against misuse</h2>
<p>
We may restrict, interrupt or end connections, block network addresses, or suspend the service at any time and
without notice, in particular if we suspect misuse. As content is encrypted end to end, we can't inspect it. Where
we are legally obliged to, we cooperate with the authorities and disclose the information available to us,
usually only technical data as described in the <a href="privacy">privacy policy</a>. Please report misuse via
our <a href="abuse">abuse contact</a>.
</p>
<h2>7. Availability</h2>
<p>
We provide the service as it is and as available. We don't guarantee that it is always available, free of errors
or suitable for a particular purpose, and we may change or discontinue it at any time.
</p>
<h2>8. Liability</h2>
<p>
To the extent permitted by law, we exclude all liability for damage arising from the use of the service or from
it being unavailable, in particular for interrupted transfers, lost data and content transmitted by other users.
This doesn't apply to damage caused intentionally or through gross negligence (Art. 100 Swiss Code of
Obligations) or where liability is otherwise mandatory by law. You are liable to us for damage resulting from a
breach of these terms.
</p>
<h2>9. Privacy</h2>
<p>How we process personal data is described in the <a href="privacy">privacy policy</a>.</p>
<h2>10. Changes</h2>
<p>We may change these terms at any time. The version published on this website at the time of use applies.</p>
<h2>11. Applicable law and place of jurisdiction</h2>
<p>
These terms are governed by Swiss law, excluding its conflict-of-law rules. The place of jurisdiction is
<ProtectedText Value="@Legal.PlaceOfJurisdiction"/>, Switzerland. Mandatory places of jurisdiction, in particular
for consumers (Art. 32 Swiss Civil Procedure Code), remain reserved.
</p>
<h2>12. Severability</h2>
<p>If any provision of these terms is invalid, the remaining provisions remain in force.</p>
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
private TransferOptions Transfer => TransferOptions.Value;
private string LastUpdated => Legal.LastUpdated.ToString("d MMMM yyyy", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,8 @@
@using DataTransfer.Components.Pages
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }"
NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View file

@ -0,0 +1,12 @@
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using DataTransfer
@using DataTransfer.Client
@using DataTransfer.Client.Components
@using DataTransfer.Client.Shared
@using DataTransfer.Components
@using DataTransfer.Components.Layout
@using DataTransfer.Security
@using DataTransfer.Services
@using Microsoft.Extensions.Options

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.13"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DataTransfer.Client\DataTransfer.Client.csproj"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,94 @@
using System.Collections.Concurrent;
using System.Threading.RateLimiting;
using DataTransfer.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
namespace DataTransfer.Hubs;
/// <summary>
/// Limits every browser to <see cref="TransferOptions.MaxInvocationsPerSecond" /> hub method invocations within any
/// second. Invocations beyond the limit are delayed rather than rejected, which also paces file transfers; only a
/// client that floods the queue gets an error.
/// </summary>
public sealed partial class InvocationRateLimitFilter(
IOptions<TransferOptions> options,
ILogger<InvocationRateLimitFilter> logger) : IHubFilter, IDisposable
{
private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new(StringComparer.Ordinal);
private readonly int _permitsPerSecond = options.Value.MaxInvocationsPerSecond;
public async Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> next)
{
var limiter = new SlidingWindowRateLimiter(
new SlidingWindowRateLimiterOptions
{
PermitLimit = _permitsPerSecond,
Window = TimeSpan.FromSeconds(1),
SegmentsPerWindow = 10,
QueueLimit = _permitsPerSecond,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
if (!_limiters.TryAdd(context.Context.ConnectionId, limiter))
{
await limiter.DisposeAsync();
}
await next(context);
}
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext invocationContext,
Func<HubInvocationContext, ValueTask<object?>> next)
{
var caller = invocationContext.Context;
if (!_limiters.TryGetValue(caller.ConnectionId, out var limiter))
{
throw new HubException("The connection is closing.");
}
using (var lease = await limiter.AcquireAsync(1, caller.ConnectionAborted))
{
if (!lease.IsAcquired)
{
LogInvocationRejected(logger, invocationContext.HubMethodName);
throw new HubException("Too many requests.");
}
}
return await next(invocationContext);
}
public async Task OnDisconnectedAsync(
HubLifetimeContext context,
Exception? exception,
Func<HubLifetimeContext, Exception?, Task> next)
{
try
{
await next(context, exception);
}
finally
{
if (_limiters.TryRemove(context.Context.ConnectionId, out var limiter))
{
await limiter.DisposeAsync();
}
}
}
public void Dispose()
{
foreach (var limiter in _limiters.Values)
{
limiter.Dispose();
}
_limiters.Clear();
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Rejected an invocation of {Method}: the client exceeded its rate limit")]
private static partial void LogInvocationRejected(ILogger logger, string method);
}

View file

@ -0,0 +1,114 @@
using DataTransfer.Client.Shared;
using DataTransfer.Security;
using DataTransfer.Services;
using Microsoft.AspNetCore.SignalR;
namespace DataTransfer.Hubs;
/// <summary>
/// Pairs browsers and relays their frames: first the key exchange, then end-to-end encrypted data. It never sees
/// private keys or plaintext.
/// </summary>
public sealed partial class TransferHub(
ITransferService transferService,
ClientAddressLimiter addressLimiter,
BotCheck botCheck,
ILogger<TransferHub> logger) : Hub<ITransferHubClient>, ITransferHub
{
private static readonly object AddressKey = new();
// Runs for every transport, also for clients that skip the negotiate request and its checks.
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
if (httpContext is not null && !HubConnectionGuard.IsSameOrigin(httpContext.Request))
{
LogRejected(logger, "foreign origin");
Context.Abort();
return;
}
var address = ClientAddress.GetPartitionKey(httpContext?.Connection.RemoteIpAddress);
if (!addressLimiter.TryAcquire(address))
{
// Closed instead of kept open, so idle sockets can't pile up and hold receive buffers.
LogRejected(logger, "address limit");
Context.Abort();
return;
}
Context.Items[AddressKey] = address;
await base.OnConnectedAsync();
}
public Task<BotCheckChallenge?> GetBotCheckChallenge() => Task.FromResult(botCheck.Issue(Context));
public async Task<CreateConnectionResult> CreateConnection(int botCheckSolution)
{
if (Address is not { } address)
{
return NotCreated(CreateStatus.TooManyConnections);
}
if (!botCheck.Verify(Context, botCheckSolution))
{
LogRejected(logger, "bot check failed");
return NotCreated(CreateStatus.BotCheckFailed);
}
var result = await transferService.CreateAsync(Context.ConnectionId, address);
await LeaveIfAbortedAsync();
return result;
}
public async Task<JoinConnectionResult> JoinConnection(string code)
{
if (Address is null)
{
return new JoinConnectionResult(JoinStatus.TooManyConnections, TimeSpan.Zero);
}
var result = await transferService.JoinAsync(Context.ConnectionId, code);
await LeaveIfAbortedAsync();
return result;
}
public Task<SendStatus> Send(byte[] frame) => transferService.SendAsync(Context.ConnectionId, frame);
public Task LeaveConnection() => transferService.LeaveAsync(Context.ConnectionId);
public override async Task OnDisconnectedAsync(Exception? exception)
{
try
{
await transferService.LeaveAsync(Context.ConnectionId);
}
finally
{
if (Address is { } address)
{
addressLimiter.Release(address);
}
}
await base.OnDisconnectedAsync(exception);
}
// Only set for accepted connections; rejected ones are aborted and must not create or join anything.
private string? Address => Context.Items.TryGetValue(AddressKey, out var address) ? address as string : null;
private static CreateConnectionResult NotCreated(CreateStatus status) => new(status, null, TimeSpan.Zero, TimeSpan.Zero, 0);
// The browser may disconnect while a create/join is in flight; don't leave an orphaned registration behind.
private async Task LeaveIfAbortedAsync()
{
if (Context.ConnectionAborted.IsCancellationRequested)
{
await transferService.LeaveAsync(Context.ConnectionId);
}
}
// Debug only: an attacker can trigger this for every connection attempt.
[LoggerMessage(Level = LogLevel.Debug, Message = "Rejected a hub connection: {Reason}")]
private static partial void LogRejected(ILogger logger, string reason);
}

View file

@ -0,0 +1,170 @@
using System.Diagnostics.CodeAnalysis;
using DataTransfer.Client.Shared;
namespace DataTransfer.Models;
/// <summary>
/// A pairing of at most two browsers, identified by their SignalR connection ids. The server only relays opaque
/// frames between them: the plaintext key exchange first, end-to-end encrypted frames afterwards.
/// </summary>
public sealed class Connection
{
private readonly Lock _sync = new();
private readonly DateTimeOffset _createdAt;
private string? _joinerClientId;
private DateTimeOffset _lastActivityAt;
private long _relayedBytes;
private bool _isClosed;
public Connection(string code, string creatorClientId, string creatorAddress, DateTimeOffset createdAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
ArgumentException.ThrowIfNullOrEmpty(creatorClientId);
ArgumentException.ThrowIfNullOrEmpty(creatorAddress);
Code = code;
CreatorClientId = creatorClientId;
CreatorAddress = creatorAddress;
_createdAt = createdAt;
_lastActivityAt = createdAt;
}
public string Code { get; }
public string CreatorClientId { get; }
/// <summary>Network address partition of the creator (see <c>ClientAddress</c>), for the per-address limit.</summary>
public string CreatorAddress { get; }
public bool IsClosed
{
get
{
lock (_sync)
{
return _isClosed;
}
}
}
public IReadOnlyList<string> GetClientIds()
{
lock (_sync)
{
return _joinerClientId is null ? [CreatorClientId] : [CreatorClientId, _joinerClientId];
}
}
/// <summary>Claims the second and last slot of the connection.</summary>
public bool TryJoin(string joinerClientId, DateTimeOffset now)
{
ArgumentException.ThrowIfNullOrEmpty(joinerClientId);
lock (_sync)
{
if (_isClosed || _joinerClientId is not null)
{
return false;
}
_joinerClientId = joinerClientId;
_lastActivityAt = now;
return true;
}
}
/// <summary>
/// Resolves the recipient of a frame sent by <paramref name="senderClientId" /> and records the activity. Closes the
/// connection instead if the frame would exceed <paramref name="maxRelayedBytes" />.
/// </summary>
public bool TryRecordTransmission(
string senderClientId,
int frameBytes,
DateTimeOffset now,
long maxRelayedBytes,
[NotNullWhen(true)] out string? recipientClientId,
out SendStatus failure)
{
lock (_sync)
{
recipientClientId = null;
var isCreator = CreatorClientId == senderClientId;
if (_isClosed || (!isCreator && _joinerClientId != senderClientId))
{
failure = SendStatus.NotConnected;
return false;
}
if (_joinerClientId is null)
{
failure = SendStatus.WaitingForPeer;
return false;
}
if (_relayedBytes + frameBytes > maxRelayedBytes)
{
_isClosed = true;
failure = SendStatus.TransferLimitReached;
return false;
}
_relayedBytes += frameBytes;
recipientClientId = isCreator ? _joinerClientId : CreatorClientId;
_lastActivityAt = now;
failure = SendStatus.Delivered;
return true;
}
}
public bool TryClose()
{
lock (_sync)
{
if (_isClosed)
{
return false;
}
_isClosed = true;
return true;
}
}
/// <summary>
/// Closes the connection atomically if nobody joined within <paramref name="joinTimeout" />, nothing was
/// transmitted within <paramref name="inactivityTimeout" /> or it exists for <paramref name="maxLifetime" />.
/// </summary>
public bool TryCloseIfExpired(
DateTimeOffset now,
TimeSpan joinTimeout,
TimeSpan inactivityTimeout,
TimeSpan maxLifetime,
out ConnectionClosedReason reason)
{
lock (_sync)
{
reason = default;
if (_isClosed)
{
return false;
}
if (now - _createdAt >= maxLifetime)
{
reason = ConnectionClosedReason.MaximumLifetime;
}
else if (now - _lastActivityAt >= (_joinerClientId is null ? joinTimeout : inactivityTimeout))
{
reason = ConnectionClosedReason.Inactivity;
}
else
{
return false;
}
_isClosed = true;
return true;
}
}
}

122
DataTransfer/Program.cs Normal file
View file

@ -0,0 +1,122 @@
using System.Threading.RateLimiting;
using DataTransfer.Client.Shared;
using DataTransfer.Components;
using DataTransfer.Hubs;
using DataTransfer.Security;
using DataTransfer.Services;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using _Imports = DataTransfer.Client._Imports;
var builder = WebApplication.CreateBuilder(args);
// Kestrel only reads endpoints from its configuration section; bind the connection limits explicitly. They cap the
// sockets (and receive buffers) the server keeps open, whatever the per-address limits let through.
builder.WebHost.ConfigureKestrel(options => builder.Configuration.GetSection("Kestrel:Limits").Bind(options.Limits));
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddSingleton<InvocationRateLimitFilter>();
builder.Services.AddSignalR()
.AddHubOptions<TransferHub>(options =>
{
options.MaximumReceiveMessageSize = TransferProtocol.MaxHubMessageBytes;
options.AddFilter<InvocationRateLimitFilter>();
});
builder.Services.AddOptions<TransferOptions>()
.Bind(builder.Configuration.GetSection(TransferOptions.SectionName))
.Validate(options => options.IsValid(), "Transfer options must contain positive time spans and limits and a non-negative queue length.")
.ValidateOnStart();
builder.Services.AddOptions<LegalOptions>()
.Bind(builder.Configuration.GetSection(LegalOptions.SectionName));
builder.Services.AddOptions<BotCheckOptions>()
.Bind(builder.Configuration.GetSection(BotCheckOptions.SectionName))
.Validate(options => options.IsValid(), "Bot check options must contain positive limits, a busy threshold between 0 and 1 and a positive challenge lifetime.")
.ValidateOnStart();
builder.AddConfiguredForwardedHeaders();
builder.Services.AddRateLimiter(_ => { });
builder.Services.AddOptions<RateLimiterOptions>()
.Configure<IOptions<TransferOptions>>((limiter, transfer) =>
{
var attemptsPerMinute = transfer.Value.MaxConnectAttemptsPerMinute;
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = (context, cancellationToken) =>
{
// A content type keeps the status code pages from replacing the response.
context.HttpContext.Response.ContentType = "text/plain";
return new ValueTask(
context.HttpContext.Response.WriteAsync("Too many connection attempts. Please try again later.", cancellationToken));
};
// Browsers start every hub connection with a negotiate request, so this gives them a clear HTTP 429. The hub
// enforces the same limit again for clients that skip the negotiate request.
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
context.Request.Path.StartsWithSegments($"{TransferProtocol.HubPath}/negotiate")
? RateLimitPartition.GetFixedWindowLimiter(
ClientAddress.GetPartitionKey(context.Connection.RemoteIpAddress),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = attemptsPerMinute,
Window = TimeSpan.FromMinutes(1)
})
: RateLimitPartition.GetNoLimiter(string.Empty));
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ClientAddressLimiter>();
builder.Services.AddSingleton<BotCheck>();
builder.Services.AddSingleton<ITransferService, TransferService>();
builder.Services.AddHostedService<ConnectionCleanupService>();
var app = builder.Build();
if (!app.Environment.IsDevelopment() && app.Services.GetRequiredService<IOptions<LegalOptions>>().Value.HasPlaceholders())
{
app.Logger.LogWarning(
"The imprint, privacy policy and terms of use still contain placeholders. Fill in the {Section} configuration section before going live.",
LegalOptions.SectionName);
}
app.UseConfiguredForwardedHeaders();
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseSecurityHeaders(app.Environment.IsDevelopment());
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseHubNegotiateGuard();
app.UseRateLimiter();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapHub<TransferHub>(TransferProtocol.HubPath);
app.MapGet(
TransferProtocol.StatusPath,
(HttpContext context, ITransferService transferService) =>
{
context.Response.Headers.CacheControl = "no-store";
return transferService.GetStatus();
});
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(_Imports).Assembly);
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5133",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7260;http://localhost:5133",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,64 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using DataTransfer.Client.Shared;
using DataTransfer.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
namespace DataTransfer.Security;
/// <summary>
/// Proof of work before a connection is created: the browser has to find the number behind a SHA-256 hash. That
/// costs a person a moment once, but makes creating connections in bulk expensive, and scripts that don't run the
/// page can't create connections at all. Runs entirely on this server, without any third party or personal data.
/// Each challenge belongs to one hub connection, can be used only once and expires.
/// </summary>
public sealed class BotCheck(IOptions<BotCheckOptions> options, ITransferService transferService, TimeProvider timeProvider)
{
private static readonly object ChallengeKey = new();
private readonly BotCheckOptions _options = options.Value;
/// <returns>The challenge to solve, or <c>null</c> if the check is disabled.</returns>
public BotCheckChallenge? Issue(HubCallerContext context)
{
if (!_options.Enabled)
{
return null;
}
var maxNumber = IsBusy() ? _options.BusyMaxNumber : _options.MaxNumber;
var salt = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
var number = RandomNumberGenerator.GetInt32(maxNumber + 1);
// Hub invocations of one connection run one after another, so the item needs no further synchronization.
context.Items[ChallengeKey] = new PendingChallenge(number, timeProvider.GetUtcNow() + _options.ChallengeLifetime);
return new BotCheckChallenge(salt, ComputeHash(salt, number), maxNumber);
}
/// <summary>Checks the solution against the connection's latest challenge, which is used up either way.</summary>
public bool Verify(HubCallerContext context, int solution)
{
if (!_options.Enabled)
{
return true;
}
return context.Items.Remove(ChallengeKey, out var value)
&& value is PendingChallenge pending
&& solution == pending.Number
&& timeProvider.GetUtcNow() <= pending.ExpiresAt;
}
/// <summary>The hash the browser has to reproduce, see <see cref="BotCheckChallenge" />.</summary>
public static string ComputeHash(string salt, int number) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(salt + number.ToString(CultureInfo.InvariantCulture))));
private bool IsBusy()
{
var status = transferService.GetStatus();
return status.QueuedClients > 0 || status.ActiveConnections >= status.MaxConnections * _options.BusyThreshold;
}
private sealed record PendingChallenge(int Number, DateTimeOffset ExpiresAt);
}

View file

@ -0,0 +1,30 @@
namespace DataTransfer.Security;
public sealed class BotCheckOptions
{
public const string SectionName = "BotCheck";
public bool Enabled { get; set; } = true;
/// <summary>
/// Upper bound of the hidden number; a browser tries about half of the numbers on average. A browser computes
/// roughly 100,000 hashes per second and core, so the default takes a fraction of a second on a desktop and up to
/// about three seconds on a weak phone.
/// </summary>
public int MaxNumber { get; set; } = 200_000;
/// <summary>Upper bound used while the server is busy (see <see cref="BusyThreshold" />), to slow down bulk creation.</summary>
public int BusyMaxNumber { get; set; } = 1_000_000;
/// <summary>Share of the connection limit in use from which <see cref="BusyMaxNumber" /> applies; also while clients are queued.</summary>
public double BusyThreshold { get; set; } = 0.8;
/// <summary>A challenge must be solved within this period.</summary>
public TimeSpan ChallengeLifetime { get; set; } = TimeSpan.FromMinutes(2);
public bool IsValid() =>
MaxNumber > 0
&& BusyMaxNumber >= MaxNumber
&& BusyThreshold is > 0 and <= 1
&& ChallengeLifetime > TimeSpan.Zero;
}

View file

@ -0,0 +1,34 @@
using System.Net;
using System.Net.Sockets;
namespace DataTransfer.Security;
public static class ClientAddress
{
/// <summary>
/// Groups remote addresses that one client typically controls: an IPv4 address or an IPv6 /56 prefix. Subscribers
/// usually get a /56 or more, so limiting single IPv6 addresses or /64 networks would be pointless.
/// </summary>
public static string GetPartitionKey(IPAddress? address)
{
if (address is null)
{
return "unknown";
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.AddressFamily != AddressFamily.InterNetworkV6)
{
return address.ToString();
}
Span<byte> bytes = stackalloc byte[16];
address.TryWriteBytes(bytes, out _);
bytes[7..].Clear();
return $"{new IPAddress(bytes)}/56";
}
}

View file

@ -0,0 +1,89 @@
using System.Threading.RateLimiting;
using DataTransfer.Services;
using Microsoft.Extensions.Options;
namespace DataTransfer.Security;
/// <summary>
/// Limits the hub connections per network address (see <see cref="ClientAddress" />): how many may exist at once and
/// how many may be opened per minute. Enforced for every hub connection, so it also covers clients that skip the
/// negotiate request and connect via WebSocket directly.
/// </summary>
public sealed class ClientAddressLimiter : IDisposable
{
private readonly Lock _sync = new();
private readonly Dictionary<string, int> _clientsByAddress = new(StringComparer.Ordinal);
private readonly int _maxClientsPerAddress;
private readonly PartitionedRateLimiter<string> _connectAttempts;
public ClientAddressLimiter(IOptions<TransferOptions> options)
{
var transfer = options.Value;
_maxClientsPerAddress = transfer.MaxClientsPerAddress;
_connectAttempts = PartitionedRateLimiter.Create<string, string>(address =>
RateLimitPartition.GetFixedWindowLimiter(
address,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = transfer.MaxConnectAttemptsPerMinute,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
}
/// <summary>Whether a new hub connection from <paramref name="address" /> would exceed the concurrent limit.</summary>
public bool IsAtCapacity(string address)
{
lock (_sync)
{
return _clientsByAddress.GetValueOrDefault(address) >= _maxClientsPerAddress;
}
}
/// <summary>Counts a new hub connection; <see cref="Release" /> must be called when it closes.</summary>
/// <returns><c>false</c> if the address exceeded its connect rate or concurrent limit.</returns>
public bool TryAcquire(string address)
{
using (var lease = _connectAttempts.AttemptAcquire(address))
{
if (!lease.IsAcquired)
{
return false;
}
}
lock (_sync)
{
var clients = _clientsByAddress.GetValueOrDefault(address);
if (clients >= _maxClientsPerAddress)
{
return false;
}
_clientsByAddress[address] = clients + 1;
return true;
}
}
public void Release(string address)
{
lock (_sync)
{
if (!_clientsByAddress.TryGetValue(address, out var clients))
{
return;
}
if (clients <= 1)
{
_clientsByAddress.Remove(address);
}
else
{
_clientsByAddress[address] = clients - 1;
}
}
}
public void Dispose() => _connectAttempts.Dispose();
}

View file

@ -0,0 +1,108 @@
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
namespace DataTransfer.Security;
/// <summary>
/// Takes the client address from <c>X-Forwarded-For</c> only when the request comes from a configured reverse
/// proxy, so clients can't spoof their address to evade the per-address limits.
/// </summary>
public static partial class ForwardedHeadersSetup
{
private const string SectionName = "ForwardedHeaders";
// Set via ASPNETCORE_FORWARDEDHEADERS_ENABLED (e.g. by container images and some PaaS); the host then adds the
// middleware itself, before the app's pipeline.
private const string HostEnabledKey = "FORWARDEDHEADERS_ENABLED";
private static readonly TimeSpan WarningInterval = TimeSpan.FromMinutes(10);
private static long _nextWarningAt;
public static WebApplicationBuilder AddConfiguredForwardedHeaders(this WebApplicationBuilder builder)
{
var section = builder.Configuration.GetSection(SectionName);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Number of proxies in front of the app; each one appends an entry.
options.ForwardLimit = section.GetValue("ForwardLimit", 1);
foreach (var proxy in GetList(section, "KnownProxies"))
{
options.KnownProxies.Add(IPAddress.Parse(proxy));
}
foreach (var network in GetList(section, "KnownNetworks"))
{
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(network));
}
});
return builder;
}
public static WebApplication UseConfiguredForwardedHeaders(this WebApplication app)
{
var section = app.Configuration.GetSection(SectionName);
var hasConfiguredProxies = GetList(section, "KnownProxies").Length > 0 || GetList(section, "KnownNetworks").Length > 0;
var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger(typeof(ForwardedHeadersSetup).FullName!);
if (app.Configuration.GetValue<bool>(HostEnabledKey))
{
// The host already ran the middleware; a second pass would accept an entry supplied by the client.
if (!hasConfiguredProxies)
{
LogTrustingAllProxies(logger);
}
}
else
{
app.UseForwardedHeaders();
}
if (!hasConfiguredProxies)
{
app.Use(async (context, next) =>
{
WarnIfFromUntrustedProxy(context, logger);
await next(context);
});
}
return app;
}
private static string[] GetList(IConfiguration section, string key) => section.GetSection(key).Get<string[]>() ?? [];
// The middleware records X-Original-For whenever it applied X-Forwarded-For; a remaining header without it came
// from an untrusted sender: either a spoofing client or a reverse proxy that still needs to be configured.
private static void WarnIfFromUntrustedProxy(HttpContext context, ILogger logger)
{
var headers = context.Request.Headers;
if (!headers.ContainsKey("X-Forwarded-For") || headers.ContainsKey("X-Original-For"))
{
return;
}
var now = Environment.TickCount64;
var nextWarningAt = Interlocked.Read(ref _nextWarningAt);
if (now >= nextWarningAt
&& Interlocked.CompareExchange(ref _nextWarningAt, now + (long)WarningInterval.TotalMilliseconds, nextWarningAt) == nextWarningAt)
{
LogUntrustedForwardedHeader(logger, context.Connection.RemoteIpAddress);
}
}
[LoggerMessage(
Level = LogLevel.Warning,
Message = "ASPNETCORE_FORWARDEDHEADERS_ENABLED is set without ForwardedHeaders:KnownProxies or KnownNetworks, so " +
"X-Forwarded-For is accepted from any sender. Make sure the app is only reachable through the reverse proxy.")]
private static partial void LogTrustingAllProxies(ILogger logger);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Ignored X-Forwarded-For from {RemoteAddress}, which is not a trusted proxy. If the app runs behind a " +
"reverse proxy, add it to ForwardedHeaders:KnownProxies or KnownNetworks; otherwise all clients share the " +
"proxy's address limits.")]
private static partial void LogUntrustedForwardedHeader(ILogger logger, IPAddress? remoteAddress);
}

View file

@ -0,0 +1,57 @@
using DataTransfer.Client.Shared;
namespace DataTransfer.Security;
/// <summary>Checks requests that open hub connections before SignalR handles them.</summary>
public static class HubConnectionGuard
{
/// <summary>
/// Browsers always send <c>Origin</c> for WebSockets, so a foreign origin means another website is using its
/// visitors' browsers (and network addresses) to connect. Requests without the header aren't from a cross-site
/// browser context and are allowed.
/// </summary>
public static bool IsSameOrigin(HttpRequest request)
{
var origin = request.Headers.Origin.ToString();
if (origin.Length == 0)
{
return true;
}
return Uri.TryCreate(origin, UriKind.Absolute, out var originUri)
&& string.Equals(originUri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Rejects negotiate requests from foreign origins and from addresses that are at their concurrent limit, so
/// browsers get a clear HTTP error instead of a connection that is closed right away.
/// </summary>
public static IApplicationBuilder UseHubNegotiateGuard(this IApplicationBuilder app)
{
var negotiatePath = new PathString($"{TransferProtocol.HubPath}/negotiate");
return app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments(negotiatePath))
{
if (!IsSameOrigin(context.Request))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync("Cross-origin connections are not allowed.");
return;
}
var limiter = context.RequestServices.GetRequiredService<ClientAddressLimiter>();
if (limiter.IsAtCapacity(ClientAddress.GetPartitionKey(context.Connection.RemoteIpAddress)))
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync("Too many connections from your network. Please try again later.");
return;
}
}
await next(context);
});
}
}

View file

@ -0,0 +1,78 @@
using System.Security.Cryptography;
namespace DataTransfer.Security;
/// <summary>
/// Adds a strict Content Security Policy and related headers. The served code is the trust anchor of the
/// end-to-end encryption, so injected script must never run.
/// </summary>
public static class SecurityHeaders
{
private static readonly object NonceKey = new();
public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app, bool isDevelopment)
{
return app.Use(async (context, next) =>
{
// Error and status code pages re-execute the pipeline for the same request; keep its nonce.
if (GetNonce(context) is null)
{
var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
context.Items[NonceKey] = nonce;
// OnStarting also covers error responses, whose headers are cleared by the exception handler.
context.Response.OnStarting(() =>
{
Apply(context, nonce, isDevelopment);
return Task.CompletedTask;
});
}
await next(context);
});
}
/// <summary>The nonce that allows the inline import map of the current request.</summary>
public static string? GetNonce(HttpContext? context) =>
context?.Items.TryGetValue(NonceKey, out var nonce) == true ? nonce as string : null;
private static void Apply(HttpContext context, string nonce, bool isDevelopment)
{
var headers = context.Response.Headers;
// The WebAssembly debugger page relies on inline scripts.
if (!(isDevelopment && context.Request.Path.StartsWithSegments("/_framework/debug")))
{
headers.ContentSecurityPolicy = BuildContentSecurityPolicy(nonce, isDevelopment);
}
headers.XContentTypeOptions = "nosniff";
headers.XFrameOptions = "DENY";
headers["Referrer-Policy"] = "no-referrer";
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()";
headers["Cross-Origin-Opener-Policy"] = "same-origin";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
}
private static string BuildContentSecurityPolicy(string nonce, bool isDevelopment)
{
// Development tooling (browser refresh) talks to localhost on other ports.
var connectSources = isDevelopment ? "'self' ws://localhost:* wss://localhost:*" : "'self'";
return string.Join(
"; ",
"default-src 'self'",
// WebAssembly compilation needs 'wasm-unsafe-eval'; the nonce allows only the generated import map.
$"script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce}'",
"style-src 'self'",
// Progress bars set their width via style attributes; <style> elements stay forbidden.
"style-src-attr 'unsafe-inline'",
"img-src 'self'",
"font-src 'self'",
$"connect-src {connectSources}",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'");
}
}

View file

@ -0,0 +1,45 @@
using Microsoft.Extensions.Options;
namespace DataTransfer.Services;
/// <summary>
/// Periodically removes connections that have not transmitted anything within the inactivity timeout or exceeded
/// their maximum lifetime.
/// </summary>
public sealed partial class ConnectionCleanupService(
ITransferService transferService,
IOptions<TransferOptions> options,
TimeProvider timeProvider,
ILogger<ConnectionCleanupService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(options.Value.CleanupInterval, timeProvider);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
var removed = await transferService.RemoveExpiredConnectionsAsync(stoppingToken);
if (removed > 0)
{
LogConnectionsRemoved(logger, removed);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogCleanupFailed(logger, ex);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
}
[LoggerMessage(Level = LogLevel.Information, Message = "Removed {Count} expired connection(s)")]
private static partial void LogConnectionsRemoved(ILogger logger, int count);
[LoggerMessage(Level = LogLevel.Error, Message = "Removing inactive connections failed")]
private static partial void LogCleanupFailed(ILogger logger, Exception exception);
}

View file

@ -0,0 +1,35 @@
using DataTransfer.Client.Shared;
namespace DataTransfer.Services;
/// <summary>Registry of all connections. <c>clientId</c> is the SignalR connection id of a browser.</summary>
public interface ITransferService
{
ServerStatus GetStatus();
/// <summary>
/// Creates a new connection, or queues the request if the connection limit is reached; any previous connection or
/// queue entry of the client is removed first. Queued clients are notified once their connection is created.
/// Rejected if the creator's network address already has <see cref="TransferOptions.MaxConnectionsPerAddress" />
/// connections or queued requests.
/// </summary>
Task<CreateConnectionResult> CreateAsync(string clientId, string address);
/// <summary>Joins a connection as the second and last participant; any previous connection of the client is closed first.</summary>
Task<JoinConnectionResult> JoinAsync(string clientId, string code);
/// <summary>
/// Relays an opaque frame to the peer of the client. Closes the connection once it would exceed
/// <see cref="TransferOptions.MaxRelayedBytesPerConnection" />.
/// </summary>
Task<SendStatus> SendAsync(string clientId, byte[] frame);
/// <summary>Closes the client's connection or removes its queued create request, and notifies the peer.</summary>
Task LeaveAsync(string clientId);
/// <summary>
/// Removes all connections nobody joined within the join timeout, without a transmission within the inactivity
/// timeout or older than the maximum lifetime.
/// </summary>
Task<int> RemoveExpiredConnectionsAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,62 @@
namespace DataTransfer.Services;
/// <summary>
/// Operator details shown in the imprint, privacy policy, terms of use and abuse page. Values in square brackets
/// are placeholders that must be replaced before the service goes live.
/// </summary>
public sealed class LegalOptions
{
public const string SectionName = "Legal";
/// <summary>Full name of the person operating the service, or the company name.</summary>
public string OperatorName { get; set; } = string.Empty;
/// <summary>Postal address, one entry per line (street and number, postal code and city, country).</summary>
public string[] Address { get; set; } = [];
public string Email { get; set; } = string.Empty;
/// <summary>Address for abuse reports and security vulnerabilities.</summary>
public string AbuseEmail { get; set; } = string.Empty;
public string? Phone { get; set; }
/// <summary>Enterprise identification number (UID, e.g. CHE-123.456.789) of a company; leave empty as a private person.</summary>
public string? CompanyId { get; set; }
/// <summary>Person responsible for the content, if it isn't evident from <see cref="OperatorName" />.</summary>
public string? ResponsiblePerson { get; set; }
/// <summary>The company hosting the server; leave empty if you run the server yourself.</summary>
public string? HostingProvider { get; set; }
public bool IsSelfHosted => string.IsNullOrWhiteSpace(HostingProvider);
/// <summary>Country where the servers are located, e.g. "Switzerland".</summary>
public string HostingLocation { get; set; } = string.Empty;
/// <summary>Days the hosting infrastructure keeps access logs (IP address, time, URL); <c>0</c> if none are kept.</summary>
public int AccessLogRetentionDays { get; set; }
/// <summary>The operator's municipality of residence, or the city of the company's registered office.</summary>
public string PlaceOfJurisdiction { get; set; } = string.Empty;
/// <summary>Date the legal texts were last changed; update it whenever they change.</summary>
public DateOnly LastUpdated { get; set; }
/// <summary>Public repository with the source code of the service; leave empty to hide all source code links.</summary>
public string? SourceCodeUrl { get; set; }
/// <summary>The <see cref="SourceCodeUrl" /> if it is an absolute http(s) address, otherwise <c>null</c>.</summary>
public Uri? SourceCodeUri =>
Uri.TryCreate(SourceCodeUrl, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)
? uri
: null;
/// <summary>Whether a required value is missing or still a placeholder.</summary>
public bool HasPlaceholders() =>
new[] { OperatorName, Email, AbuseEmail, HostingLocation, PlaceOfJurisdiction }
.Concat(Address.Length > 0 ? Address : [string.Empty])
.Any(value => string.IsNullOrWhiteSpace(value) || value.Contains('['))
|| HostingProvider?.Contains('[') == true;
}

View file

@ -0,0 +1,57 @@
namespace DataTransfer.Services;
public sealed class TransferOptions
{
public const string SectionName = "Transfer";
/// <summary>A connection is removed when no message or file was transmitted within this period.</summary>
public TimeSpan InactivityTimeout { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>A new code expires unless someone joins within this period, so unused codes don't block slots.</summary>
public TimeSpan JoinTimeout { get; set; } = TimeSpan.FromMinutes(3);
public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>Maximum number of simultaneous connections (pairs of two browsers). Further create requests are queued.</summary>
public int MaxConnections { get; set; } = 100;
/// <summary>Maximum number of create requests waiting for a free connection slot; <c>0</c> disables the queue.</summary>
public int MaxQueueLength { get; set; } = 50;
/// <summary>A connection is closed after this period even if it is still active.</summary>
public TimeSpan MaxConnectionLifetime { get; set; } = TimeSpan.FromHours(2);
/// <summary>A connection is closed once it relayed this many bytes (both directions combined).</summary>
public long MaxRelayedBytesPerConnection { get; set; } = 2L * 1024 * 1024 * 1024;
/// <summary>
/// Maximum number of simultaneously connected browsers per network address (IPv4 address or IPv6 /56 prefix).
/// Further hub connections are closed immediately.
/// </summary>
public int MaxClientsPerAddress { get; set; } = 20;
/// <summary>
/// Maximum number of connections and queued create requests per network address, so a few addresses can't
/// occupy all slots. Joining a connection doesn't count.
/// </summary>
public int MaxConnectionsPerAddress { get; set; } = 3;
/// <summary>Maximum number of new hub connections per network address and minute.</summary>
public int MaxConnectAttemptsPerMinute { get; set; } = 60;
/// <summary>Maximum number of hub method invocations per browser and second; further invocations are delayed.</summary>
public int MaxInvocationsPerSecond { get; set; } = 10;
public bool IsValid() =>
InactivityTimeout > TimeSpan.Zero
&& JoinTimeout > TimeSpan.Zero
&& CleanupInterval > TimeSpan.Zero
&& MaxConnections > 0
&& MaxQueueLength >= 0
&& MaxConnectionLifetime > TimeSpan.Zero
&& MaxRelayedBytesPerConnection > 0
&& MaxClientsPerAddress > 0
&& MaxConnectionsPerAddress > 0
&& MaxConnectAttemptsPerMinute > 0
&& MaxInvocationsPerSecond > 0;
}

View file

@ -0,0 +1,395 @@
using System.Collections.Concurrent;
using DataTransfer.Client.Shared;
using DataTransfer.Hubs;
using DataTransfer.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
namespace DataTransfer.Services;
/// <summary>
/// In-memory registry of all connections. Registered as a singleton, so up to
/// <see cref="TransferOptions.MaxConnections" /> pairs can be connected in parallel; each connection is isolated by
/// its unique code and holds at most two participants. Further create requests wait in a FIFO queue and are admitted
/// as soon as a connection closes. The server never sees keys: the browsers exchange them through the relayed frames
/// and encrypt everything else end to end.
/// </summary>
public sealed partial class TransferService(
IHubContext<TransferHub, ITransferHubClient> hubContext,
IOptions<TransferOptions> options,
TimeProvider timeProvider,
ILogger<TransferService> logger) : ITransferService
{
private readonly ConcurrentDictionary<string, Connection> _connectionsByCode = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, Connection> _connectionsByClient = new(StringComparer.Ordinal);
// Guards adding connections, the queue and the per-address counts, so the limits hold and nobody can skip the queue.
private readonly Lock _admissionSync = new();
// Queued create requests, oldest first.
private readonly List<QueuedClient> _queue = [];
// Connections plus queued create requests per creator address; guarded by _admissionSync.
private readonly Dictionary<string, int> _entriesByAddress = new(StringComparer.Ordinal);
private readonly TransferOptions _options = options.Value;
public ServerStatus GetStatus()
{
lock (_admissionSync)
{
return new ServerStatus(_connectionsByCode.Count, _options.MaxConnections, _queue.Count);
}
}
public async Task<CreateConnectionResult> CreateAsync(string clientId, string address)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
ArgumentException.ThrowIfNullOrEmpty(address);
await LeaveAsync(clientId);
CreateConnectionResult result;
int activeConnections;
lock (_admissionSync)
{
if (_entriesByAddress.GetValueOrDefault(address) >= _options.MaxConnectionsPerAddress)
{
result = NotCreated(CreateStatus.TooManyConnections);
}
// A non-empty queue means a freed slot is about to be handed to the first queued client.
else if (_queue.Count == 0 && _connectionsByCode.Count < _options.MaxConnections)
{
result = Register(clientId, address);
AddAddressEntry(address);
}
else if (_queue.Count < _options.MaxQueueLength)
{
_queue.Add(new QueuedClient(clientId, address));
AddAddressEntry(address);
result = NotCreated(CreateStatus.Queued, _queue.Count);
}
else
{
result = NotCreated(CreateStatus.ServerBusy);
}
activeConnections = _connectionsByCode.Count;
}
switch (result.Status)
{
case CreateStatus.Created:
LogConnectionCreated(logger, activeConnections);
break;
case CreateStatus.Queued:
LogClientQueued(logger, result.QueuePosition);
break;
case CreateStatus.TooManyConnections:
LogAddressLimitReached(logger);
break;
default:
LogServerBusy(logger, activeConnections);
break;
}
return result;
}
public async Task<JoinConnectionResult> JoinAsync(string clientId, string code)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
await LeaveAsync(clientId);
if (!ConnectionCode.TryNormalize(code, out var normalizedCode))
{
return Failed(JoinStatus.InvalidCode);
}
if (!_connectionsByCode.TryGetValue(normalizedCode, out var connection))
{
return Failed(JoinStatus.NotFound);
}
if (!connection.TryJoin(clientId, timeProvider.GetUtcNow()))
{
return Failed(connection.IsClosed ? JoinStatus.NotFound : JoinStatus.AlreadyInUse);
}
_connectionsByClient[clientId] = connection;
if (connection.IsClosed)
{
_connectionsByClient.TryRemove(new KeyValuePair<string, Connection>(clientId, connection));
return Failed(JoinStatus.NotFound);
}
// Sent before the join result, so the creator learns about the peer before the key exchange starts.
await NotifyAsync(() => hubContext.Clients.Client(connection.CreatorClientId).PeerJoined());
LogConnectionJoined(logger);
return new JoinConnectionResult(JoinStatus.Joined, _options.InactivityTimeout);
}
public async Task<SendStatus> SendAsync(string clientId, byte[] frame)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
if (frame is not { Length: > 0 } || frame.Length > TransferProtocol.MaxFrameBytes)
{
return SendStatus.InvalidSize;
}
if (!_connectionsByClient.TryGetValue(clientId, out var connection))
{
return SendStatus.NotConnected;
}
if (!connection.TryRecordTransmission(
clientId,
frame.Length,
timeProvider.GetUtcNow(),
_options.MaxRelayedBytesPerConnection,
out var recipientClientId,
out var failure))
{
if (failure == SendStatus.TransferLimitReached)
{
await CompleteCloseAsync(connection, ConnectionClosedReason.TransferLimitReached, null);
}
return failure;
}
await hubContext.Clients.Client(recipientClientId).Receive(frame);
return SendStatus.Delivered;
}
public async Task LeaveAsync(string clientId)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
if (TryRemoveFromQueue(clientId, out List<QueuePosition> movedUp))
{
await NotifyQueuePositionsAsync(movedUp);
return;
}
if (_connectionsByClient.TryRemove(clientId, out var connection) && connection.TryClose())
{
await CompleteCloseAsync(connection, ConnectionClosedReason.PeerLeft, clientId);
}
}
public async Task<int> RemoveExpiredConnectionsAsync(CancellationToken cancellationToken = default)
{
var now = timeProvider.GetUtcNow();
var closing = new List<Task>();
foreach (var (_, connection) in _connectionsByCode)
{
cancellationToken.ThrowIfCancellationRequested();
if (connection.TryCloseIfExpired(
now,
_options.JoinTimeout,
_options.InactivityTimeout,
_options.MaxConnectionLifetime,
out var reason))
{
closing.Add(CompleteCloseAsync(connection, reason, null));
}
}
await Task.WhenAll(closing);
return closing.Count;
}
private static JoinConnectionResult Failed(JoinStatus status) => new(status, TimeSpan.Zero);
private static CreateConnectionResult NotCreated(CreateStatus status, int queuePosition = 0) =>
new(status, null, TimeSpan.Zero, TimeSpan.Zero, queuePosition);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection created. Active connections: {ActiveConnections}")]
private static partial void LogConnectionCreated(ILogger logger, int activeConnections);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection limit reached, create request queued at position {Position}")]
private static partial void LogClientQueued(ILogger logger, int position);
[LoggerMessage(Level = LogLevel.Warning, Message = "Connection limit and queue are full, create request rejected. Active connections: {ActiveConnections}")]
private static partial void LogServerBusy(ILogger logger, int activeConnections);
[LoggerMessage(Level = LogLevel.Debug, Message = "Create request rejected: the network address already has the maximum number of connections")]
private static partial void LogAddressLimitReached(ILogger logger);
[LoggerMessage(Level = LogLevel.Information, Message = "{Count} queued client(s) admitted. Active connections: {ActiveConnections}")]
private static partial void LogQueuedClientsAdmitted(ILogger logger, int count, int activeConnections);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection joined")]
private static partial void LogConnectionJoined(ILogger logger);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection closed ({Reason}). Active connections: {ActiveConnections}")]
private static partial void LogConnectionClosed(ILogger logger, ConnectionClosedReason reason, int activeConnections);
[LoggerMessage(Level = LogLevel.Debug, Message = "Notifying a client failed")]
private static partial void LogNotificationFailed(ILogger logger, Exception exception);
private async Task CompleteCloseAsync(Connection connection, ConnectionClosedReason reason, string? excludedClientId)
{
_connectionsByCode.TryRemove(new KeyValuePair<string, Connection>(connection.Code, connection));
lock (_admissionSync)
{
RemoveAddressEntry(connection.CreatorAddress);
}
var recipients = new List<string>(2);
foreach (var participantClientId in connection.GetClientIds())
{
_connectionsByClient.TryRemove(new KeyValuePair<string, Connection>(participantClientId, connection));
if (participantClientId != excludedClientId)
{
recipients.Add(participantClientId);
}
}
LogConnectionClosed(logger, reason, _connectionsByCode.Count);
try
{
if (recipients.Count > 0)
{
await hubContext.Clients.Clients(recipients).ConnectionClosed(reason);
}
}
catch (Exception ex)
{
LogNotificationFailed(logger, ex);
}
await AdmitQueuedClientsAsync();
}
/// <summary>Creates the connection of a client; the caller must hold <see cref="_admissionSync" />.</summary>
private CreateConnectionResult Register(string clientId, string address)
{
while (true)
{
var connection = new Connection(ConnectionCode.Generate(), clientId, address, timeProvider.GetUtcNow());
if (_connectionsByCode.TryAdd(connection.Code, connection))
{
_connectionsByClient[clientId] = connection;
return new CreateConnectionResult(
CreateStatus.Created,
connection.Code,
_options.JoinTimeout,
_options.InactivityTimeout,
0);
}
}
}
// A queued request keeps its entry when it is admitted; the entry is removed when the connection closes.
private void AddAddressEntry(string address) =>
_entriesByAddress[address] = _entriesByAddress.GetValueOrDefault(address) + 1;
private void RemoveAddressEntry(string address)
{
if (!_entriesByAddress.TryGetValue(address, out var entries))
{
return;
}
if (entries <= 1)
{
_entriesByAddress.Remove(address);
}
else
{
_entriesByAddress[address] = entries - 1;
}
}
private bool TryRemoveFromQueue(string clientId, out List<QueuePosition> movedUp)
{
lock (_admissionSync)
{
var index = _queue.FindIndex(queued => queued.ClientId == clientId);
if (index < 0)
{
movedUp = [];
return false;
}
RemoveAddressEntry(_queue[index].Address);
_queue.RemoveAt(index);
movedUp = GetQueuePositions(index);
return true;
}
}
/// <summary>Hands free slots to the clients at the head of the queue.</summary>
private async Task AdmitQueuedClientsAsync()
{
var admitted = new List<(string ClientId, CreateConnectionResult Result)>();
List<QueuePosition> movedUp;
int activeConnections;
lock (_admissionSync)
{
while (_queue.Count > 0 && _connectionsByCode.Count < _options.MaxConnections)
{
var next = _queue[0];
_queue.RemoveAt(0);
admitted.Add((next.ClientId, Register(next.ClientId, next.Address)));
}
movedUp = admitted.Count > 0 ? GetQueuePositions(0) : [];
activeConnections = _connectionsByCode.Count;
}
if (admitted.Count == 0)
{
return;
}
LogQueuedClientsAdmitted(logger, admitted.Count, activeConnections);
// A client that disconnected meanwhile is cleaned up by its own LeaveAsync call from the hub.
await Task.WhenAll(
admitted.Select(entry =>
NotifyAsync(() => hubContext.Clients.Client(entry.ClientId).ConnectionCreated(entry.Result))));
await NotifyQueuePositionsAsync(movedUp);
}
/// <summary>Returns the 1-based positions of all queued clients from <paramref name="startIndex" /> on.</summary>
private List<QueuePosition> GetQueuePositions(int startIndex)
{
var positions = new List<QueuePosition>(_queue.Count - startIndex);
for (var i = startIndex; i < _queue.Count; i++)
{
positions.Add(new QueuePosition(_queue[i].ClientId, i + 1));
}
return positions;
}
private Task NotifyQueuePositionsAsync(List<QueuePosition> positions) =>
positions.Count == 0
? Task.CompletedTask
: Task.WhenAll(
positions.Select(entry =>
NotifyAsync(() => hubContext.Clients.Client(entry.ClientId).QueuePositionChanged(entry.Position))));
private async Task NotifyAsync(Func<Task> notify)
{
try
{
await notify();
}
catch (Exception ex)
{
LogNotificationFailed(logger, ex);
}
}
private sealed record QueuedClient(string ClientId, string Address);
private readonly record struct QueuePosition(string ClientId, int Position);
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,59 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "localhost;127.0.0.1;[::1]",
"Kestrel": {
"Limits": {
"MaxConcurrentConnections": 2000,
"MaxConcurrentUpgradedConnections": 1000
}
},
"Transfer": {
"InactivityTimeout": "00:10:00",
"JoinTimeout": "00:03:00",
"CleanupInterval": "00:00:15",
"MaxConnections": 100,
"MaxQueueLength": 50,
"MaxConnectionLifetime": "02:00:00",
"MaxRelayedBytesPerConnection": 2147483648,
"MaxClientsPerAddress": 20,
"MaxConnectionsPerAddress": 3,
"MaxConnectAttemptsPerMinute": 60,
"MaxInvocationsPerSecond": 10
},
"BotCheck": {
"Enabled": true,
"MaxNumber": 200000,
"BusyMaxNumber": 1000000,
"BusyThreshold": 0.8,
"ChallengeLifetime": "00:02:00"
},
"ForwardedHeaders": {
"ForwardLimit": 1,
"KnownProxies": [],
"KnownNetworks": []
},
"Legal": {
"OperatorName": "[Your full name or company name]",
"Address": [
"[Street and number]",
"[Postal code and city]",
"Switzerland"
],
"Email": "[contact e-mail address]",
"AbuseEmail": "[abuse e-mail address]",
"Phone": "",
"CompanyId": "",
"ResponsiblePerson": "",
"HostingProvider": "",
"HostingLocation": "Switzerland",
"AccessLogRetentionDays": 14,
"PlaceOfJurisdiction": "[Municipality]",
"LastUpdated": "2026-10-15",
"SourceCodeUrl": "https://git.sithis.ch/Sithis/DataTransfer"
}
}

View file

@ -0,0 +1,30 @@
// Blazor JS initializer of the server app. Reveals the personal data (e-mail addresses, name, postal address) that the
// server renders only in encoded form (Components/Layout/ProtectedData.cs), on the first load and after every enhanced
// navigation.
export function afterWebStarted(blazor) {
revealProtectedData();
blazor.addEventListener('enhancedload', revealProtectedData);
}
function decode(encoded) {
const bytes = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)).reverse();
return new TextDecoder().decode(bytes);
}
function revealProtectedData() {
for (const link of document.querySelectorAll('a[data-protected-email]')) {
const address = decode(link.dataset.protectedEmail);
const subject = link.dataset.subject;
link.href = `mailto:${address}${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
link.textContent = address;
link.removeAttribute('title');
link.removeAttribute('data-protected-email');
}
for (const element of document.querySelectorAll('[data-protected-text]')) {
element.textContent = decode(element.dataset.protectedText);
element.removeAttribute('title');
element.removeAttribute('data-protected-text');
}
}

View file

@ -0,0 +1,571 @@
/* ---------- Design tokens ---------- */
:root {
color-scheme: light;
--bg: #eceff2;
--surface: #ffffff;
--surface-2: #f5f7f9;
--border: #d9dee3;
--border-strong: #b9c1c9;
--text: #1b2127;
--text-muted: #5b6672;
--primary: #1a5fb4;
--primary-hover: #154f97;
--accent: #1a5fb4;
--accent-soft: #e5edf8;
--success: #1f7a3a;
--success-soft: #e3f2e7;
--warning: #9a5b00;
--warning-soft: #fcf1dc;
--danger: #b9302a;
--danger-soft: #fbeceb;
--danger-border: #eec3c0;
--header-bg: #1d2329;
--header-text: #eef1f4;
--header-muted: #a3adb7;
--radius: 4px;
--radius-lg: 6px;
--focus-ring: 0 0 0 3px rgb(26 95 180 / 0.25);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #14181c;
--surface: #1c2126;
--surface-2: #22282e;
--border: #313941;
--border-strong: #46505a;
--text: #e2e7eb;
--text-muted: #96a1ac;
--primary: #2a6fc4;
--primary-hover: #3a7fd3;
--accent: #6aa5ea;
--accent-soft: #1d2e44;
--success: #5cba76;
--success-soft: #1a2e21;
--warning: #e0a63c;
--warning-soft: #352a15;
--danger: #f07a73;
--danger-soft: #3a1f1e;
--danger-border: #5c2d2a;
--header-bg: #0e1114;
--focus-ring: 0 0 0 3px rgb(106 165 234 / 0.3);
}
}
/* ---------- Base ---------- */
*, *::before, *::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font);
font-size: 0.9375rem;
line-height: 1.5;
color: var(--text);
background-color: var(--bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, p, ol, ul, dl, dd {
margin: 0;
}
h1:focus {
outline: none;
}
a {
color: var(--accent);
}
button, input, textarea, select {
font: inherit;
color: inherit;
}
code, kbd, .mono {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.mono {
font-size: 0.95em;
}
kbd {
display: inline-block;
padding: 0 0.3125rem;
font-size: 0.6875rem;
line-height: 1.5;
border: 1px solid var(--border-strong);
border-bottom-width: 2px;
border-radius: 3px;
background-color: var(--surface);
}
.muted {
color: var(--text-muted);
}
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* ---------- Icons ---------- */
.icon {
flex: none;
width: 1.125rem;
height: 1.125rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
vertical-align: middle;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4375rem;
height: 2.25rem;
padding: 0 0.875rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background-color: var(--surface);
color: var(--text);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover:not(:disabled) {
background-color: var(--surface-2);
border-color: var(--text-muted);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn .icon {
width: 1rem;
height: 1rem;
}
.btn-primary {
background-color: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-danger {
color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background-color: var(--danger-soft);
border-color: var(--danger);
}
.btn-ghost {
background-color: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.btn-sm {
height: 1.875rem;
padding: 0 0.625rem;
font-size: 0.8125rem;
}
.btn-icon {
width: 1.875rem;
padding: 0;
}
/* ---------- Form fields ---------- */
.input {
display: block;
width: 100%;
min-width: 0;
padding: 0.4375rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background-color: var(--surface);
color: var(--text);
line-height: 1.5;
transition: border-color 0.12s, box-shadow 0.12s;
}
.input::placeholder {
color: var(--text-muted);
opacity: 0.75;
}
.input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
textarea.input {
resize: vertical;
}
.field-label {
display: block;
font-size: 0.8125rem;
font-weight: 600;
}
/* ---------- Panels ---------- */
.panel {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 1px 2px rgb(16 24 32 / 0.04);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background-color: var(--surface-2);
}
.panel-title {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.panel-body {
padding: 1rem;
}
.panel-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
background-color: var(--surface-2);
}
/* ---------- Feedback ---------- */
.notice {
display: flex;
align-items: flex-start;
gap: 0.625rem;
margin-bottom: 1rem;
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: var(--radius);
background-color: var(--surface);
}
.notice > .icon {
margin-top: 0.1875rem;
}
.notice-text {
flex: 1;
min-width: 0;
}
.notice .btn {
margin: -0.1875rem -0.25rem -0.1875rem 0;
}
.notice-danger {
border-color: var(--danger-border);
border-left-color: var(--danger);
background-color: var(--danger-soft);
}
.notice-danger > .icon {
color: var(--danger);
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.status::before {
content: "";
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: currentColor;
}
.status-online {
color: var(--success);
}
.status-online::before {
box-shadow: 0 0 0 3px var(--success-soft);
}
.status-offline {
color: var(--text-muted);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
height: 1.5rem;
padding: 0 0.5rem;
border-radius: 3px;
background-color: var(--surface-2);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.tag .icon {
width: 0.875rem;
height: 0.875rem;
stroke-width: 2.5;
}
.tag-success {
background-color: var(--success-soft);
color: var(--success);
}
.tag-danger {
background-color: var(--danger-soft);
color: var(--danger);
}
.tag-progress {
background-color: var(--accent-soft);
color: var(--accent);
}
.spinner {
flex: none;
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.progress {
height: 4px;
overflow: hidden;
border-radius: 2px;
background-color: var(--border);
}
.progress-bar {
height: 100%;
background-color: var(--accent);
transition: width 0.2s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 2.4s;
}
.progress-bar {
transition: none;
}
}
/* ---------- Error and not-found pages ---------- */
.status-page {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
max-width: 32rem;
margin: 3rem auto;
text-align: center;
}
.status-page-code {
padding: 0.125rem 0.5rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
color: var(--text-muted);
font-family: var(--mono);
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.1em;
}
.status-page h1 {
font-size: 1.5rem;
font-weight: 600;
}
.status-page p {
color: var(--text-muted);
}
.status-page .btn {
margin-top: 0.5rem;
}
/* ---------- Legal pages ---------- */
.legal {
max-width: 48rem;
margin: 0 auto;
padding: 0.5rem 0 2rem;
}
.legal h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.25;
}
.legal h2 {
margin-top: 1.75rem;
margin-bottom: 0.5rem;
font-size: 1.0625rem;
font-weight: 600;
}
.legal h3 {
margin-top: 1.25rem;
margin-bottom: 0.375rem;
font-size: 0.9375rem;
font-weight: 600;
}
.legal p,
.legal ul,
.legal address {
margin-bottom: 0.75rem;
}
.legal ul {
padding-left: 1.25rem;
}
.legal li + li {
margin-top: 0.25rem;
}
.legal .legal-updated {
margin-top: 0.25rem;
margin-bottom: 1.5rem;
color: var(--text-muted);
}
.legal-address {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background-color: var(--surface);
font-style: normal;
}
.legal-note {
padding: 0.75rem 1rem;
border: 1px solid var(--danger-border);
border-left: 3px solid var(--danger);
border-radius: var(--radius);
background-color: var(--danger-soft);
}
/* ---------- Blazor error boundary ---------- */
.blazor-error-boundary {
padding: 0.75rem 1rem;
border: 1px solid var(--danger-border);
border-left: 3px solid var(--danger);
border-radius: var(--radius);
background-color: var(--danger-soft);
color: var(--text);
}
.blazor-error-boundary::after {
content: "An error has occurred.";
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a5fb4"/>
<path d="M7 12.5h17l-4.5-4.5M25 19.5H8l4.5 4.5" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,45 @@
// Searches numbers start..end for the one whose SHA-256(salt + number) equals the target hash; posts it, or -1.
const encoder = new TextEncoder();
// Digests are requested in batches, because each WebCrypto call is asynchronous.
const batchSize = 256;
self.onmessage = async ({ data: { salt, hash, start, end } }) => {
const target = hexToBytes(hash);
for (let first = start; first <= end; first += batchSize) {
const last = Math.min(first + batchSize - 1, end);
const pending = [];
for (let number = first; number <= last; number++) {
pending.push(crypto.subtle.digest('SHA-256', encoder.encode(salt + number)));
}
const digests = await Promise.all(pending);
for (let i = 0; i < digests.length; i++) {
if (matches(new Uint8Array(digests[i]), target)) {
self.postMessage(first + i);
return;
}
}
}
self.postMessage(-1);
};
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return bytes;
}
function matches(digest, target) {
for (let i = 0; i < target.length; i++) {
if (digest[i] !== target[i]) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,31 @@
// Solves the server's proof-of-work bot check (see BotCheckChallenge) in parallel web workers, so the page stays
// responsive. Resolves with the number, or -1 if none was found.
export function solve(salt, hash, maxNumber) {
const workerCount = Math.max(1, Math.min(navigator.hardwareConcurrency || 2, 8));
const sliceSize = Math.ceil((maxNumber + 1) / workerCount);
const workerUrl = new URL('./bot-check-worker.js', import.meta.url);
return new Promise(resolve => {
const workers = [];
let running = workerCount;
const finish = result => {
workers.forEach(worker => worker.terminate());
resolve(result);
};
const sliceDone = () => {
if (--running === 0) {
finish(-1);
}
};
for (let i = 0; i < workerCount; i++) {
const worker = new Worker(workerUrl);
workers.push(worker);
worker.onmessage = ({ data }) => data >= 0 ? finish(data) : sliceDone();
worker.onerror = sliceDone;
const start = i * sliceSize;
worker.postMessage({ salt, hash, start, end: Math.min(start + sliceSize - 1, maxNumber) });
}
});
}