refactor: move core to packages/core

This commit is contained in:
Tien Do Nam
2026-07-13 23:23:51 +02:00
parent 6db7a1a1b4
commit 1034fbd773
46 changed files with 1 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
target/
+3529
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
[package]
name = "localsend"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.100"
base64 = "0.22.1"
bytes = "1.11"
ed25519-dalek = { version = "2.2", features = ["pem", "rand_core"], optional = true }
flate2 = { version = "1.1", optional = true }
form_urlencoded = { version = "1.2", optional = true }
futures-util = { version = "0.3.31", features = ["sink"] }
http-body-util = { version = "0.1.3", optional = true }
hyper = { version = "1.8.1", optional = true }
hyper-util = { version = "0.1.19", features = ["server"], optional = true }
lru = "0.16.3"
pem = { version = "3.0.6", optional = true }
percent-encoding = { version = "2.3", optional = true }
reqwest = { version = "0.13.1", features = ["charset", "http2", "system-proxy", "json", "rustls-no-provider", "stream", "webpki-roots"], default-features = false, optional = true }
rand = "0.9.1"
rsa = { version = "0.9.8", optional = true }
rustls = { version = "0.23.32", default-features = false, features = ["ring", "tls12", "std"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = { version = "0.10.9", optional = true }
socket2 = { version = "0.6.2", optional = true }
thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["full"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"], optional = true }
tokio-stream = "0.1.18"
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"], optional = true }
tokio-util = { version = "0.7.16", optional = true }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.20" }
tungstenite = "0.28.0"
uuid = { version = "1.20.0", features = ["serde", "v4"] }
webrtc = { version = "0.14.0", optional = true }
x509-parser = { version = "0.18.0", features = ["verify"], optional = true }
[features]
default = []
crypto = ["ed25519-dalek", "rsa", "sha2"]
http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"]
webrtc-signaling = ["tokio-tungstenite"]
webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"]
full = ["crypto", "http", "webrtc"]
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>LocalSend</title>
</head>
<body>
<h1>403 Forbidden</h1>
<p>You don't have permission to access this resource.</p>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalSend</title>
<style>
body {
font-family: sans-serif, system-ui;
margin: 0;
line-height: 1.5;
text-align: center;
}
a {
text-decoration: none;
color: #FFFFFF;
}
#file-list {
box-sizing: border-box;
width: 100%;
max-width: 1000px;
padding: 0 1em;
margin: 0 auto;
text-align: left;
}
.file-item {
display: table;
width: 100%;
padding: 0.5em 1em;
background-color: #00796B;
border-radius: 0.5em;
margin-bottom: 1em;
float: left;
box-sizing: border-box;
}
.file-item:hover {
background-color: #009688;
}
.file-name-cell, .file-size-cell, .file-index-cell {
display: table-cell;
vertical-align: middle;
}
.file-name-cell {
width: 100%;
padding: 0 0.5em;
}
.file-size-cell, .file-index-cell {
font-size: 0.8em;
white-space: nowrap;
color: #E0E0E0;
}
/* Two columns layout */
.file-item {
width: 100%;
}
@media screen and (min-width: 768px) {
#file-list.file-item {
width: 48%;
margin-right: 2%;
}
#file-list.file-item:nth-child(2n) {
margin-right: 0;
}
}
/* Single file mode */
#single-file {
box-sizing: border-box;
width: 100%;
text-align: left;
max-width: 400px;
padding: 0 1em;
margin: 0 auto;
}
</style>
<script src="main.js" defer></script>
</head>
<body>
<noscript>
LocalSend requires JavaScript, which is currently disabled. Please enable it and try again.
</noscript>
<h1 style="padding: 0.5em">LocalSend</h1>
<p id="status-text"></p>
<div id="file-list"></div>
<div id="single-file"></div>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
// IMPORTANT: This script works in Internet Explorer 8!
var BASE_URL = '/api/localsend/v2';
var i18n = {};
var sessionId = sessionStorage.getItem('sessionId');
var queryParams = location.search.slice(1).split('&');
var queryPin = null;
// Parse query parameters manually for IE
for (var i = 0; i < queryParams.length; i++) {
var pair = queryParams[i].split('=');
if (pair[0] === 'pin') {
queryPin = decodeURIComponent(pair[1]);
break;
}
}
function firstRequestFiles() {
document.getElementById('status-text').innerText = i18n.waiting;
var initialUrl = BASE_URL + '/prepare-download';
if (sessionId) {
initialUrl += '?sessionId=' + encodeURIComponent(sessionId);
if (queryPin) {
initialUrl += '&pin=' + encodeURIComponent(queryPin);
}
} else if (queryPin) {
initialUrl += '?pin=' + encodeURIComponent(queryPin);
}
makeRequest(initialUrl, 'POST', function (response) {
if (response.status === 401) {
pinRequestFiles(true);
return;
}
if (response.status === 403) {
document.getElementById('status-text').innerText = i18n.rejected;
return;
}
if (response.status === 429) {
document.getElementById('status-text').innerText = i18n.tooManyAttempts;
return;
}
if (response.status !== 200) {
document.getElementById('status-text').innerText = 'Error: ' + response.status;
return;
}
handleSuccess(response);
});
}
function pinRequestFiles(firstAttempt) {
var pin = prompt(i18n.enterPin + (firstAttempt ? '' : '\n' + i18n.invalidPin));
if (!pin) {
document.getElementById('status-text').innerText = i18n.invalidPin;
return;
}
makeRequest(BASE_URL + '/prepare-download?pin=' + encodeURIComponent(pin), 'POST', function (response) {
if (response.status === 401) {
pinRequestFiles(false);
return;
}
if (response.status === 403) {
document.getElementById('status-text').innerText = i18n.rejected;
return;
}
if (response.status === 429) {
document.getElementById('status-text').innerText = i18n.tooManyAttempts;
return;
}
if (response.status !== 200) {
document.getElementById('status-text').innerText = 'Error: ' + response.status;
return;
}
handleSuccess(response);
});
}
function makeRequest(url, method, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
callback(xhr);
}
};
xhr.send();
}
function fetchI18n(then) {
makeRequest('/i18n.json', 'GET', function (response) {
if (response.status === 200) {
i18n = JSON.parse(response.responseText);
then();
}
});
}
function init() {
fetchI18n(firstRequestFiles);
}
function handleSuccess(response) {
var data = JSON.parse(response.responseText);
var files = data.files;
sessionId = data.sessionId;
sessionStorage.setItem('sessionId', sessionId);
document.getElementById('status-text').innerText = i18n.files + ' (' + getKeys(data.files).length + ')';
// Handling file display
handleFilesDisplay(files, sessionId);
}
function handleFilesDisplay(files, sessionId) {
var html= '';
var fileKeys = getKeys(files);
for (var i = 0; i < fileKeys.length; i++) {
var file = files[fileKeys[i]];
html += '<a class="file-item" href="' + BASE_URL + '/download?sessionId=' + encodeURIComponent(sessionId) + '&fileId=' + encodeURIComponent(fileKeys[i]) + '">' +
'<div class="file-index-cell">' + (i + 1) + '</div>' +
'<div class="file-name-cell">' + escapeHtml(file.fileName) + '</div>' +
'<div class="file-size-cell">' + formatBytes(file.size) + '</div>' +
'</a>';
}
if (fileKeys.length === 1) {
document.getElementById('single-file').innerHTML = html;
} else {
document.getElementById('file-list').innerHTML = html;
}
}
function escapeHtml(text) {
if (text === null || text === undefined) {
return '';
}
var map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
}
function formatBytes(bytes) {
if (bytes < 1024) {
return bytes + ' B';
} else if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
} else if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
} else {
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
}
}
function getKeys(obj) {
var keys = [];
for (var key in obj) {
keys.push(key);
}
return keys;
}
init();
+187
View File
@@ -0,0 +1,187 @@
use std::io::Cursor;
use x509_parser::asn1_rs::FromDer;
use x509_parser::certificate::X509Certificate;
use x509_parser::pem::Pem;
use x509_parser::x509::SubjectPublicKeyInfo;
pub fn verify_cert_from_pem(cert: String, public_key: Option<&str>) -> anyhow::Result<()> {
let (cert_pem, _) = Pem::read(Cursor::new(cert.into_bytes()))?;
let parsed_cert: X509Certificate = cert_pem.parse_x509()?;
verify_cert_from_cert(parsed_cert, public_key)
}
pub fn verify_cert_from_der(cert: &[u8], public_key: Option<&str>) -> anyhow::Result<()> {
let (_, parsed_cert) = X509Certificate::from_der(&cert)?;
verify_cert_from_cert(parsed_cert, public_key)
}
/// Verifies if the certificate is valid
/// - according to the signature
/// - according to the time validity
/// - according to the public key (if provided)
fn verify_cert_from_cert(cert: X509Certificate, public_key: Option<&str>) -> anyhow::Result<()> {
if !cert.validity.is_valid() {
return Err(anyhow::anyhow!("Time validity error"));
}
if let Some(public_key) = public_key {
let cert_public_key = cert.tbs_certificate.subject_pki.parsed()?;
let (public_key_pem, _) = Pem::read(Cursor::new(public_key.as_bytes()))?;
let (_, public_key_spki) = SubjectPublicKeyInfo::from_der(&public_key_pem.contents)?;
let expected_public_key = public_key_spki.parsed()?;
if cert_public_key != expected_public_key {
// We catch public key mismatch separately from signature verification
// so that we can better unit test the function
return Err(anyhow::anyhow!("Public key mismatch"));
}
}
cert.verify_signature(None)?;
Ok(())
}
pub fn public_key_from_cert_pem(cert: String) -> anyhow::Result<String> {
let (cert_pem, _) = Pem::read(Cursor::new(cert.into_bytes()))?;
let parsed_cert: X509Certificate = cert_pem.parse_x509()?;
public_key_from_cert(parsed_cert)
}
/// Extracts the public key from the certificate which is in DER format.
/// Encodes the public key in PEM format.
pub fn public_key_from_cert_der(cert: &[u8]) -> anyhow::Result<String> {
let (_, parsed_cert) = X509Certificate::from_der(&cert)?;
public_key_from_cert(parsed_cert)
}
/// Extracts the public key from the certificate.
/// Encodes the public key in PEM format.
pub fn public_key_from_cert(cert: X509Certificate) -> anyhow::Result<String> {
let cert_public_key = cert.tbs_certificate.subject_pki.raw;
let public_key = pem::encode(&pem::Pem::new("PUBLIC KEY", cert_public_key.to_vec()));
Ok(public_key)
}
#[cfg(test)]
mod tests {
use super::*;
static PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi9uDMYRn63SZtEPGRogZ
Gdu5XXBAoQeMO60mycoinqLKDWyZdpMo+XWY3wYVhoAyxgzDOcPjIf+Uq1oEy/0K
4WwfpbK8SCy851qgYkfMCT9D9mFvXwWoULJCUHFF7f947ArDE1nmuK1nNx2RodN2
wJCXyzPjw0jn06bwGeg0EqfUC8wvW4FTZ6t1tErzmRqRdMUWuCJwsk1IMbDbFePh
iK5jecOBG0RVVWLuw+TkuX8TUgrpIktH2+qEM1KdLyAMnL71hx2wMvE+lDKFKK9p
37zXK8omjl+VgTC8ocjGeYDDsl43ZtW09V0pb7Vz2FM8b7BgM06kvJl48PIe5puY
bQIDAQAB
-----END PUBLIC KEY-----";
#[test]
fn test_verify_cert() {
let cert = "-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh
bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG
A1UECBMAMQkwBwYDVQQGEwAwHhcNMjUwMjA5MDAwMzE0WhcNMzUwMjA3MDAwMzE0
WjBQMRcwFQYDVQQDEw5Mb2NhbFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQL
EwAxCTAHBgNVBAcTADEJMAcGA1UECBMAMQkwBwYDVQQGEwAwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCL24MxhGfrdJm0Q8ZGiBkZ27ldcEChB4w7rSbJ
yiKeosoNbJl2kyj5dZjfBhWGgDLGDMM5w+Mh/5SrWgTL/QrhbB+lsrxILLznWqBi
R8wJP0P2YW9fBahQskJQcUXt/3jsCsMTWea4rWc3HZGh03bAkJfLM+PDSOfTpvAZ
6DQSp9QLzC9bgVNnq3W0SvOZGpF0xRa4InCyTUgxsNsV4+GIrmN5w4EbRFVVYu7D
5OS5fxNSCukiS0fb6oQzUp0vIAycvvWHHbAy8T6UMoUor2nfvNcryiaOX5WBMLyh
yMZ5gMOyXjdm1bT1XSlvtXPYUzxvsGAzTqS8mXjw8h7mm5htAgMBAAEwDQYJKoZI
hvcNAQELBQADggEBABZ+I7D6wkeSrsi1NBLP2zoZ5oGh+INNcGTravfOQHs4Fbas
/CysaUYjsD3fmaDh4MxgWEqAmWnnBiojfpGX2SGuFqRBKyT9DgitBt0L7Ezg1k3h
bfSiFW4hXWp75grVO8xfML7ZcWMlhKrOsOMUGiy1qs3qsyJ3w7B2Tz78HhXGO5dd
jyPmZarhixKO92UpEvKGxjO0E/3UUNUzxKTAAgFfhKpuwHUgIijM/EppZtA8OcSh
fEztiV0xKfcPVx4d6dqRt/NMElK1Ivw2vUuxTymphZkkFOzht9m73/kyKaeFp8Ij
VRus1zGVD8IVpIdPMyz01WJyS7M0fWaHXKWo+Bo=
-----END CERTIFICATE-----"
.to_string();
assert_eq!(
verify_cert_from_pem(cert, Some(PUBLIC_KEY)).map_err(|e| e.to_string()),
Ok(())
);
let cert_invalid_signature = "-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh
bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG
A1UECBMAMQkwBwYDVQQGEwAwHhcNMjUwMjA5MDAwMzE0WhcNMzUwMjA3MDAwMzE0
WjBQMRcwFQYDVQQDEw5Mb2NhbFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQL
EwAxCTAHBgNVBAcTADEJMAcGA1UECBMAMQkwBwYDVQQGEwAwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCL24MxhGfrdJm0Q8ZGiBkZ27ldcEChB4w7rSbJ
yiKeosoNbJl2kyj5dZjfBhWGgDLGDMM5w+Mh/5SrWgTL/QrhbB+lsrxILLznWqBi
R8wJP0P2YW9fBahQskJQcUXt/3jsCsMTWea4rWc3HZGh03bAkJfLM+PDSOfTpvAZ
6DQSp9QLzC9bgVNnq3W0SvOZGpF0xRa4InCyTUgxsNsV4+GIrmN5w4EbRFVVYu7D
5OS5fxNSCukiS0fb6oQzUp0vIAycvvWHHbAy8T6UMoUor2nfvNcryiaOX5WBMLyh
yMZ5gMOyXjdm1bT1XSlvtXPYUzxvsGAzTqS8mXjw8h7mm5htAgMBAAEwDQYJKoZI
hvcNAQELBQADggEBABZ+I7D6wkeSrsi1NBLP2zoZ5oGh+INNcGTravfOQHs4Fbas
/CysaUYjsD3fmaDh4MxgWEqAmWnnBiojfpGX2SGuFqRBKyT9DgitBt0L7Ezg1k3h
bfSiFW4hXWp75grVO8xfML7ZcWMlhKrOsOMUGiy1qs3qsyJ3w7B2Tz78HhXGO5dd
jyPmZarhixKO92UpEvKGxjO0E/3UUNUzxKTAAgFfhKpuwHUgIijM/EppZtA8OcSh
fEztiV0xKfcPVx4d6dqRt/NMElK1Ivw2vUuxTymphZkkFOzht9m73/kyKaeFp8Ij
VRus1zGVD8IVpIdPMyz01WJySwAAAAAAAAAAAAA=
-----END CERTIFICATE-----"
.to_string();
assert_eq!(
verify_cert_from_pem(cert_invalid_signature, Some(PUBLIC_KEY))
.map_err(|e| e.to_string()),
Err("signature verification error".to_string())
);
let cert_invalid_public_key = "-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh
bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG
A1UECBMAMQkwBwYDVQQGEwAwHhcNMjUwMjA5MDI1ODQxWhcNMzUwMjA3MDI1ODQx
WjBQMRcwFQYDVQQDEw5Mb2NhbFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQL
EwAxCTAHBgNVBAcTADEJMAcGA1UECBMAMQkwBwYDVQQGEwAwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCnrVjQQ0mAfBaucJd5rbZX9usLROwHDuXdczFq
XJhb8pPjEF18FoDHzobjz5JWq+GDkBmcg0k6+AeETGQEaJisZDBWH7NOjJahGGnQ
0okw1iVUoEpQ26ZSFkr3H5NNtGAa6EkS4xb0bsEDb3vs69zRvFyrVd6OEqmdsRy3
aU2AvAMoLthgY8bUZ/XyWpbA8euV3VjkRSHsju+DOQH4oj46ZITJ3M2/x5o/3jqJ
ILBhoLcu7UJJTHYqeBsPSTkMIGKLkYSUPOd/mSgwQB854wks4nf+hO4VWvKQFx9X
4gIjS7vJ6e9rQOn2NFfluPbRiijmWIiwUDUWz3UW2RS0b6gDAgMBAAEwDQYJKoZI
hvcNAQELBQADggEBAAO3rG2YcQqH8Z7jDX82q0nn/bglOWvTySv4EP3FNrVPZKfN
aR+oLo8WdAWulbxXDIOK7XLk1V9SxEJvVxOTp2EIgcoWqJANoWjp+5nNInE02eNX
G8euvPvh+p/1cTbHxhrZqtsSpkAx1AbbbcvT+5hUUDXSU7cMN+vFjUqkEVrBlj7S
vFbLDHP82ywisZrkOfNapxV67U4ENaEwJ4P4OERnqOOieJr0elv598cSDu+OSKmt
rFNYYHERELX36g4+KcWGN223Pg4Xl0bFYqV0xwRUThh0657t8cioXaOsjjpKnGAm
eVVihnrJ3sdk7nnreAYMse/OipyufRyZ9t3WU8A=
-----END CERTIFICATE-----"
.to_string();
assert_eq!(
verify_cert_from_pem(cert_invalid_public_key, Some(PUBLIC_KEY))
.map_err(|e| e.to_string()),
Err("Public key mismatch".to_string())
);
let cert_expired = "-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh
bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG
A1UECBMAMQkwBwYDVQQGEwAwHhcNMjUwMjA5MjEwOTQ0WhcNMjUwMjA5MjEwOTQ0
WjBQMRcwFQYDVQQDEw5Mb2NhbFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQL
EwAxCTAHBgNVBAcTADEJMAcGA1UECBMAMQkwBwYDVQQGEwAwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCL24MxhGfrdJm0Q8ZGiBkZ27ldcEChB4w7rSbJ
yiKeosoNbJl2kyj5dZjfBhWGgDLGDMM5w+Mh/5SrWgTL/QrhbB+lsrxILLznWqBi
R8wJP0P2YW9fBahQskJQcUXt/3jsCsMTWea4rWc3HZGh03bAkJfLM+PDSOfTpvAZ
6DQSp9QLzC9bgVNnq3W0SvOZGpF0xRa4InCyTUgxsNsV4+GIrmN5w4EbRFVVYu7D
5OS5fxNSCukiS0fb6oQzUp0vIAycvvWHHbAy8T6UMoUor2nfvNcryiaOX5WBMLyh
yMZ5gMOyXjdm1bT1XSlvtXPYUzxvsGAzTqS8mXjw8h7mm5htAgMBAAEwDQYJKoZI
hvcNAQELBQADggEBAH2/F6iEH8W5gIHcKJ6/EbrG2BY5Uhg5U8X6yPk6z9ctmY6w
n7fDT749PMVDJq+qhIcnoBlUgVJdJ2qFa5h3VaSF+tUFu/CImr+S8TYHCdQGYXA5
6b/pnHmbrWqZdNdxs6Y80A9Mu+iNeLDcrTo60/zGfJsiD9Cnlj0Q6c8nn+Obzeqq
iIwUmPFw0krH+ku/DlSenKnyL8jaktf48neufu0jObUvCuj62I2WlFZwzXd8CnDR
X2/mKq6FWHCDR6RTh1yMLfD+NoVNcswxwMFq8ILCfBuTjNVaSFm3eUqKeEOAaDes
nidU/qXQvBJ7NPUkXXgbcgqxK735iijOqQHmKts=
-----END CERTIFICATE-----"
.to_string();
assert_eq!(
verify_cert_from_pem(cert_expired, Some(PUBLIC_KEY)).map_err(|e| e.to_string()),
Err("Time validity error".to_string())
);
}
}
+7
View File
@@ -0,0 +1,7 @@
use sha2::{Digest, Sha256};
pub fn sha256(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
+4
View File
@@ -0,0 +1,4 @@
pub mod cert;
pub mod hash;
pub mod nonce;
pub mod token;
+11
View File
@@ -0,0 +1,11 @@
use rand::RngCore;
pub fn generate_nonce() -> Vec<u8> {
let mut nonce = vec![0; 32];
rand::rng().fill_bytes(&mut nonce);
nonce
}
pub fn validate_nonce(nonce: &[u8]) -> bool {
nonce.len() >= 16 && nonce.len() <= 128
}
+254
View File
@@ -0,0 +1,254 @@
use crate::crypto::hash;
use crate::util;
use ed25519_dalek::ed25519::signature::rand_core::OsRng;
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::spki::der::zeroize::Zeroizing;
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::{Signer, Verifier};
pub struct SigningTokenKey {
inner: ed25519_dalek::SigningKey,
}
impl SigningTokenKey {
pub fn to_verifying_key(&self) -> Box<dyn VerifyingTokenKey> {
Box::new(Ed25519VerifyingKey {
inner: self.inner.verifying_key(),
})
}
}
pub trait VerifyingTokenKey {
fn verify(&self, msg: &[u8], signature: &[u8]) -> anyhow::Result<()>;
fn to_der(&self) -> anyhow::Result<Vec<u8>>;
fn signature_method(&self) -> &'static str;
}
struct Ed25519VerifyingKey {
inner: ed25519_dalek::VerifyingKey,
}
struct RsaPssVerifyingKey {
inner: rsa::pss::VerifyingKey<sha2::Sha256>,
}
impl VerifyingTokenKey for Ed25519VerifyingKey {
fn verify(&self, msg: &[u8], signature: &[u8]) -> anyhow::Result<()> {
let signature = ed25519_dalek::Signature::from_slice(signature)?;
self.inner.verify(msg, &signature)?;
Ok(())
}
fn to_der(&self) -> anyhow::Result<Vec<u8>> {
Ok(self.inner.to_public_key_der()?.into_vec())
}
fn signature_method(&self) -> &'static str {
"ed25519"
}
}
impl VerifyingTokenKey for RsaPssVerifyingKey {
fn verify(&self, msg: &[u8], signature: &[u8]) -> anyhow::Result<()> {
let signature = rsa::pss::Signature::try_from(signature)?;
self.inner.verify(msg, &signature)?;
Ok(())
}
fn to_der(&self) -> anyhow::Result<Vec<u8>> {
Ok(self.inner.to_public_key_der()?.into_vec())
}
fn signature_method(&self) -> &'static str {
"rsa-pss"
}
}
pub fn generate_key() -> SigningTokenKey {
let mut csprng = OsRng;
SigningTokenKey {
inner: ed25519_dalek::SigningKey::generate(&mut csprng),
}
}
pub fn export_private_key(key: &SigningTokenKey) -> anyhow::Result<Zeroizing<String>> {
let pem = key.inner.to_pkcs8_pem(LineEnding::LF)?;
Ok(pem)
}
pub fn parse_private_key(private_key: &str) -> anyhow::Result<SigningTokenKey> {
let parsed = ed25519_dalek::SigningKey::from_pkcs8_pem(private_key)?;
Ok(SigningTokenKey { inner: parsed })
}
pub fn export_public_key(key: &SigningTokenKey) -> anyhow::Result<String> {
let pem = key
.inner
.verifying_key()
.to_public_key_pem(LineEnding::LF)?;
Ok(pem)
}
pub fn parse_public_key(
public_key: &str,
identifier: &str,
) -> anyhow::Result<Box<dyn VerifyingTokenKey + Send>> {
Ok(match identifier {
"ed25519" => Box::new(Ed25519VerifyingKey {
inner: ed25519_dalek::VerifyingKey::from_public_key_pem(public_key)?,
}),
"rsa-pss" => Box::new(RsaPssVerifyingKey {
inner: {
let public_key = rsa::RsaPublicKey::from_public_key_pem(public_key)?;
rsa::pss::VerifyingKey::new(public_key)
},
}),
_ => return Err(anyhow::anyhow!("Unsupported key type")),
})
}
pub fn generate_token_timestamp(key: &SigningTokenKey) -> anyhow::Result<String> {
let salt = util::time::unix_timestamp_u64()?.to_le_bytes();
let result = generate_token_nonce(key, &salt)?;
Ok(result)
}
pub fn generate_token_nonce(key: &SigningTokenKey, salt: &[u8]) -> anyhow::Result<String> {
let digest = {
let public_key = key.inner.verifying_key().to_public_key_der()?;
let hash_input = [public_key.as_bytes(), &salt].concat();
hash::sha256(&hash_input)
};
let signature = key.inner.sign(&digest);
let hash_method = "sha256";
let hash_base64 = util::base64::encode(&digest);
let salt_base64 = util::base64::encode(&salt);
let sign_method = "ed25519";
let signature_base64 = util::base64::encode(signature.to_bytes());
let result =
format!("{hash_method}.{hash_base64}.{salt_base64}.{sign_method}.{signature_base64}");
Ok(result)
}
pub fn extract_signature_identifier(token: &str) -> Option<&str> {
let parts: Vec<&str> = token.split('.').collect();
parts.get(3).copied()
}
pub fn verify_token_timestamp(public_key: &dyn VerifyingTokenKey, token: &str) -> bool {
verify_token_with_result(public_key, token, |salt| {
let salt = {
if salt.len() != 8 {
return Err(anyhow::anyhow!("Invalid salt length"));
}
u64::from_le_bytes(
salt.try_into()
.map_err(|_| anyhow::anyhow!("Invalid salt"))?,
)
};
let now_seconds = util::time::unix_timestamp_u64()?;
if now_seconds - salt > 60 * 60 {
// Fingerprint is older than 1h, reject
return Err(anyhow::anyhow!("Fingerprint timestamp expired"));
}
Ok(())
})
.is_ok()
}
pub fn verify_token_nonce(public_key: &dyn VerifyingTokenKey, token: &str, nonce: &[u8]) -> bool {
verify_token_with_result(public_key, token, |salt| {
if salt != nonce {
return Err(anyhow::anyhow!("Invalid nonce"));
}
Ok(())
})
.is_ok()
}
pub fn verify_token_with_result(
public_key: &dyn VerifyingTokenKey,
token: &str,
verify_salt: impl Fn(&[u8]) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let parts: Vec<&str> = token.split('.').collect();
let [hash_method, hash_base64, salt_base64, sign_method, signature_base64] = parts[0..5] else {
return Err(anyhow::anyhow!("Invalid structure"));
};
if hash_method != "sha256" {
return Err(anyhow::anyhow!("Invalid hash method"));
}
if sign_method != public_key.signature_method() {
return Err(anyhow::anyhow!("Invalid sign method"));
}
let salt = {
let salt_bytes = util::base64::decode(salt_base64)?;
verify_salt(&salt_bytes)?;
salt_bytes
};
let digest = {
let public_key_der = public_key.to_der()?;
let hash_input = [public_key_der.as_slice(), &salt].concat();
hash::sha256(&hash_input)
};
if util::base64::encode(&digest) != hash_base64 {
return Err(anyhow::anyhow!("Hash mismatch"));
}
let Ok(signature) = util::base64::decode(signature_base64) else {
return Err(anyhow::anyhow!("Invalid signature base64 encoding"));
};
public_key
.verify(&digest, &signature)
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_key() {
let key = generate_key();
let pem = export_private_key(&key).unwrap();
let parsed = parse_private_key(&pem).unwrap();
assert_eq!(parsed.inner.as_bytes(), key.inner.as_bytes());
}
#[test]
fn test_sign_verify() {
let key = generate_key();
let data = b"hello world";
let signature = key.inner.sign(data);
let verified = key
.to_verifying_key()
.verify(data, signature.to_vec().as_ref())
.is_ok();
assert!(verified);
}
#[test]
fn test_fingerprint() {
let key = generate_key();
let fingerprint = generate_token_timestamp(&key).unwrap();
let verified = verify_token_timestamp(&*key.to_verifying_key(), &fingerprint);
assert!(verified);
}
}
+228
View File
@@ -0,0 +1,228 @@
mod url;
pub mod v2;
pub mod v3;
pub use v2::LsHttpClientV2;
pub use v3::LsHttpClientV3;
use crate::http::StatusCodeError;
use crate::{crypto, http, model};
use reqwest::Response;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub enum LsHttpClient {
V2(LsHttpClientV2),
V3(LsHttpClientV3),
}
pub enum LsHttpClientVersion {
V2,
V3,
}
#[derive(Debug, Error)]
pub enum ClientError {
#[error(transparent)]
StatusCode(StatusCodeError),
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
#[error("Upload cancelled")]
Cancelled,
}
impl LsHttpClient {
pub fn new(
private_key: &str,
cert: &str,
version: LsHttpClientVersion,
) -> Result<LsHttpClient, ClientError> {
let client = match version {
LsHttpClientVersion::V2 => {
LsHttpClient::V2(LsHttpClientV2::try_new(&private_key, &cert)?)
}
LsHttpClientVersion::V3 => {
LsHttpClient::V3(LsHttpClientV3::try_new(&private_key, &cert)?)
}
};
Ok(client)
}
pub async fn register(
&self,
protocol: http::dto::ProtocolType,
ip: &str,
port: u16,
payload: http::dto::RegisterDto,
) -> Result<ResultWithPublicKey<http::dto::RegisterResponseDto>, ClientError> {
match self {
LsHttpClient::V2(client) => {
let result = client.register(protocol, ip, port, payload.into()).await?;
Ok(ResultWithPublicKey {
public_key: result.public_key,
body: result.body.into(),
})
}
LsHttpClient::V3(client) => client.register(protocol, ip, port, payload).await,
}
}
pub async fn prepare_upload(
&self,
protocol: http::dto::ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
payload: http::dto::PrepareUploadRequestDto,
pin: Option<&str>,
) -> Result<http::dto::PrepareUploadResult, ClientError> {
match self {
LsHttpClient::V2(client) => {
let result = client
.prepare_upload(protocol, ip, port, public_key, payload.into(), pin)
.await?;
Ok(result.into())
}
LsHttpClient::V3(client) => {
client
.prepare_upload(protocol, ip, port, public_key, payload)
.await
}
}
}
pub async fn upload(
&self,
protocol: http::dto::ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
session_id: &str,
file_id: &str,
token: &str,
content: model::transfer::FileContent,
cancel: tokio_util::sync::CancellationToken,
) -> Result<(), ClientError> {
match self {
LsHttpClient::V2(client) => {
client
.upload(
protocol, ip, port, public_key, session_id, file_id, token, content, cancel,
)
.await
}
LsHttpClient::V3(client) => {
client
.upload(
protocol, ip, port, public_key, session_id, file_id, token, content, cancel,
)
.await
}
}
}
pub async fn cancel(
&self,
protocol: http::dto::ProtocolType,
ip: &str,
port: u16,
session_id: &str,
) -> Result<(), ClientError> {
match self {
LsHttpClient::V2(client) => client.cancel(protocol, ip, port, session_id).await,
LsHttpClient::V3(client) => client.cancel(protocol, ip, port, session_id).await,
}
}
}
pub(super) fn create_reqwest_client(
private_key: &str,
cert: &str,
) -> Result<reqwest::Client, ClientError> {
let _ = rustls::crypto::ring::default_provider().install_default();
let identity = {
let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat();
reqwest::Identity::from_pem(pem)?
};
let client = reqwest::Client::builder()
.use_rustls_tls()
.danger_accept_invalid_certs(true)
.tls_info(true)
.identity(identity)
.build()?;
Ok(client)
}
/// Verifies the certificate from the response.
/// Returns the public key extracted from the certificate.
pub(super) fn verify_cert_from_res(
response: &Response,
public_key: Option<String>,
) -> anyhow::Result<String> {
let tls_info_ext = response
.extensions()
.get::<reqwest::tls::TlsInfo>()
.ok_or_else(|| anyhow::anyhow!("TLS info not found"))?;
let cert = tls_info_ext
.peer_certificate()
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?;
crypto::cert::verify_cert_from_der(cert, public_key.as_deref())?;
let public_key = match public_key {
Some(public_key) => public_key,
None => crypto::cert::public_key_from_cert_der(cert)?,
};
Ok(public_key)
}
#[derive(Serialize, Deserialize)]
struct ErrorResponse {
message: String,
}
pub struct ResultWithPublicKey<T> {
/// The public key extracted from the certificate.
/// Encoded in PEM format.
/// Only available in HTTPS mode.
pub public_key: Option<String>,
/// The response body.
pub body: T,
}
pub(super) trait ResponseExt {
async fn into_error<T>(self) -> Result<T, ClientError>;
}
impl ResponseExt for Response {
async fn into_error<T>(self) -> Result<T, ClientError> {
let status = self.status().as_u16();
let body = self.text().await.unwrap_or_default();
let message = match serde_json::from_str::<ErrorResponse>(&body) {
Ok(error) => error.message,
Err(_) => body,
};
Err(ClientError::StatusCode(StatusCodeError {
status,
message: if message.is_empty() {
None
} else {
Some(message)
},
}))
}
}
+95
View File
@@ -0,0 +1,95 @@
use std::borrow::Cow;
pub struct TargetUrl<'a> {
pub version: ApiVersion,
pub protocol: &'static str,
pub host: String,
pub port: u16,
pub path: &'static str,
/// Query parameters as key-value pairs.
/// Note: It is expected that the caller will URL-encode the values if necessary.
pub params: &'a [(&'static str, &'a str)],
}
pub enum ApiVersion {
V2,
V3,
}
impl<'a> TargetUrl<'a> {
pub fn to_string(&self) -> String {
let base = format!(
"{}://{}:{}/api/localsend/{}{}",
self.protocol,
match self.host.contains(':') {
true => Cow::Owned(format!("[{}]", self.host)), // IPv6 addresses need to be enclosed in brackets
false => Cow::Borrowed(&self.host),
},
self.port,
match self.version {
ApiVersion::V2 => "v2",
ApiVersion::V3 => "v3",
},
self.path
);
if self.params.is_empty() {
base
} else {
let query = self
.params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
format!("{}?{}", base, query)
}
}
}
#[cfg(test)]
mod tests {
use super::{ApiVersion, TargetUrl};
#[test]
fn test_build_url_ipv4() {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: "https",
host: "192.168.1.1".to_string(),
port: 53317,
path: "/register",
params: &[],
}
.to_string();
assert_eq!(url, "https://192.168.1.1:53317/api/localsend/v2/register");
}
#[test]
fn test_build_url_ipv6() {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: "https",
host: "::1".to_string(),
port: 53317,
path: "/register",
params: &[],
}
.to_string();
assert_eq!(url, "https://[::1]:53317/api/localsend/v2/register");
}
#[test]
fn test_build_url_http() {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: "http",
host: "192.168.1.1".to_string(),
port: 53317,
path: "/info",
params: &[],
}
.to_string();
assert_eq!(url, "http://192.168.1.1:53317/api/localsend/v2/info");
}
}
+459
View File
@@ -0,0 +1,459 @@
use super::{ClientError, ResponseExt, ResultWithPublicKey};
use crate::http::client::url::{ApiVersion, TargetUrl};
use crate::http::dto::ProtocolType;
use crate::http::dto_v2::{
InfoResponseDtoV2, PrepareDownloadResponseDtoV2, PrepareUploadRequestDtoV2,
PrepareUploadResponseDtoV2, PrepareUploadResultV2, RegisterDtoV2, RegisterResponseDtoV2,
};
use crate::model;
use bytes::Bytes;
use futures_util::StreamExt;
use reqwest::{Response, StatusCode};
use tokio::io::AsyncWriteExt;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
/// HTTP client for LocalSend Protocol v2.1.
pub struct LsHttpClientV2 {
client: reqwest::Client,
}
impl LsHttpClientV2 {
/// Creates a new HTTP client for v2.1 protocol.
///
/// # Arguments
/// * `private_key` - PEM-encoded private key for client certificate
/// * `cert` - PEM-encoded certificate for client authentication
///
/// # Returns
/// A new client instance or an error if TLS setup fails.
pub fn try_new(private_key: &str, cert: &str) -> Result<Self, ClientError> {
Ok(Self {
client: super::create_reqwest_client(private_key, cert)?,
})
}
/// Creates a new HTTP client without TLS client certificate.
///
/// Use this for HTTP-only connections or when client authentication is not needed.
pub fn try_new_without_cert() -> Result<Self, ClientError> {
let _ = rustls::crypto::ring::default_provider().install_default();
let client = reqwest::Client::builder()
.use_rustls_tls()
.danger_accept_invalid_certs(true)
.tls_info(true)
.build()?;
Ok(Self { client })
}
/// Registers with another device for discovery.
///
/// POST /api/localsend/v2/register
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Target device IP address
/// * `port` - Target device port
/// * `payload` - Device information to register
///
/// # Returns
/// Registration result containing the remote device info and optional public key.
pub async fn register(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
payload: RegisterDtoV2,
) -> Result<ResultWithPublicKey<RegisterResponseDtoV2>, ClientError> {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/register",
params: &[],
}
.to_string();
let res = self
.client
.post(&url)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&payload)?)
.send()
.await?;
if res.status() != StatusCode::OK {
return res.into_error().await;
}
let public_key = match protocol {
ProtocolType::Https => Some(super::verify_cert_from_res(&res, None)?),
_ => None,
};
let body = res.json::<RegisterResponseDtoV2>().await?;
Ok(ResultWithPublicKey { public_key, body })
}
/// Prepares a file upload session with the receiver.
///
/// POST /api/localsend/v2/prepare-upload
///
/// The receiver will decide if this request gets accepted, partially accepted, or rejected.
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Receiver's IP address
/// * `port` - Receiver's port
/// * `public_key` - Expected public key for verification (HTTPS only)
/// * `payload` - Upload request with device info and file metadata
/// * `pin` - Optional PIN if required by receiver
///
/// # Returns
/// Session ID and accepted file tokens, or an error.
///
/// # Errors
/// * 204 - No file transfer needed (e.g. text-only transfer)
/// * 400 - Invalid body
/// * 401 - PIN required or invalid
/// * 403 - Rejected by user
/// * 409 - Blocked by another session
/// * 429 - Too many requests
/// * 500 - Unknown error
pub async fn prepare_upload(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
payload: PrepareUploadRequestDtoV2,
pin: Option<&str>,
) -> Result<PrepareUploadResultV2, ClientError> {
let pin_params: &[(&'static str, &str)] = match &pin {
Some(pin) => &[("pin", pin)],
None => &[],
};
let url = TargetUrl {
version: ApiVersion::V2,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/prepare-upload",
params: pin_params,
}
.to_string();
let res = self
.client
.post(&url)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&payload)?)
.send()
.await?;
if protocol == ProtocolType::Https {
super::verify_cert_from_res(&res, public_key)?;
}
let status = res.status();
if status.as_u16() >= 400 {
return res.into_error().await;
}
if status == StatusCode::NO_CONTENT {
return Ok(PrepareUploadResultV2 {
status_code: status.as_u16(),
response: None,
});
}
let body = res.json::<PrepareUploadResponseDtoV2>().await?;
Ok(PrepareUploadResultV2 {
status_code: status.as_u16(),
response: Some(body),
})
}
/// Uploads a file to the receiver.
///
/// POST /api/localsend/v2/upload?sessionId=...&fileId=...&token=...
///
/// Use the session_id, file_id, and token from prepare_upload response.
/// This method can be called in parallel for multiple files.
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Receiver's IP address
/// * `port` - Receiver's port
/// * `session_id` - Session ID from prepare_upload
/// * `file_id` - File ID to upload
/// * `token` - File-specific token from prepare_upload
/// * `content` - The file content to upload (a chunk stream or a raw file descriptor)
/// * `cancel` - Cancellation token; cancelling it aborts the upload with [`ClientError::Cancelled`]
///
/// # Errors
/// * 400 - Missing parameters
/// * 403 - Invalid token or IP address
/// * 409 - Blocked by another session
/// * 500 - Unknown error
pub async fn upload(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
session_id: &str,
file_id: &str,
token: &str,
content: model::transfer::FileContent,
cancel: CancellationToken,
) -> Result<(), ClientError> {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/upload",
params: &[
("sessionId", session_id),
("fileId", file_id),
("token", token),
],
}
.to_string();
let stream = ReceiverStream::new(content.into_receiver()).map(Ok::<Bytes, anyhow::Error>);
let body = reqwest::Body::wrap_stream(stream);
let res = tokio::select! {
res = self.client.post(&url).body(body).send() => res?,
_ = cancel.cancelled() => return Err(ClientError::Cancelled),
};
if protocol == ProtocolType::Https {
super::verify_cert_from_res(&res, public_key)?;
}
if res.status() != StatusCode::OK {
return res.into_error().await;
}
Ok(())
}
/// Cancels an ongoing file transfer session.
///
/// POST /api/localsend/v2/cancel?sessionId=...
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Receiver's IP address
/// * `port` - Receiver's port
/// * `session_id` - Session ID to cancel
pub async fn cancel(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
session_id: &str,
) -> Result<(), ClientError> {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/cancel",
params: &[("sessionId", session_id)],
}
.to_string();
self.client.post(&url).send().await?;
Ok(())
}
/// Gets device info from a remote device.
///
/// GET /api/localsend/v2/info
///
/// This is primarily for debugging purposes.
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Target device IP address
/// * `port` - Target device port
///
/// # Returns
/// Device information including alias, version, device type, fingerprint, etc.
pub async fn info(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
) -> Result<InfoResponseDtoV2, ClientError> {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/info",
params: &[],
}
.to_string();
let res = self.client.get(&url).send().await?;
if res.status() != StatusCode::OK {
return res.into_error().await;
}
let body = res.json::<InfoResponseDtoV2>().await?;
Ok(body)
}
/// Prepares to download files from a sender (Download API).
///
/// POST /api/localsend/v2/prepare-download
///
/// This is used in reverse file transfer mode where the sender hosts the files
/// and receivers download them.
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Sender's IP address
/// * `port` - Sender's port
/// * `session_id` - Optional existing session ID (for browser refresh scenarios)
/// * `pin` - Optional PIN if required by sender
///
/// # Returns
/// Sender info, session ID, and available files.
///
/// # Errors
/// * 401 - PIN required or invalid
/// * 403 - Rejected
/// * 429 - Too many requests
/// * 500 - Unknown error
pub async fn prepare_download(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
session_id: Option<&str>,
pin: Option<&str>,
) -> Result<PrepareDownloadResponseDtoV2, ClientError> {
let mut params: Vec<(&'static str, &str)> = Vec::new();
if let Some(session_id) = session_id {
params.push(("sessionId", session_id));
}
if let Some(pin) = pin {
params.push(("pin", pin));
}
let url = TargetUrl {
version: ApiVersion::V2,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/prepare-download",
params: &params,
}
.to_string();
let res = self.client.post(&url).send().await?;
if res.status() != StatusCode::OK {
return res.into_error().await;
}
let body = res.json::<PrepareDownloadResponseDtoV2>().await?;
Ok(body)
}
/// Downloads a file from a sender (Download API).
///
/// GET /api/localsend/v2/download?sessionId=...&fileId=...
///
/// This method can be called in parallel for multiple files.
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Sender's IP address
/// * `port` - Sender's port
/// * `session_id` - Session ID from prepare_download
/// * `file_id` - File ID to download
///
/// # Returns
/// Response containing the file data stream.
pub async fn download(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
session_id: &str,
file_id: &str,
) -> Result<Response, ClientError> {
let url = TargetUrl {
version: ApiVersion::V2,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/download",
params: &[("sessionId", session_id), ("fileId", file_id)],
}
.to_string();
let res = self.client.get(&url).send().await?;
if res.status() != StatusCode::OK {
return res.into_error().await;
}
Ok(res)
}
/// Downloads a file to a writer (convenience method).
///
/// # Arguments
/// * `protocol` - HTTP or HTTPS
/// * `ip` - Sender's IP address
/// * `port` - Sender's port
/// * `session_id` - Session ID from prepare_download
/// * `file_id` - File ID to download
/// * `writer` - AsyncWrite destination for file data
///
/// # Returns
/// Total bytes written.
pub async fn download_to_writer<W: tokio::io::AsyncWrite + Unpin>(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
session_id: &str,
file_id: &str,
writer: &mut W,
) -> Result<u64, ClientError> {
let response = self
.download(protocol, ip, port, session_id, file_id)
.await?;
let mut stream = response.bytes_stream();
let mut total_bytes = 0u64;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
writer.write_all(&chunk).await?;
total_bytes += chunk.len() as u64;
}
writer.flush().await?;
Ok(total_bytes)
}
}
+279
View File
@@ -0,0 +1,279 @@
use super::{ClientError, ResponseExt, ResultWithPublicKey};
use crate::http::client::url::{ApiVersion, TargetUrl};
use crate::http::dto::ProtocolType;
use crate::{crypto, util};
use crate::{http, model};
use bytes::Bytes;
use futures_util::StreamExt;
use lru::LruCache;
use reqwest::{Response, StatusCode};
use std::num::NonZeroUsize;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
pub struct LsHttpClientV3 {
client: reqwest::Client,
/// Maps client identifiers to nonces that have been received from remote.
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
/// Maps client identifiers to nonces that are expected to be received from remote.
generated_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
}
impl LsHttpClientV3 {
pub fn try_new(private_key: &str, cert: &str) -> Result<Self, ClientError> {
Ok(Self {
client: super::create_reqwest_client(private_key, cert)?,
received_nonce_map: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(200).unwrap(),
))),
generated_nonce_map: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(200).unwrap(),
))),
})
}
pub async fn nonce(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
) -> Result<String, ClientError> {
// Generate nonce to send to server
let generated_nonce = crypto::nonce::generate_nonce();
let generated_nonce_base64 = util::base64::encode(&generated_nonce);
let request_body = http::dto::NonceRequest {
nonce: generated_nonce_base64,
};
let res = self
.client
.post(
TargetUrl {
version: ApiVersion::V3,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/nonce",
params: &[],
}
.to_string(),
)
.body(serde_json::to_string(&request_body)?)
.send()
.await?;
if res.status() != StatusCode::OK {
return res.into_error().await;
}
let remote_key = to_identifier(&res, protocol == ProtocolType::Https, None)?;
let body = res.json::<http::dto::NonceResponse>().await?;
// Save the response nonce and our generated nonce
let response_nonce = util::base64::decode(&body.nonce).map_err(|e| anyhow::anyhow!(e))?;
let mut received_nonce_map = self.received_nonce_map.lock().await;
received_nonce_map.put(remote_key.clone(), response_nonce);
let mut generated_nonce_map = self.generated_nonce_map.lock().await;
generated_nonce_map.put(remote_key.clone(), generated_nonce);
tracing::info!("Nonce exchange successful for server: {ip} (ID: {remote_key})");
tracing::debug!(
"Received map: {:?}",
received_nonce_map.get(&remote_key).unwrap()
);
tracing::debug!(
"Generated map: {:?}",
generated_nonce_map.get(&remote_key).unwrap()
);
Ok(body.nonce)
}
pub async fn register(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
payload: http::dto::RegisterDto,
) -> Result<ResultWithPublicKey<http::dto::RegisterResponseDto>, ClientError> {
let res = self
.client
.post(
TargetUrl {
version: ApiVersion::V3,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/register",
params: &[],
}
.to_string(),
)
.body(serde_json::to_string(&payload)?)
.send()
.await?;
let public_key = match protocol {
ProtocolType::Https => Some(super::verify_cert_from_res(&res, None)?),
_ => None,
};
let body = res.json::<http::dto::RegisterResponseDto>().await?;
Ok(ResultWithPublicKey { public_key, body })
}
pub async fn prepare_upload(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
payload: http::dto::PrepareUploadRequestDto,
) -> Result<http::dto::PrepareUploadResult, ClientError> {
let res = self
.client
.post(
TargetUrl {
version: ApiVersion::V3,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/prepare-upload",
params: &[],
}
.to_string(),
)
.body(serde_json::to_string(&payload)?)
.send()
.await?;
if protocol == ProtocolType::Https {
super::verify_cert_from_res(&res, public_key)?;
}
let status = res.status();
if status.as_u16() >= 400 {
return res.into_error().await;
}
if status == StatusCode::NO_CONTENT {
return Ok(http::dto::PrepareUploadResult {
status_code: status.as_u16(),
response: None,
});
}
let body = res.json::<http::dto::PrepareUploadResponseDto>().await?;
Ok(http::dto::PrepareUploadResult {
status_code: status.as_u16(),
response: Some(body),
})
}
/// Uploads a file to the server.
///
/// `cancel` is a cancellation token; cancelling it aborts the upload with
/// [`ClientError::Cancelled`].
pub async fn upload(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
public_key: Option<String>,
session_id: &str,
file_id: &str,
token: &str,
content: model::transfer::FileContent,
cancel: CancellationToken,
) -> Result<(), ClientError> {
let send = self
.client
.post(
TargetUrl {
version: ApiVersion::V3,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/upload",
params: &[
("sessionId", &session_id),
("fileId", &file_id),
("token", &token),
],
}
.to_string(),
)
.body({
let stream =
ReceiverStream::new(content.into_receiver()).map(Ok::<Bytes, anyhow::Error>);
reqwest::Body::wrap_stream(stream)
})
.send();
let res = tokio::select! {
res = send => res?,
_ = cancel.cancelled() => return Err(ClientError::Cancelled),
};
if protocol == ProtocolType::Https {
super::verify_cert_from_res(&res, public_key)?;
}
if res.status() != StatusCode::OK {
return res.into_error().await;
}
Ok(())
}
pub async fn cancel(
&self,
protocol: ProtocolType,
ip: &str,
port: u16,
session_id: &str,
) -> Result<(), ClientError> {
self.client
.post(
TargetUrl {
version: ApiVersion::V3,
protocol: protocol.as_str(),
host: ip.to_string(),
port,
path: "/cancel",
params: &[("sessionId", session_id)],
}
.to_string(),
)
.send()
.await?;
Ok(())
}
}
fn to_identifier(
response: &Response,
require_cert: bool,
public_key: Option<String>,
) -> Result<String, ClientError> {
match require_cert {
true => Ok(super::verify_cert_from_res(response, public_key)?),
false => response
.remote_addr()
.map(|addr| addr.ip().to_string())
.ok_or_else(|| anyhow::anyhow!("Remote address not found in response"))
.map_err(ClientError::Other),
}
}
+172
View File
@@ -0,0 +1,172 @@
use crate::http::dto_v2::{
PrepareUploadRequestDtoV2, PrepareUploadResponseDtoV2, ProtocolTypeV2, RegisterDtoV2,
RegisterResponseDtoV2,
};
use crate::model::discovery::DeviceType;
use crate::model::transfer::FileDto;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct NonceRequest {
/// The nonce string.
pub nonce: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct NonceResponse {
/// The nonce string.
pub nonce: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct ErrorResponse {
/// The error message.
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterDto {
pub alias: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
pub token: String,
pub port: u16,
pub protocol: ProtocolType,
#[serde(default, skip_serializing_if = "is_default")]
pub has_web_interface: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProtocolType {
Http,
Https,
}
impl ProtocolType {
pub fn as_str(&self) -> &'static str {
match self {
ProtocolType::Http => "http",
ProtocolType::Https => "https",
}
}
}
impl From<ProtocolType> for ProtocolTypeV2 {
fn from(p: ProtocolType) -> Self {
match p {
ProtocolType::Http => ProtocolTypeV2::Http,
ProtocolType::Https => ProtocolTypeV2::Https,
}
}
}
impl From<RegisterDto> for RegisterDtoV2 {
fn from(v3: RegisterDto) -> Self {
RegisterDtoV2 {
alias: v3.alias,
version: v3.version,
device_model: v3.device_model,
device_type: v3.device_type,
fingerprint: v3.token,
port: v3.port,
protocol: v3.protocol.into(),
download: v3.has_web_interface,
}
}
}
/// Similar to `RegisterDto`, but without `port` and `protocol` (those are already known).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterResponseDto {
pub alias: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
pub token: String,
#[serde(default, skip_serializing_if = "is_default")]
pub has_web_interface: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUploadRequestDto {
pub info: RegisterDto,
pub files: HashMap<String, FileDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUploadResponseDto {
pub session_id: String,
pub files: HashMap<String, String>,
}
impl From<PrepareUploadRequestDto> for PrepareUploadRequestDtoV2 {
fn from(v3: PrepareUploadRequestDto) -> Self {
PrepareUploadRequestDtoV2 {
info: v3.info.into(),
files: v3.files,
}
}
}
pub struct PrepareUploadResult {
pub status_code: u16,
pub response: Option<PrepareUploadResponseDto>,
}
impl From<PrepareUploadResponseDtoV2> for PrepareUploadResponseDto {
fn from(v2: PrepareUploadResponseDtoV2) -> Self {
PrepareUploadResponseDto {
session_id: v2.session_id,
files: v2.files,
}
}
}
impl From<crate::http::dto_v2::PrepareUploadResultV2> for PrepareUploadResult {
fn from(v2: crate::http::dto_v2::PrepareUploadResultV2) -> Self {
PrepareUploadResult {
status_code: v2.status_code,
response: v2.response.map(|r| r.into()),
}
}
}
impl From<RegisterResponseDtoV2> for RegisterResponseDto {
fn from(v2: RegisterResponseDtoV2) -> Self {
RegisterResponseDto {
alias: v2.alias,
version: v2.version,
device_model: v2.device_model,
device_type: v2.device_type,
token: v2.fingerprint,
has_web_interface: v2.download,
}
}
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
+380
View File
@@ -0,0 +1,380 @@
use crate::model::discovery::DeviceType;
use crate::model::transfer::FileDto;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// The protocol version (major.minor) implemented by this crate for the v2 protocol.
pub const PROTOCOL_VERSION_V2: &str = "2.1";
/// Serde helpers for `DeviceType` in the v2 protocol.
///
/// The v2 protocol uses lowercase values (e.g. "desktop") on the wire.
/// Unknown values fall back to `Desktop` as required by the protocol (section 7.1).
pub(crate) mod device_type_v2 {
use crate::model::discovery::DeviceType;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(
value: &Option<DeviceType>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match value {
Some(device_type) => serializer.serialize_str(match device_type {
DeviceType::Mobile => "mobile",
DeviceType::Desktop => "desktop",
DeviceType::Web => "web",
DeviceType::Headless => "headless",
DeviceType::Server => "server",
}),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<DeviceType>, D::Error> {
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.map(|value| match value.to_lowercase().as_str() {
"mobile" => DeviceType::Mobile,
"desktop" => DeviceType::Desktop,
"web" => DeviceType::Web,
"headless" => DeviceType::Headless,
"server" => DeviceType::Server,
_ => DeviceType::Desktop,
}))
}
}
/// Protocol type for HTTP or HTTPS connections.
#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ProtocolTypeV2 {
Http,
Https,
}
impl ProtocolTypeV2 {
pub fn as_str(&self) -> &'static str {
match self {
ProtocolTypeV2::Http => "http",
ProtocolTypeV2::Https => "https",
}
}
}
/// Multicast announcement/response message for UDP discovery (v2.1).
///
/// Used for both sending announcements and responding to announcements.
/// When `announce` is true, other devices should respond.
/// When `announce` is false, this is a response to an announcement.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MulticastMessageV2 {
/// The display name of the device.
pub alias: String,
/// Protocol version (e.g., "2.1").
pub version: String,
/// Device model (e.g., "Samsung", "Windows"). Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
/// Device type category. Optional.
#[serde(
default,
with = "device_type_v2",
skip_serializing_if = "Option::is_none"
)]
pub device_type: Option<DeviceType>,
/// Fingerprint for device identification.
/// In HTTPS mode: SHA-256 hash of the certificate.
/// In HTTP mode: randomly generated string.
pub fingerprint: String,
/// Port number the device is listening on.
pub port: u16,
/// Protocol type (http or https).
pub protocol: ProtocolTypeV2,
/// Whether the download API (sections 5.2, 5.3) is active.
#[serde(default)]
pub download: bool,
/// Whether this is an announcement (true) or a response (false).
/// Other devices should only respond when this is true.
pub announce: bool,
}
/// Register request DTO for v2.1 protocol.
///
/// Sent to POST /api/localsend/v2/register for device discovery.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterDtoV2 {
/// The display name of the device.
pub alias: String,
/// Protocol version (e.g., "2.0", "2.1").
pub version: String,
/// Device model (e.g., "Samsung", "Windows"). Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
/// Device type category. Optional.
#[serde(
default,
with = "device_type_v2",
skip_serializing_if = "Option::is_none"
)]
pub device_type: Option<DeviceType>,
/// Fingerprint for device identification.
/// Ignored in HTTPS mode (certificate is used instead).
pub fingerprint: String,
/// Port number the device is listening on.
pub port: u16,
/// Protocol type (http or https).
pub protocol: ProtocolTypeV2,
/// Whether the download API (sections 5.2, 5.3) is active.
#[serde(default)]
pub download: bool,
}
/// Register response DTO for v2.1 protocol.
///
/// Response from POST /api/localsend/v2/register.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterResponseDtoV2 {
/// The display name of the device.
pub alias: String,
/// Protocol version (e.g., "2.0", "2.1").
pub version: String,
/// Device model. Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
/// Device type category. Optional.
#[serde(
default,
with = "device_type_v2",
skip_serializing_if = "Option::is_none"
)]
pub device_type: Option<DeviceType>,
/// Fingerprint for device identification.
/// Ignored in HTTPS mode (certificate is used instead).
#[serde(default)]
pub fingerprint: String,
/// Whether the download API (sections 5.2, 5.3) is active.
#[serde(default)]
pub download: bool,
}
/// Prepare upload request DTO for v2.1 protocol.
///
/// Sent to POST /api/localsend/v2/prepare-upload to initiate a file transfer.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUploadRequestDtoV2 {
/// Sender's device information.
pub info: RegisterDtoV2,
/// Map of file ID to file metadata.
pub files: HashMap<String, FileDto>,
}
/// Prepare upload response DTO for v2.1 protocol.
///
/// Response from POST /api/localsend/v2/prepare-upload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUploadResponseDtoV2 {
/// Session ID for the file transfer.
pub session_id: String,
/// Map of file ID to file token.
/// Only contains files that were accepted by the receiver.
pub files: HashMap<String, String>,
}
pub struct PrepareUploadResultV2 {
pub status_code: u16,
pub response: Option<PrepareUploadResponseDtoV2>,
}
/// Prepare download response DTO for v2.1 protocol (Download API).
///
/// Response from POST /api/localsend/v2/prepare-download.
/// Used when the sender provides files for others to download.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareDownloadResponseDtoV2 {
/// Sender's device information.
pub info: InfoResponseDtoV2,
/// Session ID for the download session.
pub session_id: String,
/// Map of file ID to file metadata.
pub files: HashMap<String, FileDto>,
}
/// Info response DTO for v2.1 protocol.
///
/// Response from GET /api/localsend/v2/info.
/// Also used as the `info` field in PrepareDownloadResponseDtoV2.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InfoResponseDtoV2 {
/// The display name of the device.
pub alias: String,
/// Protocol version (e.g., "2.0", "2.1").
pub version: String,
/// Device model. Optional.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
/// Device type category. Optional.
#[serde(
default,
with = "device_type_v2",
skip_serializing_if = "Option::is_none"
)]
pub device_type: Option<DeviceType>,
/// Fingerprint for device identification.
pub fingerprint: String,
/// Whether the download API (sections 5.2, 5.3) is active.
#[serde(default)]
pub download: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multicast_message_serialization() {
let msg = MulticastMessageV2 {
alias: "Nice Orange".to_string(),
version: "2.1".to_string(),
device_model: Some("Samsung".to_string()),
device_type: Some(DeviceType::Mobile),
fingerprint: "random string".to_string(),
port: 53317,
protocol: ProtocolTypeV2::Https,
download: true,
announce: true,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"alias\":\"Nice Orange\""));
assert!(json.contains("\"version\":\"2.1\""));
assert!(json.contains("\"fingerprint\":\"random string\""));
assert!(json.contains("\"announce\":true"));
assert!(json.contains("\"download\":true"));
assert!(json.contains("\"protocol\":\"https\""));
assert!(json.contains("\"deviceType\":\"mobile\""));
}
#[test]
fn test_register_dto_v2_deserialization() {
let json = r#"{
"alias": "Secret Banana",
"version": "2.0",
"deviceModel": "Windows",
"deviceType": "desktop",
"fingerprint": "random string",
"port": 53317,
"protocol": "https",
"download": true
}"#;
let dto: RegisterDtoV2 = serde_json::from_str(json).unwrap();
assert_eq!(dto.alias, "Secret Banana");
assert_eq!(dto.version, "2.0");
assert_eq!(dto.device_model, Some("Windows".to_string()));
assert_eq!(dto.device_type, Some(DeviceType::Desktop));
assert_eq!(dto.fingerprint, "random string");
assert_eq!(dto.port, 53317);
assert_eq!(dto.protocol, ProtocolTypeV2::Https);
assert!(dto.download);
}
#[test]
fn test_device_type_unknown_falls_back_to_desktop() {
// Unknown device types must fall back to desktop (protocol section 7.1).
let json = r#"{
"alias": "Test Device",
"version": "2.0",
"deviceType": "fridge",
"fingerprint": "abc123",
"port": 53317,
"protocol": "http"
}"#;
let dto: RegisterDtoV2 = serde_json::from_str(json).unwrap();
assert_eq!(dto.device_type, Some(DeviceType::Desktop));
}
#[test]
fn test_register_response_without_download_field() {
// Test that download defaults to false when not present
let json = r#"{
"alias": "Test Device",
"version": "2.0",
"fingerprint": "abc123"
}"#;
let dto: RegisterResponseDtoV2 = serde_json::from_str(json).unwrap();
assert_eq!(dto.alias, "Test Device");
assert!(!dto.download);
}
#[test]
fn test_prepare_upload_request_v2() {
let request = PrepareUploadRequestDtoV2 {
info: RegisterDtoV2 {
alias: "Sender".to_string(),
version: "2.1".to_string(),
device_model: None,
device_type: None,
fingerprint: "sender-fingerprint".to_string(),
port: 53317,
protocol: ProtocolTypeV2::Https,
download: false,
},
files: HashMap::from([(
"file1".to_string(),
FileDto {
id: "file1".to_string(),
file_name: "test.png".to_string(),
size: 1024,
file_type: "image/png".to_string(),
sha256: None,
preview: None,
metadata: None,
},
)]),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"info\""));
assert!(json.contains("\"files\""));
assert!(json.contains("\"fingerprint\":\"sender-fingerprint\""));
}
}
+14
View File
@@ -0,0 +1,14 @@
use thiserror::Error;
pub mod client;
pub mod dto;
pub mod dto_v2;
pub mod server;
pub mod state;
#[derive(Debug, Error)]
#[error("{status};{message:?}")]
pub struct StatusCodeError {
pub status: u16,
pub message: Option<String>,
}
@@ -0,0 +1,83 @@
use rustls::client::danger::HandshakeSignatureValid;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, UnixTime};
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
use rustls::server::WebPkiClientVerifier;
use rustls::{DigitallySignedStruct, DistinguishedName, Error, RootCertStore, SignatureScheme};
use std::fmt::{Debug, Formatter};
use std::sync::Arc;
use x509_parser::nom::AsBytes;
/// Enables client certificate verification.
pub(crate) struct CustomClientCertVerifier {
inner: Arc<dyn ClientCertVerifier>,
}
impl CustomClientCertVerifier {
pub(crate) fn try_new(cert: &str) -> anyhow::Result<Self> {
// We add the certificate of the server itself just so that no "empty" error is returned.
// We don't care about the authority of the certificate, just that it is valid.
let mut root_cert_store = RootCertStore::empty();
root_cert_store.add(PemObject::from_pem_slice(cert.as_bytes())?)?;
Ok(Self {
inner: WebPkiClientVerifier::builder(Arc::new(root_cert_store)).build()?,
})
}
}
impl Debug for CustomClientCertVerifier {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.inner.fmt(f)
}
}
impl ClientCertVerifier for CustomClientCertVerifier {
fn offer_client_auth(&self) -> bool {
true
}
fn client_auth_mandatory(&self) -> bool {
true
}
fn root_hint_subjects(&self) -> &[DistinguishedName] {
self.inner.root_hint_subjects()
}
fn verify_client_cert(
&self,
cert: &CertificateDer<'_>,
_: &[CertificateDer<'_>],
_: UnixTime,
) -> Result<ClientCertVerified, Error> {
// We trust any certificate that is valid.
crate::crypto::cert::verify_cert_from_der(cert.as_bytes(), None).map_err(|e| {
tracing::warn!("Client certificate verification failed: {e:#}");
Error::InvalidCertificate(rustls::CertificateError::ApplicationVerificationFailure)
})?;
Ok(ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
self.inner.verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
self.inner.verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.inner.supported_verify_schemes()
}
}
@@ -0,0 +1,18 @@
use crate::http::server::common::error::AppError;
use http_body_util::BodyExt;
use hyper::body::Incoming;
use serde::de::DeserializeOwned;
pub(crate) trait CollectToJson {
async fn collect_to_json<T: DeserializeOwned>(self) -> Result<T, AppError>;
}
impl CollectToJson for Incoming {
async fn collect_to_json<T: DeserializeOwned>(self) -> Result<T, AppError> {
let bytes = self.collect().await?.to_bytes();
serde_json::from_slice::<T>(&bytes).map_err(|err| {
tracing::warn!("Failed to parse JSON body: {err:#}");
AppError::BadRequest("Invalid JSON body".to_string())
})
}
}
@@ -0,0 +1,47 @@
use crate::http::dto::ErrorResponse;
use crate::http::server::common::response::{BoxedBody, JsonResponse};
use hyper::{Response, StatusCode};
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Hyper error: {0}")]
Hyper(#[from] hyper::Error),
#[error("Status Error: {0}")]
Status(StatusCode),
#[error("Invalid request: {0}")]
BadRequest(String),
#[error("{0}: {1}")]
Message(StatusCode, String),
}
impl AppError {
pub(crate) fn to_response(self) -> Response<BoxedBody> {
let json = match self {
AppError::Hyper(_) => JsonResponse {
status: StatusCode::INTERNAL_SERVER_ERROR,
body: ErrorResponse {
message: "Internal server error".to_string(),
},
},
AppError::Status(code) => JsonResponse {
status: code,
body: ErrorResponse {
message: format!("Status code: {code}"),
},
},
AppError::BadRequest(message) => JsonResponse {
status: StatusCode::BAD_REQUEST,
body: ErrorResponse { message },
},
AppError::Message(status, message) => JsonResponse {
status,
body: ErrorResponse { message },
},
};
json.into_response()
}
}
@@ -0,0 +1,8 @@
pub mod client_cert_verifier;
pub mod collect_to_json;
pub mod error;
pub mod pin;
pub mod query;
pub mod response;
pub mod save;
pub mod session;
@@ -0,0 +1,48 @@
use crate::http::server::common::error::AppError;
use hyper::StatusCode;
use lru::LruCache;
use std::collections::HashMap;
use std::net::IpAddr;
use tokio::sync::Mutex;
/// Maximum failed PIN attempts per IP before requests are blocked with 429.
const MAX_PIN_ATTEMPTS: u32 = 3;
/// Checks the `pin` query parameter against the required PIN (if any).
pub(crate) async fn check_pin(
required_pin: Option<&str>,
pin_attempts: &Mutex<LruCache<IpAddr, u32>>,
query: &HashMap<String, String>,
ip: IpAddr,
) -> Result<(), AppError> {
let Some(required_pin) = required_pin else {
return Ok(());
};
let mut attempts = pin_attempts.lock().await;
let count = attempts.get(&ip).copied().unwrap_or(0);
if count >= MAX_PIN_ATTEMPTS {
return Err(AppError::Message(
StatusCode::TOO_MANY_REQUESTS,
"Too many requests".to_string(),
));
}
match query.get("pin") {
Some(pin) if pin == required_pin => {
attempts.pop(&ip);
Ok(())
}
Some(_) => {
attempts.put(ip, count + 1);
Err(AppError::Message(
StatusCode::UNAUTHORIZED,
"Invalid PIN".to_string(),
))
}
None => Err(AppError::Message(
StatusCode::UNAUTHORIZED,
"PIN required".to_string(),
)),
}
}
@@ -0,0 +1,45 @@
use std::collections::HashMap;
/// Parses a URL query string into a key-value map with percent-decoding.
pub(crate) fn parse_query(query: Option<&str>) -> HashMap<String, String> {
let Some(query) = query else {
return HashMap::new();
};
form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect()
}
#[cfg(test)]
mod tests {
use super::parse_query;
#[test]
fn test_parse_query() {
let query = parse_query(Some("sessionId=abc&fileId=some%20file&token=a%2Bb+c"));
assert_eq!(query.get("sessionId").unwrap(), "abc");
assert_eq!(query.get("fileId").unwrap(), "some file");
assert_eq!(query.get("token").unwrap(), "a+b c");
}
#[test]
fn test_parse_query_empty() {
assert!(parse_query(None).is_empty());
assert!(parse_query(Some("")).is_empty());
}
#[test]
fn test_parse_query_no_value() {
let query = parse_query(Some("flag&pin=123456"));
assert_eq!(query.get("flag").unwrap(), "");
assert_eq!(query.get("pin").unwrap(), "123456");
}
#[test]
fn test_parse_query_invalid_percent() {
let query = parse_query(Some("a=%zz&b=%4"));
assert_eq!(query.get("a").unwrap(), "%zz");
assert_eq!(query.get("b").unwrap(), "%4");
}
}
@@ -0,0 +1,42 @@
use bytes::Bytes;
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::{http, Response, StatusCode};
use serde::Serialize;
/// Response body that is either fully buffered or streamed (e.g. file downloads).
pub(crate) type BoxedBody = BoxBody<Bytes, std::io::Error>;
/// Creates a fully buffered response body.
pub(crate) fn full_body(bytes: impl Into<Bytes>) -> BoxedBody {
Full::new(bytes.into())
.map_err(std::io::Error::other)
.boxed()
}
/// Creates an empty response body.
pub(crate) fn empty_body() -> BoxedBody {
full_body(Bytes::new())
}
pub(crate) struct JsonResponse<T: Serialize> {
pub(crate) status: StatusCode,
pub(crate) body: T,
}
impl<T: Serialize> JsonResponse<T> {
pub(crate) fn into_response(self) -> Response<BoxedBody> {
let mut response = Response::new(empty_body());
*response.status_mut() = self.status;
response.headers_mut().insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/json"),
);
*response.body_mut() =
full_body(serde_json::to_string(&self.body).unwrap_or_else(|_| "{}".to_string()));
response
}
}
@@ -0,0 +1,188 @@
use bytes::Bytes;
use http_body_util::BodyExt;
use hyper::body::Incoming;
use hyper::Request;
use std::future::Future;
use std::path::PathBuf;
use tokio::sync::{mpsc, oneshot};
/// Channel capacity for file upload chunks (provides backpressure).
const UPLOAD_CHANNEL_CAPACITY: usize = 16;
/// Where the content of an uploaded file should go, decided by the application.
#[derive(Debug)]
pub enum FileUploadTarget {
/// The application consumes the binary chunks itself.
///
/// The server forwards chunks into `binary_tx` and closes it at end of file.
/// The application should compare the number of received bytes with `file.size`
/// and report the result on the sender side of `result_rx` which determines
/// the HTTP response (200 on `Ok`, 500 on `Err` or when the sender is dropped).
Stream {
/// Channel the server sends the binary chunks of the file into.
binary_tx: mpsc::Sender<Bytes>,
/// Channel on which the application reports whether the file was
/// processed successfully.
result_rx: oneshot::Receiver<Result<(), String>>,
},
/// The server writes the file to this path (created or truncated)
/// and reports the result on `result_tx`.
Path {
/// The path to write the file to.
path: PathBuf,
/// Channel on which the server reports whether the file was saved successfully.
result_tx: oneshot::Sender<Result<(), String>>,
},
/// The server writes the file to this raw file descriptor (Android only)
/// and reports the result on `result_tx`.
#[cfg(target_os = "android")]
Fd {
/// The raw file descriptor to write the file to.
/// Ownership is transferred; the descriptor is closed after writing.
fd: std::os::fd::RawFd,
/// Channel on which the server reports whether the file was saved successfully.
result_tx: oneshot::Sender<Result<(), String>>,
},
}
pub(crate) async fn save_req_to_target(
req: Request<Incoming>,
target: FileUploadTarget,
file_size: u64,
) -> bool {
// Resolve the target into a chunk sender and a result receiver.
let (binary_tx, result_rx) = match target {
FileUploadTarget::Stream {
binary_tx,
result_rx,
} => (binary_tx, result_rx),
FileUploadTarget::Path { path, result_tx } => spawn_file_writer(
async move {
tokio::fs::File::create(&path)
.await
.map_err(|e| format!("Failed to create {}: {e}", path.display()))
},
file_size,
result_tx,
),
#[cfg(target_os = "android")]
FileUploadTarget::Fd { fd, result_tx } => spawn_file_writer(
async move {
use std::os::fd::FromRawFd;
// SAFETY: the descriptor is owned by this transfer; wrapping it in
// a File transfers that ownership so it is closed once writing finishes.
let std_file = unsafe { std::fs::File::from_raw_fd(fd) };
Ok(tokio::fs::File::from_std(std_file))
},
file_size,
result_tx,
),
};
// Forward the request body to the target.
let mut body = req.into_body();
let mut stream_error = false;
while let Some(frame) = body.frame().await {
match frame {
Ok(frame) => {
let Ok(data) = frame.into_data() else {
continue; // ignore non-data frames (e.g. trailers)
};
if data.is_empty() {
continue;
}
if binary_tx.send(data).await.is_err() {
// The receiver is gone (dropped by the application or
// closed by the file writer after an error).
stream_error = true;
break;
}
}
Err(err) => {
tracing::warn!("Error reading upload body of file: {err:#}");
stream_error = true;
break;
}
}
}
// Signal end of file to the receiving side.
drop(binary_tx);
match stream_error {
true => false,
false => match result_rx.await {
Ok(Ok(())) => true,
Ok(Err(err)) => {
tracing::warn!("Failed to process file: {err}");
false
}
Err(_) => false,
},
}
}
/// Spawns a task that writes incoming chunks to a file provided by `open`.
///
/// Returns the sender for the binary chunks and a receiver for the final result.
/// The result is additionally reported to the application on `result_tx`.
fn spawn_file_writer(
open: impl Future<Output = Result<tokio::fs::File, String>> + Send + 'static,
expected_size: u64,
result_tx: oneshot::Sender<Result<(), String>>,
) -> (mpsc::Sender<Bytes>, oneshot::Receiver<Result<(), String>>) {
let (binary_tx, mut binary_rx) = mpsc::channel::<Bytes>(UPLOAD_CHANNEL_CAPACITY);
let (internal_tx, internal_rx) = oneshot::channel::<Result<(), String>>();
tokio::spawn(async move {
let result = write_file_from_receiver(open, expected_size, &mut binary_rx).await;
// Unblock the request handler if it is still sending chunks.
binary_rx.close();
let _ = result_tx.send(result.clone());
let _ = internal_tx.send(result);
});
(binary_tx, internal_rx)
}
/// Writes all chunks received on `rx` to the file provided by `open`.
///
/// Fails if the total number of written bytes does not match `expected_size`
/// (e.g. the sender disconnected mid-transfer).
async fn write_file_from_receiver(
open: impl Future<Output = Result<tokio::fs::File, String>>,
expected_size: u64,
rx: &mut mpsc::Receiver<Bytes>,
) -> Result<(), String> {
use tokio::io::AsyncWriteExt;
let mut file = open.await?;
let mut written: u64 = 0;
while let Some(chunk) = rx.recv().await {
written += chunk.len() as u64;
if written > expected_size {
return Err(format!(
"Expected {expected_size} bytes, received at least {written}"
));
}
file.write_all(&chunk)
.await
.map_err(|e| format!("Failed to write file: {e}"))?;
}
file.flush()
.await
.map_err(|e| format!("Failed to flush file: {e}"))?;
if written != expected_size {
return Err(format!(
"Expected {expected_size} bytes, received {written}"
));
}
Ok(())
}
@@ -0,0 +1,48 @@
use crate::model::transfer::FileDto;
use std::collections::HashMap;
use std::net::IpAddr;
/// State of the single v2 upload session slot.
pub(crate) enum SessionStateV2 {
/// A prepare-upload request is waiting for the application's decision.
Pending,
/// An accepted upload session.
Active(UploadSessionV2),
}
pub(crate) struct UploadSessionV2 {
pub(crate) session_id: String,
/// The IP address of the sender. Uploads are only accepted from this address.
pub(crate) sender_ip: IpAddr,
/// The accepted files, mapped by file ID.
pub(crate) files: HashMap<String, SessionFileV2>,
}
impl UploadSessionV2 {
/// Whether all files reached a final state.
pub(crate) fn is_complete(&self) -> bool {
self.files
.values()
.all(|file| matches!(file.status, FileStatusV2::Finished | FileStatusV2::Failed))
}
}
pub(crate) struct SessionFileV2 {
pub(crate) dto: FileDto,
/// The file-specific token required for the upload request.
pub(crate) token: String,
pub(crate) status: FileStatusV2,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FileStatusV2 {
Pending,
InProgress,
Finished,
Failed,
}
+384
View File
@@ -0,0 +1,384 @@
pub mod common;
pub mod v2;
pub mod v3;
pub mod web;
use crate::crypto::cert::public_key_from_cert_der;
use crate::http::server::v2::ServerEventV2;
use crate::http::server::web::WebSendConfig;
use crate::http::state::ClientInfo;
use common::client_cert_verifier::CustomClientCertVerifier;
use common::error::AppError;
use common::response;
use common::response::BoxedBody;
use common::session::SessionStateV2;
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder;
use lru::LruCache;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::fmt::Debug;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::num::NonZeroUsize;
use std::ops::Deref;
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot, Mutex};
use web::WebPageState;
/// Configuration for the v2 (legacy) protocol endpoints.
pub struct ServerConfigV2 {
/// Optional PIN that senders must provide via the `pin` query parameter.
pub pin: Option<String>,
/// Channel on which the server emits events that must be handled by the application.
pub event_tx: mpsc::Sender<ServerEventV2>,
}
/// Runtime state of the v2 protocol endpoints.
pub(crate) struct V2State {
/// Optional PIN required for prepare-upload requests.
pub(crate) pin: Option<String>,
/// Channel on which server events are emitted to the application.
pub(crate) event_tx: mpsc::Sender<ServerEventV2>,
/// The single upload session slot. Only one session can be active at a time.
pub(crate) session: Mutex<Option<SessionStateV2>>,
/// Maps client IPs to the number of failed PIN attempts.
pub(crate) pin_attempts: Mutex<LruCache<IpAddr, u32>>,
}
#[derive(Clone)]
pub struct AppState {
/// Information about server's device.
info: Arc<Mutex<ClientInfo>>,
/// State for serving web pages.
web: Option<Arc<WebPageState>>,
/// Maps client identifiers to nonces that have been received from remote.
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
/// Maps client identifiers to nonces that are expected to be received from remote.
generated_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
/// State of the v2 protocol endpoints. `None` disables the v2 routes.
v2: Option<Arc<V2State>>,
}
impl AppState {
fn new(
info: Arc<Mutex<ClientInfo>>,
v2_config: Option<ServerConfigV2>,
web_send_config: Option<WebSendConfig>,
) -> Self {
let v2 = v2_config.map(|config| {
Arc::new(V2State {
pin: config.pin,
event_tx: config.event_tx,
session: Mutex::new(None),
pin_attempts: Mutex::new(LruCache::new(NonZeroUsize::new(200).unwrap())),
})
});
let web = web_send_config.map(|config| Arc::new(WebPageState::new(config)));
Self {
info,
web,
received_nonce_map: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(200).unwrap(),
))),
generated_nonce_map: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(200).unwrap(),
))),
v2,
}
}
}
/// Binds the server to the specified port on both IPv4 and IPv6 addresses.
pub async fn start_with_port(
port: u16,
tls_config: Option<TlsConfig>,
info: ClientInfo,
v2_config: Option<ServerConfigV2>,
web_send_config: Option<WebSendConfig>,
stop_rx: oneshot::Receiver<()>,
) -> anyhow::Result<()> {
let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port);
let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port);
let info = Arc::new(Mutex::new(info));
let state = AppState::new(info.clone(), v2_config, web_send_config);
let ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?;
let ipv6_listener = match bind_ipv6_only(ipv6_socket_addr) {
Ok(listener) => Some(listener),
Err(err) => {
tracing::warn!("Failed to start server on {}: {err:#}", ipv6_socket_addr);
None
}
};
tokio::spawn({
let state = state.clone();
async move {
tokio::select! {
_ = start_server_with_listener(ipv4_listener, tls_config.clone(), state.clone()) => {
tracing::info!("Server stopped on: {}", ipv4_socket_addr);
}
_ = async {
if let Some(listener) = ipv6_listener {
let _ = start_server_with_listener(listener, tls_config, state).await;
}
// Keep the future running forever, so we continue using "ipv4 only" even if ipv6 fails.
tokio::time::sleep(std::time::Duration::from_secs(u64::MAX)).await;
} => {}
_ = stop_rx => {}
}
}
});
Ok(())
}
/// Binds an IPv6 listener with `IPV6_V6ONLY` enabled.
///
/// Without this flag, some systems (e.g. macOS) bind IPv6 wildcard sockets in
/// dual-stack mode, which conflicts with the separate IPv4 listener on the same port.
fn bind_ipv6_only(socket_addr: SocketAddr) -> anyhow::Result<tokio::net::TcpListener> {
let socket = socket2::Socket::new(
socket2::Domain::IPV6,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)?;
socket.set_only_v6(true)?;
#[cfg(not(windows))]
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&socket_addr.into())?;
socket.listen(1024)?;
Ok(tokio::net::TcpListener::from_std(socket.into())?)
}
#[derive(Clone, Debug)]
pub struct TlsConfig {
pub cert: String,
pub private_key: String,
}
async fn start_server_with_listener(
incoming: tokio::net::TcpListener,
tls_config: Option<TlsConfig>,
app_state: AppState,
) -> anyhow::Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default();
let tls_acceptor = match tls_config {
Some(tls_config) => Some(create_tls_config(&tls_config).inspect_err(|err| {
tracing::error!("failed to create tls config: {err:#}");
})?),
None => None,
};
tracing::info!(
"Started server on {} (TLS: {})",
incoming.local_addr()?,
tls_acceptor.is_some()
);
loop {
let (tcp_stream, remote_addr) = incoming.accept().await?;
let tls_acceptor = tls_acceptor.clone();
let app_state = app_state.clone();
tokio::spawn(async move {
let res = match tls_acceptor {
Some(tls_acceptor) => {
let tls_stream = match tls_acceptor.accept(tcp_stream).await {
Ok(tls_stream) => tls_stream,
Err(err) => {
tracing::warn!("TLS handshake error: {err:#}");
return;
}
};
let client_info = {
let (_, server_connection) = tls_stream.get_ref();
RequestClientInfo {
ip: remote_addr.ip(),
cert: server_connection
.deref()
.deref()
.peer_certificates()
.map(|cert| cert.get(0).unwrap().to_vec()),
}
};
Builder::new(TokioExecutor::new())
.serve_connection(
TokioIo::new(tls_stream),
hyper::service::service_fn(move |mut req: Request<Incoming>| {
req.extensions_mut()
.insert::<RequestClientInfo>(client_info.clone());
req.extensions_mut().insert::<AppState>(app_state.clone());
handle_request(req)
}),
)
.await
}
None => {
Builder::new(TokioExecutor::new())
.serve_connection(
TokioIo::new(tcp_stream),
hyper::service::service_fn(move |mut req: Request<Incoming>| {
req.extensions_mut().insert::<RequestClientInfo>(
RequestClientInfo {
ip: remote_addr.ip(),
cert: None,
},
);
req.extensions_mut().insert::<AppState>(app_state.clone());
handle_request(req)
}),
)
.await
}
};
if let Err(err) = res {
tracing::warn!("Failed to serve connection: {err:#}");
}
});
}
}
fn create_tls_config(tls_config: &TlsConfig) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
let config = {
let certs = vec![CertificateDer::from_pem_slice(&tls_config.cert.as_bytes())?];
let key = PrivateKeyDer::from_pem_slice(&tls_config.private_key.as_bytes())?;
rustls::ServerConfig::builder()
.with_client_cert_verifier(Arc::new(CustomClientCertVerifier::try_new(
&tls_config.cert,
)?))
.with_single_cert(certs, key)?
};
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
}
#[derive(Clone, Debug)]
pub struct RequestClientInfo {
/// The IP address of the client.
ip: IpAddr,
/// The client certificate in DER format.
cert: Option<Vec<u8>>,
}
impl RequestClientInfo {
fn extract_public_key(&self) -> Option<String> {
match &self.cert {
Some(cert) => match public_key_from_cert_der(cert) {
Ok(public_key) => Some(public_key),
Err(err) => {
tracing::warn!("Failed to extract public key from certificate: {err:#}");
None
}
},
None => None,
}
}
fn identifier(&self) -> String {
self.extract_public_key()
.unwrap_or_else(|| self.ip.to_string())
}
}
async fn handle_request(req: Request<Incoming>) -> Result<Response<BoxedBody>, hyper::Error> {
Ok(handle_request_inner(req).await.unwrap_or_else(|err| {
tracing::error!("Error handling request: {err:?}");
err.to_response()
}))
}
async fn handle_request_inner(mut req: Request<Incoming>) -> Result<Response<BoxedBody>, AppError> {
let Some(state) = req.extensions_mut().remove::<AppState>() else {
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
};
let Some(client_info) = req.extensions_mut().remove::<RequestClientInfo>() else {
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
};
let v2_enabled = state.v2.is_some();
match (req.method(), req.uri().path()) {
(&Method::GET, "/") => Ok(web::index(&state)),
(&Method::GET, "/main.js") => Ok(web::main_js(&state)),
(&Method::GET, "/i18n.json") => web::i18n(&state),
(&Method::POST, "/api/localsend/v2/prepare-download") => {
web::prepare_download(req, state, client_info).await
}
(&Method::GET, "/api/localsend/v2/download") => {
web::download(req, state, client_info).await
}
(&Method::POST, "/api/localsend/v2/register") => {
if !v2_enabled {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
Ok(v2::register(req.into_body(), state, client_info)
.await?
.into_response())
}
(&Method::GET, "/api/localsend/v2/info") => {
if !v2_enabled {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
Ok(v2::info(state).await?.into_response())
}
(&Method::POST, "/api/localsend/v2/prepare-upload") => {
if !v2_enabled {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
v2::prepare_upload(req, state, client_info).await
}
(&Method::POST, "/api/localsend/v2/upload") => {
if !v2_enabled {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
v2::upload(req, state, client_info).await
}
(&Method::POST, "/api/localsend/v2/cancel") => {
if !v2_enabled {
return Err(AppError::Status(StatusCode::NOT_FOUND));
}
v2::cancel(req, state).await
}
(&Method::POST, "/api/localsend/v3/nonce") => {
Ok(v3::nonce_exchange(req.into_body(), state, client_info)
.await?
.into_response())
}
(&Method::POST, "/api/localsend/v3/register") => {
Ok(v3::register(req.into_body(), state, client_info)
.await?
.into_response())
}
_ => {
let mut res = Response::new(response::empty_body());
*res.status_mut() = StatusCode::NOT_FOUND;
Ok(res)
}
}
}
+506
View File
@@ -0,0 +1,506 @@
use crate::http::dto_v2::{
InfoResponseDtoV2, PrepareUploadRequestDtoV2, PrepareUploadResponseDtoV2, RegisterDtoV2,
RegisterResponseDtoV2, PROTOCOL_VERSION_V2,
};
use crate::http::server::common::collect_to_json::CollectToJson;
use crate::http::server::common::error::AppError;
use crate::http::server::common::pin::check_pin;
use crate::http::server::common::query::parse_query;
use crate::http::server::common::response::{empty_body, BoxedBody, JsonResponse};
use crate::http::server::common::save::FileUploadTarget;
use crate::http::server::common::session::{
FileStatusV2, SessionFileV2, SessionStateV2, UploadSessionV2,
};
use crate::http::server::{common, AppState, RequestClientInfo, V2State};
use crate::model::transfer::FileDto;
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode};
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::sync::Arc;
use tokio::sync::oneshot;
use uuid::Uuid;
/// Events emitted by the v2 HTTP server that must be handled by the application.
#[derive(Debug)]
pub enum ServerEventV2 {
/// A device registered itself via `POST /api/localsend/v2/register`.
Register {
/// The IP address of the remote device.
ip: IpAddr,
/// The device information sent by the remote device.
info: RegisterDtoV2,
},
/// A sender requests to upload files via `POST /api/localsend/v2/prepare-upload`.
///
/// The application must answer on `decision_tx`.
/// Dropping `decision_tx` results in a 500 response.
PrepareUpload {
/// The IP address of the sender.
ip: IpAddr,
/// The device information of the sender.
info: RegisterDtoV2,
/// The offered files, mapped by file ID.
files: HashMap<String, FileDto>,
/// Channel to send the decision (accept all, a subset, or decline).
decision_tx: oneshot::Sender<PrepareUploadDecisionV2>,
},
/// An accepted file is being uploaded via `POST /api/localsend/v2/upload`.
///
/// The application must answer on `target_tx` with where the file content
/// should go (a stream to consume itself, a path, or a file descriptor).
/// Dropping `target_tx` results in a 500 response.
FileUpload {
/// The session ID of the upload session.
session_id: String,
/// The ID of the file being uploaded.
file_id: String,
/// The metadata of the file being uploaded.
file: FileDto,
/// Channel to send the target the file content should be written to.
target_tx: oneshot::Sender<FileUploadTarget>,
},
/// An upload session ended.
SessionEnd {
/// The session ID of the ended session.
session_id: String,
/// Why the session ended.
reason: SessionEndReasonV2,
},
}
/// The application's decision for a prepare-upload request.
#[derive(Debug)]
pub enum PrepareUploadDecisionV2 {
/// Accept the given file IDs (a subset of the offered files).
/// An empty set responds with 204 (no file transfer needed).
Accept(HashSet<String>),
/// Decline the request (403).
Decline,
}
/// Why an upload session ended.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionEndReasonV2 {
/// All accepted files reached a final state (finished or failed).
Finished,
/// The sender cancelled the session via `POST /api/localsend/v2/cancel`.
Cancelled,
}
pub(crate) async fn register(
body: Incoming,
state: AppState,
client_info: RequestClientInfo,
) -> Result<JsonResponse<RegisterResponseDtoV2>, AppError> {
let payload = body.collect_to_json::<RegisterDtoV2>().await?;
if let Some(v2) = &state.v2 {
let _ = v2
.event_tx
.send(ServerEventV2::Register {
ip: client_info.ip,
info: payload,
})
.await;
}
let info = state.info.lock().await.clone();
let download = state.web.is_some();
Ok(JsonResponse {
status: StatusCode::OK,
body: RegisterResponseDtoV2 {
alias: info.alias,
version: PROTOCOL_VERSION_V2.to_string(),
device_model: info.device_model,
device_type: info.device_type,
fingerprint: info.token,
download,
},
})
}
pub(crate) async fn info(state: AppState) -> Result<JsonResponse<InfoResponseDtoV2>, AppError> {
let info = state.info.lock().await.clone();
let download = state.web.is_some();
Ok(JsonResponse {
status: StatusCode::OK,
body: InfoResponseDtoV2 {
alias: info.alias,
version: PROTOCOL_VERSION_V2.to_string(),
device_model: info.device_model,
device_type: info.device_type,
fingerprint: info.token,
download,
},
})
}
pub(crate) async fn prepare_upload(
req: Request<Incoming>,
state: AppState,
client_info: RequestClientInfo,
) -> Result<Response<BoxedBody>, AppError> {
let v2 = require_v2(&state)?;
let query = parse_query(req.uri().query());
check_pin(v2.pin.as_deref(), &v2.pin_attempts, &query, client_info.ip).await?;
let payload = req
.into_body()
.collect_to_json::<PrepareUploadRequestDtoV2>()
.await?;
if payload.files.is_empty() {
return Err(AppError::BadRequest("No files provided".to_string()));
}
// Claim the single session slot.
{
let mut slot = v2.session.lock().await;
if slot.is_some() {
return Err(AppError::Message(
StatusCode::CONFLICT,
"Blocked by another session".to_string(),
));
}
*slot = Some(SessionStateV2::Pending);
}
// Frees the slot again if this request is aborted before a session is created.
let mut pending_guard = PendingSessionGuard::new(v2.clone());
let (decision_tx, decision_rx) = oneshot::channel();
let event = ServerEventV2::PrepareUpload {
ip: client_info.ip,
info: payload.info,
files: payload.files.clone(),
decision_tx,
};
if v2.event_tx.send(event).await.is_err() {
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
let decision = decision_rx
.await
.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?;
let accepted_ids = match decision {
PrepareUploadDecisionV2::Decline => {
pending_guard.clear().await;
return Err(AppError::Message(
StatusCode::FORBIDDEN,
"Rejected".to_string(),
));
}
PrepareUploadDecisionV2::Accept(ids) => ids,
};
let files: HashMap<String, SessionFileV2> = payload
.files
.into_iter()
.filter(|(id, _)| accepted_ids.contains(id))
.map(|(id, dto)| {
let file = SessionFileV2 {
dto,
token: Uuid::new_v4().to_string(),
status: FileStatusV2::Pending,
};
(id, file)
})
.collect();
if files.is_empty() {
// Nothing to transfer.
pending_guard.clear().await;
let mut res = Response::new(empty_body());
*res.status_mut() = StatusCode::NO_CONTENT;
return Ok(res);
}
let session_id = Uuid::new_v4().to_string();
let tokens: HashMap<String, String> = files
.iter()
.map(|(id, file)| (id.clone(), file.token.clone()))
.collect();
{
let mut slot = v2.session.lock().await;
*slot = Some(SessionStateV2::Active(UploadSessionV2 {
session_id: session_id.clone(),
sender_ip: client_info.ip,
files,
}));
}
pending_guard.disarm();
tracing::info!("Upload session created: {session_id}");
Ok(JsonResponse {
status: StatusCode::OK,
body: PrepareUploadResponseDtoV2 {
session_id,
files: tokens,
},
}
.into_response())
}
pub(crate) async fn upload(
req: Request<Incoming>,
state: AppState,
client_info: RequestClientInfo,
) -> Result<Response<BoxedBody>, AppError> {
let v2 = require_v2(&state)?;
let query = parse_query(req.uri().query());
let (Some(session_id), Some(file_id), Some(token)) = (
query.get("sessionId"),
query.get("fileId"),
query.get("token"),
) else {
return Err(AppError::Message(
StatusCode::BAD_REQUEST,
"Missing parameters".to_string(),
));
};
// Validate the request and mark the file as in progress.
let file_dto = {
let mut slot = v2.session.lock().await;
let Some(SessionStateV2::Active(session)) = slot.as_mut() else {
return Err(invalid_token_error());
};
if session.session_id != *session_id || session.sender_ip != client_info.ip {
return Err(invalid_token_error());
}
let Some(file) = session.files.get_mut(file_id) else {
return Err(invalid_token_error());
};
if file.token != *token || file.status != FileStatusV2::Pending {
return Err(invalid_token_error());
}
file.status = FileStatusV2::InProgress;
file.dto.clone()
};
// Marks the file as failed if this request is aborted mid-transfer.
let mut upload_guard = UploadGuard::new(v2.clone(), session_id.clone(), file_id.clone());
let file_size = file_dto.size;
let (target_tx, target_rx) = oneshot::channel::<FileUploadTarget>();
let event = ServerEventV2::FileUpload {
session_id: session_id.clone(),
file_id: file_id.clone(),
file: file_dto,
target_tx,
};
if v2.event_tx.send(event).await.is_err() {
upload_guard.finish(false).await;
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
let Ok(target) = target_rx.await else {
upload_guard.finish(false).await;
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
};
let success = common::save::save_req_to_target(req, target, file_size).await;
upload_guard.finish(success).await;
match success {
true => Ok(Response::new(empty_body())),
false => Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR)),
}
}
pub(crate) async fn cancel(
req: Request<Incoming>,
state: AppState,
) -> Result<Response<BoxedBody>, AppError> {
let v2 = require_v2(&state)?;
let query = parse_query(req.uri().query());
if let Some(session_id) = query.get("sessionId") {
let cancelled = {
let mut slot = v2.session.lock().await;
match slot.as_ref() {
Some(SessionStateV2::Active(session)) if session.session_id == *session_id => {
*slot = None;
true
}
_ => false,
}
};
if cancelled {
tracing::info!("Upload session cancelled by sender: {session_id}");
let _ = v2
.event_tx
.send(ServerEventV2::SessionEnd {
session_id: session_id.clone(),
reason: SessionEndReasonV2::Cancelled,
})
.await;
}
}
Ok(Response::new(empty_body()))
}
fn require_v2(state: &AppState) -> Result<Arc<V2State>, AppError> {
state
.v2
.clone()
.ok_or(AppError::Status(StatusCode::NOT_FOUND))
}
fn invalid_token_error() -> AppError {
AppError::Message(
StatusCode::FORBIDDEN,
"Invalid token or IP address".to_string(),
)
}
/// Frees a claimed `Pending` session slot unless a session was created.
///
/// The cleanup also runs on drop so the slot is not leaked
/// when the request future is cancelled (e.g. the sender disconnected
/// while the application was still deciding).
struct PendingSessionGuard {
v2: Arc<V2State>,
armed: bool,
}
impl PendingSessionGuard {
fn new(v2: Arc<V2State>) -> Self {
Self { v2, armed: true }
}
/// Disarms the guard after the pending slot was replaced by an active session.
fn disarm(&mut self) {
self.armed = false;
}
/// Frees the pending slot immediately.
async fn clear(&mut self) {
self.armed = false;
clear_pending_session(&self.v2).await;
}
}
impl Drop for PendingSessionGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let v2 = self.v2.clone();
tokio::spawn(async move {
clear_pending_session(&v2).await;
});
}
}
async fn clear_pending_session(v2: &V2State) {
let mut slot = v2.session.lock().await;
if matches!(*slot, Some(SessionStateV2::Pending)) {
*slot = None;
}
}
/// Sets the final status of a file after an upload attempt.
///
/// The cleanup also runs on drop (as a failure) so the file is not stuck
/// in progress when the request future is cancelled mid-transfer.
struct UploadGuard {
v2: Arc<V2State>,
session_id: String,
file_id: String,
armed: bool,
}
impl UploadGuard {
fn new(v2: Arc<V2State>, session_id: String, file_id: String) -> Self {
Self {
v2,
session_id,
file_id,
armed: true,
}
}
async fn finish(&mut self, success: bool) {
self.armed = false;
finalize_file(&self.v2, &self.session_id, &self.file_id, success).await;
}
}
impl Drop for UploadGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let v2 = self.v2.clone();
let session_id = std::mem::take(&mut self.session_id);
let file_id = std::mem::take(&mut self.file_id);
tokio::spawn(async move {
finalize_file(&v2, &session_id, &file_id, false).await;
});
}
}
/// Sets the final status of a file and ends the session once all files are done.
async fn finalize_file(v2: &V2State, session_id: &str, file_id: &str, success: bool) {
let session_ended = {
let mut slot = v2.session.lock().await;
let Some(SessionStateV2::Active(session)) = slot.as_mut() else {
return;
};
if session.session_id != session_id {
return;
}
if let Some(file) = session.files.get_mut(file_id) {
if file.status == FileStatusV2::InProgress {
file.status = match success {
true => FileStatusV2::Finished,
false => FileStatusV2::Failed,
};
}
}
match session.is_complete() {
true => {
*slot = None;
true
}
false => false,
}
};
if session_ended {
tracing::info!("Upload session finished: {session_id}");
let _ = v2
.event_tx
.send(ServerEventV2::SessionEnd {
session_id: session_id.to_string(),
reason: SessionEndReasonV2::Finished,
})
.await;
}
}
+73
View File
@@ -0,0 +1,73 @@
use crate::http::dto::{NonceRequest, NonceResponse, RegisterDto, RegisterResponseDto};
use crate::http::server::common::collect_to_json::CollectToJson;
use crate::http::server::common::error::AppError;
use crate::http::server::common::response::JsonResponse;
use crate::http::server::{AppState, RequestClientInfo};
use crate::{crypto, util};
use hyper::body::Incoming;
use hyper::StatusCode;
pub(crate) async fn nonce_exchange(
body: Incoming,
state: AppState,
client_info: RequestClientInfo,
) -> Result<JsonResponse<NonceResponse>, AppError> {
let payload = body.collect_to_json::<NonceRequest>().await?;
let nonce = util::base64::decode(&payload.nonce).map_err(|_| {
tracing::warn!("Failed to decode nonce from base64");
AppError::BadRequest("Invalid nonce format".to_string())
})?;
if !crypto::nonce::validate_nonce(&nonce) {
tracing::warn!("Invalid nonce received");
return Err(AppError::BadRequest("Invalid nonce".to_string()));
}
// Save the nonce
let remote_key = client_info.identifier();
let mut received_nonce_map = state.received_nonce_map.lock().await;
received_nonce_map.put(remote_key.clone(), nonce);
// Generate new nonce for the client
let new_nonce = crypto::nonce::generate_nonce();
let new_nonce_base64 = util::base64::encode(&new_nonce);
let mut generated_nonce_map = state.generated_nonce_map.lock().await;
generated_nonce_map.put(remote_key.clone(), new_nonce);
tracing::info!(
"Nonce exchange successful for client: {} (ID: {})",
client_info.ip,
remote_key
);
Ok(JsonResponse {
status: StatusCode::OK,
body: NonceResponse {
nonce: new_nonce_base64,
},
})
}
pub(crate) async fn register(
body: Incoming,
state: AppState,
client_info: RequestClientInfo,
) -> Result<JsonResponse<RegisterResponseDto>, AppError> {
let payload = body.collect_to_json::<RegisterDto>().await?;
let info = state.info.lock().await.clone();
let has_web_interface = state.web.is_some();
Ok(JsonResponse {
status: StatusCode::OK,
body: RegisterResponseDto {
alias: info.alias,
version: info.version,
device_model: info.device_model,
device_type: info.device_type,
token: info.token,
has_web_interface,
},
})
}
+477
View File
@@ -0,0 +1,477 @@
use crate::http::dto_v2::{InfoResponseDtoV2, PrepareDownloadResponseDtoV2, PROTOCOL_VERSION_V2};
use crate::http::server::common::error::AppError;
use crate::http::server::common::pin::check_pin;
use crate::http::server::common::query::parse_query;
use crate::http::server::common::response::{full_body, BoxedBody, JsonResponse};
use crate::http::server::{AppState, RequestClientInfo};
use crate::model::transfer::{FileContent, FileDto};
use bytes::Bytes;
use http_body_util::{BodyExt, StreamBody};
use hyper::body::{Frame, Incoming};
use hyper::{http, Request, Response, StatusCode};
use lru::LruCache;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use serde::Serialize;
use std::collections::HashMap;
use std::net::IpAddr;
use std::num::NonZeroUsize;
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio_stream::wrappers::ReceiverStream;
use tokio_stream::StreamExt;
/// Events emitted by the web send (download API) endpoints that must be handled
/// by the application. Web send can be enabled independently of the v2 endpoints.
#[derive(Debug)]
pub enum WebSendEvent {
/// A web client requests to download the shared files
/// via `POST /api/localsend/v2/prepare-download`.
///
/// The application must answer on `decision_tx`.
/// Dropping `decision_tx` results in a 500 response.
PrepareDownload {
/// The IP address of the web client.
ip: IpAddr,
/// The ID of the download session that is created when accepted.
session_id: String,
/// The `User-Agent` header of the web client.
user_agent: Option<String>,
/// Channel to send the decision (`true` to accept, `false` to decline).
decision_tx: oneshot::Sender<bool>,
},
/// An accepted web client downloads a file via `GET /api/localsend/v2/download`.
///
/// The application must respond on `content_tx` with the file content. The
/// response body advertises `file.size` bytes, so the application should
/// provide exactly that many bytes before closing the stream (closing it
/// earlier aborts the download).
/// Dropping `content_tx` results in a 500 response.
FileDownload {
/// The ID of the download session.
session_id: String,
/// The ID of the file being downloaded.
file_id: String,
/// The metadata of the file being downloaded.
file: FileDto,
/// Channel to provide the content of the file being downloaded.
content_tx: oneshot::Sender<FileContent>,
},
}
const INDEX_HTML: &str = include_str!("../../../assets/web/index.html");
const MAIN_JS: &str = include_str!("../../../assets/web/main.js");
const ERROR_403_HTML: &str = include_str!("../../../assets/web/error-403.html");
/// Characters that are percent-encoded in the content-disposition file name.
/// Matches the component encoding of RFC 2396 (letters, digits and marks are kept).
const FILE_NAME_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'!')
.remove(b'~')
.remove(b'*')
.remove(b'\'')
.remove(b'(')
.remove(b')');
/// Configuration for web send (download API): files offered for download by web browsers.
///
/// Web send can be enabled independently of the v2/v3 protocol endpoints.
pub struct WebSendConfig {
/// The metadata of the files offered for download, mapped by file ID.
///
/// The content is requested from the application per download
/// via [`WebSendEvent::FileDownload`].
pub files: HashMap<String, FileDto>,
/// Optional PIN that web clients must provide via the `pin` query parameter.
pub pin: Option<String>,
/// Translations for the web page, served via `/i18n.json`.
pub i18n: WebSendI18n,
/// Channel on which the server emits events that must be handled by the application.
pub event_tx: mpsc::Sender<WebSendEvent>,
}
/// Translations for the web page, served via `/i18n.json`.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebSendI18n {
pub waiting: String,
pub enter_pin: String,
pub invalid_pin: String,
pub too_many_attempts: String,
pub rejected: String,
pub files: String,
pub file_name: String,
pub size: String,
}
impl Default for WebSendI18n {
fn default() -> Self {
Self {
waiting: "Waiting for response…".to_string(),
enter_pin: "Enter PIN".to_string(),
invalid_pin: "Invalid PIN".to_string(),
too_many_attempts: "Too many attempts".to_string(),
rejected: "Rejected".to_string(),
files: "Files".to_string(),
file_name: "File name".to_string(),
size: "Size".to_string(),
}
}
}
/// Runtime state of the web send (download API) endpoints.
pub(crate) struct WebPageState {
/// The metadata of the files offered for download, mapped by file ID.
pub(crate) files: HashMap<String, FileDto>,
/// Optional PIN required for prepare-download requests.
pub(crate) pin: Option<String>,
/// Translations served via `/i18n.json`.
pub(crate) i18n: WebSendI18n,
/// Channel on which server events are emitted to the application.
pub(crate) event_tx: mpsc::Sender<WebSendEvent>,
/// Download sessions, keyed by session ID (the client's IP address).
pub(crate) sessions: Mutex<HashMap<String, WebSendSession>>,
/// Maps client IPs to the number of failed PIN attempts.
pub(crate) pin_attempts: Mutex<LruCache<IpAddr, u32>>,
}
impl WebPageState {
pub(crate) fn new(config: WebSendConfig) -> Self {
Self {
files: config.files,
pin: config.pin,
i18n: config.i18n,
event_tx: config.event_tx,
sessions: Mutex::new(HashMap::new()),
pin_attempts: Mutex::new(LruCache::new(NonZeroUsize::new(200).unwrap())),
}
}
}
/// A download session of a single web client.
pub(crate) struct WebSendSession {
/// The IP address of the web client. Downloads are only allowed from this address.
ip: IpAddr,
/// `false` while the prepare-download request is waiting for the application's decision.
accepted: bool,
}
pub(crate) fn index(state: &AppState) -> Response<BoxedBody> {
match &state.web {
Some(_) => html_response(StatusCode::OK, INDEX_HTML, "text/html; charset=utf-8"),
None => error_403_page(),
}
}
pub(crate) fn main_js(state: &AppState) -> Response<BoxedBody> {
match &state.web {
Some(_) => html_response(StatusCode::OK, MAIN_JS, "text/javascript; charset=utf-8"),
None => error_403_page(),
}
}
pub(crate) fn i18n(state: &AppState) -> Result<Response<BoxedBody>, AppError> {
let web = require_web(state)?;
Ok(JsonResponse {
status: StatusCode::OK,
body: &web.i18n,
}
.into_response())
}
pub(crate) async fn prepare_download(
req: Request<Incoming>,
state: AppState,
client_info: RequestClientInfo,
) -> Result<Response<BoxedBody>, AppError> {
let web = require_web(&state)?;
let query = parse_query(req.uri().query());
// An accepted client can re-fetch the file list (e.g. page reload).
if let Some(session_id) = query.get("sessionId") {
let sessions = web.sessions.lock().await;
let valid = sessions
.get(session_id)
.is_some_and(|session| session.accepted && session.ip == client_info.ip);
if valid {
drop(sessions);
return Ok(file_list_response(&state, &web, session_id.clone()).await);
}
}
check_pin(
web.pin.as_deref(),
&web.pin_attempts,
&query,
client_info.ip,
)
.await?;
let user_agent = req
.headers()
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
// One session per IP; a new request replaces any previous session of this client.
let session_id = client_info.ip.to_string();
{
let mut sessions = web.sessions.lock().await;
sessions.insert(
session_id.clone(),
WebSendSession {
ip: client_info.ip,
accepted: false,
},
);
}
// Removes the pending session again if this request is declined or aborted
// before the application accepted it.
let mut pending_guard = PendingWebSessionGuard::new(web.clone(), session_id.clone());
let (decision_tx, decision_rx) = oneshot::channel();
let event = WebSendEvent::PrepareDownload {
ip: client_info.ip,
session_id: session_id.clone(),
user_agent,
decision_tx,
};
if web.event_tx.send(event).await.is_err() {
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
let accepted = decision_rx
.await
.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?;
if !accepted {
pending_guard.clear().await;
return Err(AppError::Message(
StatusCode::FORBIDDEN,
"File transfer rejected.".to_string(),
));
}
{
let mut sessions = web.sessions.lock().await;
if let Some(session) = sessions.get_mut(&session_id) {
session.accepted = true;
}
}
pending_guard.disarm();
tracing::info!("Download session created: {session_id}");
Ok(file_list_response(&state, &web, session_id).await)
}
pub(crate) async fn download(
req: Request<Incoming>,
state: AppState,
client_info: RequestClientInfo,
) -> Result<Response<BoxedBody>, AppError> {
let web = require_web(&state)?;
let query = parse_query(req.uri().query());
let Some(session_id) = query.get("sessionId") else {
return Err(AppError::BadRequest("Missing sessionId.".to_string()));
};
{
let sessions = web.sessions.lock().await;
let valid = sessions
.get(session_id)
.is_some_and(|session| session.accepted && session.ip == client_info.ip);
if !valid {
return Err(AppError::Message(
StatusCode::FORBIDDEN,
"Invalid sessionId.".to_string(),
));
}
}
let Some(file_id) = query.get("fileId") else {
return Err(AppError::BadRequest("Missing fileId.".to_string()));
};
let Some(file) = web.files.get(file_id) else {
return Err(AppError::Message(
StatusCode::FORBIDDEN,
"Invalid fileId.".to_string(),
));
};
// The application provides the file content as a stream of bytes.
let (content_tx, content_rx) = oneshot::channel::<FileContent>();
let event = WebSendEvent::FileDownload {
session_id: session_id.clone(),
file_id: file_id.clone(),
file: file.clone(),
content_tx,
};
if web.event_tx.send(event).await.is_err() {
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
let content = content_rx
.await
.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?;
let size = file.size;
let body = receiver_stream_body(content.into_receiver());
// The file name may be inside directories.
let file_name = file.file_name.replace('/', "-");
let encoded_file_name = utf8_percent_encode(&file_name, FILE_NAME_ENCODE_SET);
let mut response = Response::new(body);
let headers = response.headers_mut();
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/octet-stream"),
);
headers.insert(
http::header::CONTENT_DISPOSITION,
http::HeaderValue::from_str(&format!("attachment; filename=\"{encoded_file_name}\""))
.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?,
);
headers.insert(http::header::CONTENT_LENGTH, http::HeaderValue::from(size));
Ok(response)
}
fn require_web(state: &AppState) -> Result<Arc<WebPageState>, AppError> {
state.web.clone().ok_or(AppError::Message(
StatusCode::FORBIDDEN,
"Web send not initialized.".to_string(),
))
}
fn html_response(
status: StatusCode,
content: &'static str,
content_type: &'static str,
) -> Response<BoxedBody> {
let mut response = Response::new(full_body(content));
*response.status_mut() = status;
response.headers_mut().insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static(content_type),
);
response
}
fn error_403_page() -> Response<BoxedBody> {
html_response(
StatusCode::FORBIDDEN,
ERROR_403_HTML,
"text/html; charset=utf-8",
)
}
async fn file_list_response(
state: &AppState,
web: &WebPageState,
session_id: String,
) -> Response<BoxedBody> {
let info = state.info.lock().await.clone();
JsonResponse {
status: StatusCode::OK,
body: PrepareDownloadResponseDtoV2 {
info: InfoResponseDtoV2 {
alias: info.alias,
version: PROTOCOL_VERSION_V2.to_string(),
device_model: info.device_model,
device_type: info.device_type,
fingerprint: info.token,
download: true,
},
session_id,
files: web.files.clone(),
},
}
.into_response()
}
/// Streams application-provided chunks as a response body.
fn receiver_stream_body(binary_rx: mpsc::Receiver<Bytes>) -> BoxedBody {
let stream = ReceiverStream::new(binary_rx)
.map(|chunk| Ok::<_, std::io::Error>(Frame::data(Bytes::from(chunk))));
StreamBody::new(stream).boxed()
}
/// Removes a pending download session unless it was accepted.
///
/// The cleanup also runs on drop so the session is not leaked
/// when the request future is cancelled (e.g. the web client disconnected
/// while the application was still deciding).
struct PendingWebSessionGuard {
web: Arc<WebPageState>,
session_id: String,
armed: bool,
}
impl PendingWebSessionGuard {
fn new(web: Arc<WebPageState>, session_id: String) -> Self {
Self {
web,
session_id,
armed: true,
}
}
/// Disarms the guard after the session was accepted.
fn disarm(&mut self) {
self.armed = false;
}
/// Removes the pending session immediately.
async fn clear(&mut self) {
self.armed = false;
clear_pending_session(&self.web, &self.session_id).await;
}
}
impl Drop for PendingWebSessionGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let web = self.web.clone();
let session_id = std::mem::take(&mut self.session_id);
tokio::spawn(async move {
clear_pending_session(&web, &session_id).await;
});
}
}
async fn clear_pending_session(web: &WebPageState, session_id: &str) {
let mut sessions = web.sessions.lock().await;
if sessions
.get(session_id)
.is_some_and(|session| !session.accepted)
{
sessions.remove(session_id);
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::model::discovery::DeviceType;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ClientInfo {
/// The name of the peer.
pub alias: String,
/// Client Protocol Version (major.minor)
pub version: String,
/// The device model of the peer.
/// Windows, macOS, iPhone, Samsung, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
/// The device type of the peer.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
/// A token generated by the client.
/// Used to merge the same peers detected on different channels (LAN, WebRTC, etc.).
pub token: String,
}
+11
View File
@@ -0,0 +1,11 @@
#[cfg(feature = "crypto")]
pub mod crypto;
#[cfg(feature = "http")]
pub mod http;
pub mod model;
pub(crate) mod util;
pub mod webrtc;
#[cfg(feature = "http")]
pub use reqwest;
pub use serde_json;
+578
View File
@@ -0,0 +1,578 @@
mod crypto;
mod http;
mod model;
mod util;
mod webrtc;
use crate::crypto::token;
use crate::http::client::LsHttpClientV3;
use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto};
use crate::http::server::common::save::FileUploadTarget;
use crate::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2};
use crate::http::server::{ServerConfigV2, TlsConfig};
use crate::model::discovery::DeviceType;
use crate::webrtc::signaling::{ClientInfo, WsServerMessage};
use crate::webrtc::webrtc::{PinConfig, RTCFile, RTCFileError, RTCSendFileResponse, RTCStatus};
use anyhow::Result;
use bytes::Bytes;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::fs::File;
use tokio::io;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, oneshot};
use tracing::Level;
#[tokio::main]
#[cfg(feature = "full")]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
.init();
webrtc_test().await?;
let a = tokio::spawn(async move {
let _ = server_test().await;
});
let b = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = client_test().await;
});
tokio::select! {
_ = a => {},
_ = b => {},
}
Ok(())
}
const PRIVATE_KEY: &str = "-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAqeusikjBGJ/mqG+RYPyNaP2M6/YafR5bVcEr0NirDntRaSI8
SBVy6ezqGnpJJpez2rVcLfqPOZNW+yhiWmX/DFGAbKWNUjpAfEgQ0ySS3EKEfTGa
kpbBgVmSgnJKu0cuFHk3LRQXZc9USWRtfZu/HLwrxeTy0ynKBjqctkcJmyEOleSE
tWwx/sFUszI4j3QH7iAg+jJu07qCaBv1iOVoFLwtvtkHP4pIflPi4FR0nUn8VpTR
8j3h1Z/Ea6j2nW/CfatfhOiwrlOgjpd1CFtU5OoUk0OiHYgUTLRvOR0ebmKLJZp9
2x85h3ucuwzcNHXds6IrBsV7dcMeN9+nI2yYHwIDAQABAoIBAAYVzZEKN/gUyeLg
U/mAMeQ/qEtO/fXbH3Q7vcD18XJMUkcMldITCpF8DYozNOlv513+vrVa0sRCFYxb
DuKj4nVjedDqQNxf/60zu36EQconi60cGKgFRBrIxWlshGaejvTmvmYb4RahTShv
s0gbSsXRq1Oj9lo/ld+RO8l/U8W9Y2KlHc14VbAHCxlBd70Ngpw/hKzt7jVwUt2O
QMAgek5Ffbjoqk/GvwdYFtgLHLYKWNdaqt/dGCZcDWPNOv93Lb+XuI3orAcSr5T7
V2fseLMrrQfKr4dK+DSxvB/McAahSY+6sxpm972D1MYBoDM/yCK/jcdV4T83ofIl
tnavOyECgYEA077OpgXt798miVtybRgPv2cEZHnMZStHhNOO/3A2J0mnT5lCGT8M
FnB45LE7NxP5yXLi1/cBSazwF/W2TKu4g42y9X0lPzik6uM4F7AZLCwO6zd4etwa
NwKjCiPBGJkWBODQ2IeK6Gy9gnTAnGhzuPoTYEl2AH5REbM8btsRve8CgYEAzW8U
8sztt1cLWKwAsf66KZOjU03UoAhgofhXeyCDW4tLZ/l7F2cEfRaRkIx40TU1tNSy
R130DsiRmJ/7vSy00qaC7IZgFRnDXM8oWSvE31p3AiZEvMzh3cMl67u/TPFn/Zhr
iDE2fxTmNf9a860IbYeGqbOj11fFsSMNZIYf+NECgYACR/Ht8+5mQR8nJ6cJ6dJx
m2h+tJkxFdBFbAoEUm8i6TY2M050+yrkKv4CaK5cn4h3VReAgBaxdn13pJv8I3Vv
ZV1iK6D1F2Ufaqc2Ch2bTjYy7nwLxsc5hHvBJjV0UGHeV5WoX31tl45LE3rntHBa
s8b1qJTu2G2DJU0nXJDKXQKBgEhxvrpsp/u6d2baqRgb0vxscvEihjO1IJadpAPo
kEoNEhdldBHpozyVY9nMn6JvGDRfuUrPiAxakHV5HWY1yMJsM8lDDckDH9CvwPPJ
KpD1LviUFDNcMN5qPgomWCzDCL/2Kx2I9UXVUeWC2kkKIOm3HDbmAYYkDrQLv2JO
piGxAoGAfbhVHgMhroI64t64NaVpXiHy2bd36q7hLVm2+bTDpPphHwn4kIsFvVr3
uGPVsyoOa68s1eXnOnh5TzhTltsjyAYfiKo/7ZX6mHMctFlgt4njailDWOqHwj0c
Uy/QlsvXsOcN/Y99HULigND8C49F5Sz9Ih9G1DGLvd0BUUI/+qg=
-----END RSA PRIVATE KEY-----";
const CERT: &str = "-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh
bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG
A1UECBMAMQkwBwYDVQQGEwAwHhcNMjUwMjEwMDE1ODM3WhcNMzUwMjA4MDE1ODM3
WjBQMRcwFQYDVQQDEw5Mb2NhbFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQL
EwAxCTAHBgNVBAcTADEJMAcGA1UECBMAMQkwBwYDVQQGEwAwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCp66yKSMEYn+aob5Fg/I1o/Yzr9hp9HltVwSvQ
2KsOe1FpIjxIFXLp7Ooaekkml7PatVwt+o85k1b7KGJaZf8MUYBspY1SOkB8SBDT
JJLcQoR9MZqSlsGBWZKCckq7Ry4UeTctFBdlz1RJZG19m78cvCvF5PLTKcoGOpy2
RwmbIQ6V5IS1bDH+wVSzMjiPdAfuICD6Mm7TuoJoG/WI5WgUvC2+2Qc/ikh+U+Lg
VHSdSfxWlNHyPeHVn8RrqPadb8J9q1+E6LCuU6COl3UIW1Tk6hSTQ6IdiBRMtG85
HR5uYoslmn3bHzmHe5y7DNw0dd2zoisGxXt1wx4336cjbJgfAgMBAAEwDQYJKoZI
hvcNAQELBQADggEBAJ/bopM5NjK/Roi1bS+qAQ7EHeVNfLyPgAReyJESHsg3mBEE
FhP729KlHcNCvaAnmxEaUH2XZTmP3s0m9IVabHhdFEyIibMQ/Qpid/JIDsG2IRw7
oNJj8z0C7eDjC9eWR+wZ2d0nnyNpWghcqAqqZSBuNpJ9jDqmg4LzdNXZUvh9e1Cq
qizxa3CQEHRYqdL/hA1N6eq7GkeiIeP+cbvWGmcSf8SS/ORMKvvDGzkGs2mFZnY/
DZmiOOqkzvgZOgOVQ2vFuJIXyZ/tY0ez35dtQYLhKRljlXjckA/PFuTJDa2kq1Rv
qqsPsY3pRq93zkKNx1xRtURBiJEvA/Js2+hHWrU=
-----END CERTIFICATE-----";
async fn crypto_test() -> Result<()> {
let key = token::generate_key();
let pem = token::export_private_key(&key)?;
println!("Pem: {}", pem.as_str());
let public_key = token::export_public_key(&key)?;
println!("Public Key: {}", public_key);
let fingerprint = token::generate_token_timestamp(&key)?;
println!("Fingerprint: {}", fingerprint);
let parsed_key = token::parse_public_key(
"-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAZmdXP230oqK92o65ra3XaF2F8r3+fK5DEBK4c40qVts=
-----END PUBLIC KEY-----",
"ed25519",
)?;
let signature = token::verify_token_with_result(
&*parsed_key,
"sha256.RikOdJlAUTdMVFZjEk7Bft5G9cxnNBBLfgttPpyS2FY.hJCuZwAAAAA.ed25519.iNgHrRzX2Iel-Ozj47yn5o5v0cGY_BswK6JYqwY65j7Krpr43KanAaCrjUng7gHtc2pCcylUrKswR_rxyswhDA",
|_| Ok(()),
);
println!("Signature Verification: {:?}", signature);
Ok(())
}
async fn server_test() -> Result<()> {
let client_info = http::state::ClientInfo {
alias: "Server-Test".to_string(),
version: "1.2.3".to_string(),
device_model: None,
device_type: None,
token: "456".to_string(),
};
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16);
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
ServerEventV2::Register { ip, info } => {
tracing::info!("Device registered from {ip}: {}", info.alias);
}
ServerEventV2::PrepareUpload {
info,
files,
decision_tx,
..
} => {
tracing::info!(
"Prepare upload from {}: {} file(s)",
info.alias,
files.len()
);
// Accept all files.
let _ = decision_tx.send(PrepareUploadDecisionV2::Accept(
files.keys().cloned().collect(),
));
}
ServerEventV2::FileUpload {
file, target_tx, ..
} => {
let (binary_tx, mut binary_rx) = mpsc::channel::<Bytes>(16);
let (result_tx, result_rx) = oneshot::channel::<Result<(), String>>();
let _ = target_tx.send(FileUploadTarget::Stream {
binary_tx,
result_rx,
});
tokio::spawn(async move {
let mut received: u64 = 0;
while let Some(chunk) = binary_rx.recv().await {
received += chunk.len() as u64;
}
tracing::info!(
"Received {}/{} bytes of {}",
received,
file.size,
file.file_name
);
let _ = result_tx.send(Ok(()));
});
}
ServerEventV2::SessionEnd { session_id, reason } => {
tracing::info!("Session {session_id} ended: {reason:?}");
}
}
}
});
http::server::start_with_port(
53317,
Some(TlsConfig {
cert: CERT.to_string(),
private_key: PRIVATE_KEY.to_string(),
}),
client_info,
Some(ServerConfigV2 {
pin: None,
event_tx,
}),
None,
stop_rx,
)
.await?;
tokio::time::sleep(std::time::Duration::from_secs(u64::MAX)).await;
let _ = stop_tx.send(());
Ok(())
}
async fn client_test() -> Result<()> {
let client = LsHttpClientV3::try_new(PRIVATE_KEY, CERT)?;
let nonce = client
.nonce(ProtocolType::Https, "localhost", 53317)
.await?;
println!("Received Nonce: {}", nonce);
let register_dto = RegisterDto {
alias: "test 2".to_string(),
version: "2.3".to_string(),
device_model: Some("test".to_string()),
device_type: Some(DeviceType::Headless),
token: "test".to_string(),
port: 53317,
protocol: ProtocolType::Https,
has_web_interface: false,
};
let response = client
.register(
ProtocolType::Https,
"localhost",
53317,
register_dto.clone(),
)
.await?;
println!("Public Key: {:?}", response.public_key);
println!("Body: {:?}", response.body);
let prepare_upload_dto = PrepareUploadRequestDto {
info: register_dto,
files: {
let mut map = HashMap::new();
let id = "test-123-id".to_string();
let file = model::transfer::FileDto {
id: id.clone(),
file_name: "test.mp4".to_string(),
size: 1000,
file_type: "video/mp4".to_string(),
sha256: None,
preview: None,
metadata: None,
};
map.insert(id, file);
map
},
};
let prepare_upload_response = client
.prepare_upload(
ProtocolType::Https,
"localhost",
53317,
None,
prepare_upload_dto,
)
.await?;
println!(
"Prepare Upload Response: {:?}",
prepare_upload_response.response
);
Ok(())
}
async fn webrtc_test() -> Result<()> {
let info = webrtc::signaling::ClientInfoWithoutId {
alias: "test".to_string(),
version: "2.3".to_string(),
device_model: Some("test".to_string()),
device_type: Some(DeviceType::Desktop),
token: "test".to_string(),
};
let connection =
webrtc::signaling::SignalingConnection::connect("wss://public.localsend.org/v1/ws", &info)
.await?;
let (managed_connection, mut rx) = connection.start_listener();
let managed_connection = Arc::new(managed_connection);
while let Some(message) = rx.recv().await {
let stun_servers = vec!["stun:stun.l.google.com:19302".to_string()];
match message {
WsServerMessage::Join { peer } => {
send_handler(managed_connection.clone(), stun_servers, peer).await;
return Ok(());
}
WsServerMessage::Offer(offer) => {
receive_handler(managed_connection.clone(), stun_servers, offer).await;
}
_ => {}
}
}
Ok(())
}
async fn send_handler(
connection: Arc<webrtc::signaling::ManagedSignalingConnection>,
stun_servers: Vec<String>,
peer: ClientInfo,
) {
tracing::info!("Joined: {peer:?}");
let (status_tx, mut status_rx) = mpsc::channel::<RTCStatus>(1);
let (selected_tx, mut selected_rx) = oneshot::channel::<HashSet<String>>();
let (error_tx, mut error_rx) = mpsc::channel::<RTCFileError>(1);
let (pin_tx, mut pin_rx) = mpsc::channel::<oneshot::Sender<String>>(1);
let (pair_tx, mut pair_rx) = oneshot::channel::<oneshot::Sender<bool>>();
let (send_tx, send_rx) = mpsc::channel::<RTCFile>(1);
let files = vec![model::transfer::FileDto {
id: "test-123-id".to_string(),
file_name: "test.mp4".to_string(),
size: 100,
file_type: "video/mp4".to_string(),
sha256: None,
preview: None,
metadata: None,
}];
let send_task = tokio::spawn({
let files = files.clone();
async move {
webrtc::webrtc::send_offer(
&connection,
stun_servers,
peer.id,
token::generate_key(),
None,
Some(PinConfig {
pin: "456".to_string(),
max_tries: 3,
}),
files,
status_tx,
selected_tx,
error_tx,
pin_tx,
pair_tx,
send_rx,
)
.await
.expect("Failed to send offer");
tracing::info!("Send offer completed");
}
});
tokio::spawn(async move {
while let Some(status) = status_rx.recv().await {
tracing::info!("Status: {status:?}");
}
tracing::info!("Closed channel: status");
});
tokio::spawn(async move {
while let Some(error) = error_rx.recv().await {
tracing::info!("Error: {error:?}");
}
tracing::info!("Closed channel: error");
});
tokio::spawn(async move {
let mut pin_tries = vec!["1".to_string(), "2".to_string(), "123".to_string()].into_iter();
while let Some(send_pin) = pin_rx.recv().await {
let pin = pin_tries.next().expect("Failed to get pin");
tracing::info!("Sending pin: {pin}");
send_pin.send(pin).expect("Failed to send pin");
}
tracing::info!("Closed channel: status");
});
tokio::spawn(async move {
let Ok(send_pair) = pair_rx.await else {
return;
};
tracing::info!("Declining Pair");
send_pair.send(false).expect("Failed to send pair");
tracing::info!("Closed channel: status");
});
tokio::spawn(async move {
let Ok(selected) = selected_rx.await else {
return;
};
tracing::info!("Selected: {selected:?}");
let file = files.first().unwrap();
let (tx, mut rx) = mpsc::channel::<Bytes>(16);
send_tx
.try_send(RTCFile {
file_id: file.id.clone(),
binary_rx: rx,
})
.expect("Failed to send file");
let file_path = "/Users/user/Downloads/test/send/test.mp4";
let start_time = std::time::Instant::now();
read_file_to_sender(file_path, tx)
.await
.expect("Failed to read file");
let file_size = std::fs::metadata(file_path).unwrap().len();
tracing::info!(
"Sending file completed in {:?}, speed: {} MB/s",
start_time.elapsed(),
file_size as f64 / 1024.0 / 1024.0 / start_time.elapsed().as_secs_f64()
);
});
let result = send_task.await;
tracing::info!("Send task finished with result: {:?}", result);
}
async fn receive_handler(
connection: Arc<webrtc::signaling::ManagedSignalingConnection>,
stun_servers: Vec<String>,
offer: webrtc::signaling::WsServerSdpMessage,
) {
tracing::info!("Offer: {offer:?}");
let (status_tx, mut status_rx) = mpsc::channel::<RTCStatus>(1);
let (files_tx, files_rx) = oneshot::channel::<Vec<model::transfer::FileDto>>();
let (selected_tx, selected_rx) = oneshot::channel::<Option<HashSet<String>>>();
let (error_tx, mut error_rx) = mpsc::channel::<RTCFileError>(1);
let (pin_tx, mut pin_rx) = mpsc::channel::<oneshot::Sender<String>>(1);
let (receiving_tx, mut receiving_rx) = mpsc::channel::<RTCFile>(1);
let (user_error_tx, user_error_rx) = mpsc::channel::<RTCSendFileResponse>(1);
let receive_task = tokio::spawn(async move {
webrtc::webrtc::accept_offer(
&connection,
stun_servers,
&offer,
token::generate_key(),
None,
Some(PinConfig {
pin: "123".to_string(),
max_tries: 3,
}),
status_tx,
files_tx,
selected_rx,
error_tx,
pin_tx,
receiving_tx,
user_error_rx,
)
.await
.expect("Failed to accept offer");
tracing::info!("Accept offer completed");
});
tokio::spawn(async move {
while let Some(status) = status_rx.recv().await {
tracing::info!("Status: {status:?}");
}
tracing::info!("Closed channel: status");
});
tokio::spawn(async move {
while let Some(error) = error_rx.recv().await {
tracing::info!("Error: {error:?}");
}
tracing::info!("Closed channel: error");
});
tokio::spawn(async move {
let mut pin_tries = vec!["1".to_string(), "2".to_string(), "456".to_string()].into_iter();
while let Some(send_pin) = pin_rx.recv().await {
let pin = pin_tries.next().expect("Failed to get pin");
tracing::info!("Sending pin: {pin}");
send_pin.send(pin).expect("Failed to send pin");
}
tracing::info!("Closed channel: status");
});
tokio::spawn(async move {
let Ok(files) = files_rx.await else {
return;
};
tracing::info!("Files: {files:?}");
selected_tx
.send(Some(files.iter().map(|file| file.id.clone()).collect()))
.expect("Failed to send selected");
while let Some(file) = receiving_rx.recv().await {
tracing::info!("Receiving file: {file:?}");
let file_dto = files.iter().find(|f| f.id == file.file_id).unwrap();
let file_path = format!("/Users/user/Downloads/test/{}", file_dto.file_name);
write_file_from_receiver(file_path.as_ref(), file.binary_rx)
.await
.expect("Failed to write file");
user_error_tx
.send(RTCSendFileResponse {
id: file.file_id,
success: true,
error: None,
})
.await
.expect("Failed to send response");
}
tracing::info!("Receiving files completed");
});
let result = receive_task.await;
tracing::info!("Receive task finished with result: {:?}", result);
}
async fn read_file_to_sender(file_path: &str, sender: mpsc::Sender<Bytes>) -> io::Result<()> {
let mut file = File::open(file_path).await?;
let mut buffer = [0u8; 1024];
loop {
// Read a chunk of the file
let bytes_read = file.read(&mut buffer).await?;
if bytes_read == 0 {
break; // EOF
}
// Send the chunk through the channel
let chunk = Bytes::copy_from_slice(&buffer[..bytes_read]);
if sender.send(chunk).await.is_err() {
tracing::error!("Receiver dropped, stopping.");
break;
}
}
Ok(())
}
async fn write_file_from_receiver(
file_path: &str,
mut receiver: mpsc::Receiver<Bytes>,
) -> io::Result<()> {
let mut file = File::create(file_path).await?;
while let Some(chunk) = receiver.recv().await {
file.write_all(&chunk).await?;
}
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum DeviceType {
Mobile,
Desktop,
Web,
Headless,
Server,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod discovery;
pub mod transfer;
+109
View File
@@ -0,0 +1,109 @@
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::sync::mpsc;
/// Channel capacity used when normalizing a file-backed [`FileContent`] into a stream.
const FILE_CHANNEL_CAPACITY: usize = 16;
/// The binary content of a file provided by the application for a transfer.
///
/// Shared by the HTTP client (upload) and server (download API) so both can
/// obtain a file's content as an in-memory stream of chunks, from a regular
/// file path, or, on Android, directly from a raw file descriptor.
#[derive(Debug)]
pub enum FileContent {
/// A stream of binary chunks. The channel is closed once the file has been
/// fully provided.
Stream(mpsc::Receiver<Bytes>),
/// A path to a regular file the content is read from.
Path(PathBuf),
/// A raw file descriptor the content is read from (Android only).
#[cfg(target_os = "android")]
Fd(std::os::fd::RawFd),
}
impl FileContent {
/// Normalizes the content into a stream of binary chunks.
///
/// [`FileContent::Stream`] is returned as-is. For [`FileContent::Path`] and
/// [`FileContent::Fd`], a background task reads the file and forwards the
/// chunks; the channel is closed on EOF or on an I/O error.
pub fn into_receiver(self) -> mpsc::Receiver<Bytes> {
match self {
FileContent::Stream(rx) => rx,
FileContent::Path(path) => {
let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAPACITY);
tokio::spawn(async move {
match tokio::fs::File::open(&path).await {
Ok(file) => read_file_into_sender(file, tx).await,
Err(e) => {
tracing::error!("Failed to open {}: {e}", path.display());
}
}
});
rx
}
#[cfg(target_os = "android")]
FileContent::Fd(fd) => {
use std::os::fd::FromRawFd;
let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAPACITY);
// SAFETY: the descriptor is owned by this transfer; wrapping it in
// a File transfers that ownership so it is closed once reading finishes.
let std_file = unsafe { std::fs::File::from_raw_fd(fd) };
let file = tokio::fs::File::from_std(std_file);
tokio::spawn(read_file_into_sender(file, tx));
rx
}
}
}
}
/// Reads `file` to EOF, forwarding chunks on `tx`.
///
/// Stops early if the receiver is gone or a read error occurs.
async fn read_file_into_sender(mut file: tokio::fs::File, tx: mpsc::Sender<Bytes>) {
use tokio::io::AsyncReadExt;
let mut buffer = bytes::BytesMut::with_capacity(64 * 1024);
loop {
match file.read_buf(&mut buffer).await {
Ok(0) => break,
Ok(_) => {
if tx.send(buffer.split().freeze()).await.is_err() {
break;
}
}
Err(e) => {
tracing::error!("Failed to read file content: {e}");
break;
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileDto {
pub id: String,
pub file_name: String,
pub size: u64,
pub file_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<FileMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub accessed: Option<String>,
}
+13
View File
@@ -0,0 +1,13 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::engine::GeneralPurpose;
use base64::{DecodeError, Engine};
const BASE_64_ENGINE: GeneralPurpose = URL_SAFE_NO_PAD;
pub fn encode<T: AsRef<[u8]>>(data: T) -> String {
BASE_64_ENGINE.encode(data)
}
pub fn decode(data: &str) -> Result<Vec<u8>, DecodeError> {
BASE_64_ENGINE.decode(data)
}
+2
View File
@@ -0,0 +1,2 @@
pub mod base64;
pub(crate) mod time;
+7
View File
@@ -0,0 +1,7 @@
use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH};
pub(crate) fn unix_timestamp_u64() -> Result<u64, SystemTimeError> {
let seconds = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
Ok(seconds)
}
+3
View File
@@ -0,0 +1,3 @@
pub mod signaling;
#[cfg(feature = "webrtc")]
pub mod webrtc;
+528
View File
@@ -0,0 +1,528 @@
use crate::model::discovery::DeviceType;
use crate::util::base64;
use anyhow::Result;
use futures_util::stream::StreamExt;
use futures_util::SinkExt;
use serde::{Deserialize, Serialize};
use std::cmp::PartialEq;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio::time::Duration;
#[cfg(feature = "webrtc-signaling")]
use tokio_tungstenite::connect_async;
#[cfg(feature = "webrtc-signaling")]
use tungstenite::{Bytes, Message};
use uuid::Uuid;
/// A message sent by the server to the client.
#[derive(Clone, Deserialize, Eq, Serialize, Debug, PartialEq)]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum WsServerMessage {
/// The initial message sent to the client that has just connected.
Hello {
/// The client that has just connected.
client: ClientInfo,
/// The list of members (excluding the client) in the IP room.
peers: Vec<ClientInfo>,
},
/// A new peer has joined the IP room.
Join {
/// The peer that triggered the message.
peer: ClientInfo,
},
/// A peer has updated its information.
Update {
/// The peer that triggered the message.
peer: ClientInfo,
},
/// A peer has left the IP room.
Left {
/// The ID of the peer that triggered the message.
#[serde(rename = "peerId")]
peer_id: Uuid,
},
/// SDP offer from a peer to another peer.
Offer(WsServerSdpMessage),
/// SDP answer from a peer to another peer.
Answer(WsServerSdpMessage),
/// Error message.
Error {
/// The error code.
code: u16,
},
}
#[derive(Clone, Deserialize, Eq, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WsServerSdpMessage {
/// The peer that triggered the message.
pub peer: ClientInfo,
/// The session ID of the answer.
pub session_id: String,
/// The SDP string for the answer.
/// Compressed with zlib, then encoded with base64 without padding.
pub sdp: String,
}
#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ClientInfo {
/// The ID of the peer. Generated by the server.
pub id: Uuid,
/// The name of the peer.
pub alias: String,
/// Client Protocol Version (major.minor)
pub version: String,
/// The device model of the peer.
/// Windows, macOS, iPhone, Samsung, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
/// The device type of the peer.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
/// A token generated by the client.
/// Used to merge the same peers detected on different channels (LAN, WebRTC, etc.).
pub token: String,
}
impl ClientInfo {
pub fn from(info: ClientInfoWithoutId, id: Uuid) -> Self {
Self {
id,
alias: info.alias,
version: info.version,
device_model: info.device_model,
device_type: info.device_type,
token: info.token,
}
}
}
/// The data that is encoded as JSON which is again encoded as base64.
/// Sent as query during websocket connection.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientInfoWithoutId {
/// The name of the peer.
pub alias: String,
/// Client Protocol Version (major.minor)
pub version: String,
/// The device model of the peer.
/// Windows, macOS, iPhone, Samsung, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
/// The device type of the peer.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_type: Option<DeviceType>,
/// A fingerprint generated by the client.
/// Used to merge the same peers detected on different channels (LAN, WebRTC, etc.).
pub token: String,
}
impl From<ClientInfo> for ClientInfoWithoutId {
fn from(info: ClientInfo) -> Self {
Self {
alias: info.alias,
version: info.version,
device_model: info.device_model,
device_type: info.device_type,
token: info.token,
}
}
}
/// The HTTP request sent by the client to the server.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum WsClientMessage {
Update { info: ClientInfoWithoutId },
Offer(WsClientSdpMessage),
Answer(WsClientSdpMessage),
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WsClientSdpMessage {
/// The session id to correctly associate answers with offers.
/// Generated by the client (the peer that sends the offer).
pub session_id: String,
/// Target peer ID.
pub target: Uuid,
/// The SDP offer.
/// Compressed with zlib, then encoded with base64 without padding.
pub sdp: String,
}
pub struct SignalingConnection {
/// The peer info received from the server of the client.
pub client: ClientInfo,
/// The sender to send messages to the server.
pub tx: mpsc::Sender<WsClientMessage>,
/// The receiver to receive messages from the server.
pub rx: mpsc::Receiver<WsServerMessage>,
}
#[cfg(feature = "webrtc-signaling")]
impl SignalingConnection {
pub async fn connect<S: Into<String>>(
uri: S,
info: &ClientInfoWithoutId,
) -> Result<SignalingConnection> {
let encoded_info = base64::encode(&serde_json::to_string(info)?);
let uri = format!("{}?d={}", uri.into(), encoded_info);
tracing::debug!("Connecting to the signaling server at {uri}");
let (ws_stream, _) = connect_async(&uri).await?;
tracing::debug!("Connected to the signaling server. Waiting for hello...");
let (mut write, read) = ws_stream.split();
let (send_tx, mut send_rx) = mpsc::channel(1);
tokio::spawn(async move {
let timeout = Duration::from_secs(120);
loop {
let send_result = tokio::time::timeout(timeout, async {
if let Some(message) = send_rx.recv().await {
let message =
serde_json::to_string(&message).expect("Failed to serialize message");
if write.send(Message::Text(message.into())).await.is_ok() {
return true;
}
}
false
})
.await;
match send_result {
Ok(success) => {
if !success {
return;
}
}
Err(_) => {
// Timeout: send a ping message to keep the connection alive
if write.send(Message::Ping(Bytes::new())).await.is_err() {
return;
}
}
}
}
});
let (receive_tx, receive_rx) = mpsc::channel(1);
let (client_tx, mut client_rx) = mpsc::channel::<ClientInfo>(1);
tokio::spawn(async move {
read.for_each(|message| async {
if let Ok(Message::Text(message)) = message {
match serde_json::from_str::<WsServerMessage>(&message) {
Ok(message) => {
if let WsServerMessage::Hello {
client,
peers: _peers,
} = &message
{
if client_tx.send(client.clone()).await.is_err() {
return;
}
}
match receive_tx.send(message).await {
Ok(_) => {}
Err(e) => tracing::error!("{e:?}"),
}
}
Err(e) => tracing::error!("Error: {e}, Server: {message}"),
}
}
})
.await;
});
let client = client_rx.recv().await.unwrap();
tracing::debug!("Received hello from server: {client:?}");
Ok(SignalingConnection {
client,
tx: send_tx,
rx: receive_rx,
})
}
/// Listen for incoming messages from the server.
/// Upgrades the API to a higher-level API.
pub fn start_listener(
mut self,
) -> (ManagedSignalingConnection, mpsc::Receiver<WsServerMessage>) {
let (tx, rx) = mpsc::channel::<WsServerMessage>(16);
let on_answer: Arc<Mutex<HashMap<String, AnswerCallback>>> =
Arc::new(Mutex::new(HashMap::new()));
{
let on_answer = on_answer.clone();
tokio::spawn(async move {
while let Some(message) = self.rx.recv().await {
// send answer
if let WsServerMessage::Answer(sdp) = message.clone() {
if let Some(callback) = on_answer.lock().await.remove(&sdp.session_id) {
callback(sdp);
}
}
tx.send(message).await.unwrap();
}
});
}
(
ManagedSignalingConnection {
client: self.client,
tx: self.tx,
on_answer,
},
rx,
)
}
pub async fn send_update(&self, info: ClientInfoWithoutId) -> Result<()> {
send_update(&self.tx, info).await?;
Ok(())
}
pub async fn send_offer(&self, session_id: String, target: Uuid, sdp: String) -> Result<()> {
send_offer(&self.tx, session_id, target, sdp).await?;
Ok(())
}
pub async fn send_answer(&self, session_id: String, target: Uuid, sdp: String) -> Result<()> {
send_answer(&self.tx, session_id, target, sdp).await?;
Ok(())
}
}
type AnswerCallback = Box<dyn FnOnce(WsServerSdpMessage) + Send + Sync>;
pub struct ManagedSignalingConnection {
/// The peer info received from the server of the client.
pub client: ClientInfo,
tx: mpsc::Sender<WsClientMessage>,
on_answer: Arc<Mutex<HashMap<String, AnswerCallback>>>,
}
#[cfg(feature = "webrtc-signaling")]
impl ManagedSignalingConnection {
pub async fn send_update(&self, info: ClientInfoWithoutId) -> Result<()> {
send_update(&self.tx, info).await?;
Ok(())
}
pub async fn send_offer(&self, session_id: String, target: Uuid, sdp: String) -> Result<()> {
send_offer(&self.tx, session_id, target, sdp).await?;
Ok(())
}
pub async fn send_answer(&self, session_id: String, target: Uuid, sdp: String) -> Result<()> {
send_answer(&self.tx, session_id, target, sdp).await?;
Ok(())
}
/// Adds a callback to be called when an answer having a specific `session_id` is received.
pub async fn on_answer<F>(&self, session_id: String, callback: F)
where
F: FnOnce(WsServerSdpMessage) + Send + Sync + 'static,
{
let mut callbacks = self.on_answer.lock().await;
callbacks.insert(session_id, Box::new(callback));
}
}
async fn send_update(tx: &mpsc::Sender<WsClientMessage>, info: ClientInfoWithoutId) -> Result<()> {
tx.send(WsClientMessage::Update { info }).await?;
tracing::debug!("Sent update to the server");
Ok(())
}
async fn send_offer(
tx: &mpsc::Sender<WsClientMessage>,
session_id: String,
target: Uuid,
sdp: String,
) -> Result<()> {
tx.send(WsClientMessage::Offer(WsClientSdpMessage {
session_id: session_id.clone(),
target,
sdp,
}))
.await?;
tracing::debug!("Sent offer to {target} with session ID {session_id}");
Ok(())
}
async fn send_answer(
tx: &mpsc::Sender<WsClientMessage>,
session_id: String,
target: Uuid,
sdp: String,
) -> Result<()> {
tx.send(WsClientMessage::Answer(WsClientSdpMessage {
session_id: session_id.clone(),
target,
sdp,
}))
.await?;
tracing::debug!("Sent answer to {target} with session ID {session_id}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ws_server_hello_message_encoding() {
let message = WsServerMessage::Hello {
client: ClientInfo {
id: Uuid::nil(),
alias: "Cute Apple".to_string(),
version: "2.3".to_string(),
device_model: Some("Dell".to_string()),
device_type: Some(DeviceType::Desktop),
token: "123".to_string(),
},
peers: vec![],
};
let encoded = serde_json::to_string_pretty(&message).unwrap();
assert_eq!(
encoded,
r#"{
"type": "HELLO",
"client": {
"id": "00000000-0000-0000-0000-000000000000",
"alias": "Cute Apple",
"version": "2.3",
"deviceModel": "Dell",
"deviceType": "DESKTOP",
"token": "123"
},
"peers": []
}"#
);
let decoded: WsServerMessage = serde_json::from_str(&encoded).unwrap();
assert_eq!(message, decoded);
}
#[test]
fn ws_server_offer_message_encoding() {
let message = WsServerMessage::Offer(WsServerSdpMessage {
peer: ClientInfo {
id: Uuid::nil(),
alias: "Cute Apple".to_string(),
version: "2.3".to_string(),
device_model: None,
device_type: Some(DeviceType::Desktop),
token: "123".to_string(),
},
session_id: "456".to_string(),
sdp: "my-sdp".to_string(),
});
let encoded = serde_json::to_string_pretty(&message).unwrap();
assert_eq!(
encoded,
r#"{
"type": "OFFER",
"peer": {
"id": "00000000-0000-0000-0000-000000000000",
"alias": "Cute Apple",
"version": "2.3",
"deviceType": "DESKTOP",
"token": "123"
},
"sessionId": "456",
"sdp": "my-sdp"
}"#
);
let decoded: WsServerMessage = serde_json::from_str(&encoded).unwrap();
assert_eq!(message, decoded);
}
#[test]
fn ws_client_update_message_encoding() {
let message = WsClientMessage::Update {
info: ClientInfoWithoutId {
alias: "Cute Apple".to_string(),
version: "2.3".to_string(),
device_model: Some("Dell".to_string()),
device_type: Some(DeviceType::Desktop),
token: "123".to_string(),
},
};
let encoded = serde_json::to_string_pretty(&message).unwrap();
assert_eq!(
encoded,
r#"{
"type": "UPDATE",
"info": {
"alias": "Cute Apple",
"version": "2.3",
"deviceModel": "Dell",
"deviceType": "DESKTOP",
"token": "123"
}
}"#
);
let decoded: WsClientMessage = serde_json::from_str(&encoded).unwrap();
assert_eq!(message, decoded);
}
}
File diff suppressed because it is too large Load Diff
+651
View File
@@ -0,0 +1,651 @@
#![cfg(feature = "http")]
use bytes::Bytes;
use localsend::http::client::{ClientError, LsHttpClientV2};
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, ProtocolTypeV2, RegisterDtoV2};
use localsend::http::server::common::save::FileUploadTarget;
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2};
use localsend::http::server::{start_with_port, ServerConfigV2};
use localsend::http::state::ClientInfo;
use localsend::model::transfer::{FileContent, FileDto};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio_util::sync::CancellationToken;
struct TestServer {
port: u16,
/// Uploaded file contents, mapped by file ID.
received: Arc<Mutex<HashMap<String, Vec<u8>>>>,
/// Ended sessions with their reasons.
session_ends: Arc<Mutex<Vec<(String, SessionEndReasonV2)>>>,
_stop_tx: oneshot::Sender<()>,
}
/// Starts a test server.
///
/// Uploads are received as a stream, or written by the server into `save_dir`
/// when given. Either way, the content ends up in [`TestServer::received`].
async fn start_test_server(
pin: Option<String>,
accept: bool,
save_dir: Option<PathBuf>,
) -> TestServer {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
let received: Arc<Mutex<HashMap<String, Vec<u8>>>> = Arc::new(Mutex::new(HashMap::new()));
let session_ends: Arc<Mutex<Vec<(String, SessionEndReasonV2)>>> =
Arc::new(Mutex::new(Vec::new()));
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16);
tokio::spawn({
let received = received.clone();
let session_ends = session_ends.clone();
async move {
while let Some(event) = event_rx.recv().await {
match event {
ServerEventV2::Register { .. } => {}
ServerEventV2::PrepareUpload {
files, decision_tx, ..
} => {
let decision = match accept {
true => {
PrepareUploadDecisionV2::Accept(files.keys().cloned().collect())
}
false => PrepareUploadDecisionV2::Decline,
};
let _ = decision_tx.send(decision);
}
ServerEventV2::FileUpload {
file_id, target_tx, ..
} => {
let received = received.clone();
match &save_dir {
None => {
let (binary_tx, mut binary_rx) = mpsc::channel(16);
let (result_tx, result_rx) = oneshot::channel();
let _ = target_tx.send(FileUploadTarget::Stream {
binary_tx,
result_rx,
});
tokio::spawn(async move {
let mut bytes = Vec::new();
while let Some(chunk) = binary_rx.recv().await {
bytes.extend_from_slice(&chunk);
}
received.lock().await.insert(file_id, bytes);
let _ = result_tx.send(Ok(()));
});
}
Some(dir) => {
let path = dir.join(&file_id);
let (result_tx, result_rx) = oneshot::channel();
let _ = target_tx.send(FileUploadTarget::Path {
path: path.clone(),
result_tx,
});
tokio::spawn(async move {
if let Ok(Ok(())) = result_rx.await {
let bytes = tokio::fs::read(&path).await.unwrap();
received.lock().await.insert(file_id, bytes);
}
});
}
}
}
ServerEventV2::SessionEnd { session_id, reason } => {
session_ends.lock().await.push((session_id, reason));
}
}
}
}
});
let (stop_tx, stop_rx) = oneshot::channel::<()>();
start_with_port(
port,
None, // plain HTTP
ClientInfo {
alias: "Test Server".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
token: "server-fingerprint".to_string(),
},
Some(ServerConfigV2 { pin, event_tx }),
None,
stop_rx,
)
.await
.expect("Failed to start server");
wait_until_reachable(port).await;
TestServer {
port,
received,
session_ends,
_stop_tx: stop_tx,
}
}
/// Returns a free port.
///
/// A counter is used instead of binding to port 0 because the OS may hand out
/// the same just-freed ephemeral port to multiple tests running in parallel.
fn free_port() -> u16 {
static PORT_COUNTER: AtomicU16 = AtomicU16::new(40551);
loop {
let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
}
async fn wait_until_reachable(port: u16) {
for _ in 0..100 {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("Server did not become reachable on port {port}");
}
fn sender_info() -> RegisterDtoV2 {
RegisterDtoV2 {
alias: "Test Sender".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
fingerprint: "sender-fingerprint".to_string(),
port: 53317,
protocol: ProtocolTypeV2::Http,
download: false,
}
}
fn file_dto(id: &str, name: &str, size: u64) -> FileDto {
FileDto {
id: id.to_string(),
file_name: name.to_string(),
size,
file_type: "application/octet-stream".to_string(),
sha256: None,
preview: None,
metadata: None,
}
}
fn prepare_upload_request(files: &[FileDto]) -> PrepareUploadRequestDtoV2 {
PrepareUploadRequestDtoV2 {
info: sender_info(),
files: files
.iter()
.map(|file| (file.id.clone(), file.clone()))
.collect(),
}
}
async fn upload_bytes(
client: &LsHttpClientV2,
port: u16,
session_id: &str,
file_id: &str,
token: &str,
bytes: &[u8],
) -> Result<(), ClientError> {
let (tx, rx) = mpsc::channel::<Bytes>(4);
let chunks: Vec<Vec<u8>> = bytes.chunks(1024).map(|chunk| chunk.to_vec()).collect();
tokio::spawn(async move {
for chunk in chunks {
if tx.send(Bytes::from(chunk)).await.is_err() {
break;
}
}
});
client
.upload(
ProtocolType::Http,
"127.0.0.1",
port,
None,
session_id,
file_id,
token,
FileContent::Stream(rx),
CancellationToken::new(),
)
.await
}
fn assert_status(result: Result<impl Sized, ClientError>, expected_status: u16) {
match result {
Err(ClientError::StatusCode(err)) => assert_eq!(err.status, expected_status),
Err(err) => panic!("Expected status code {expected_status}, got error: {err:?}"),
Ok(_) => panic!("Expected status code {expected_status}, got success"),
}
}
#[tokio::test]
async fn test_register_and_info() {
let server = start_test_server(None, true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let response = client
.register(ProtocolType::Http, "127.0.0.1", server.port, sender_info())
.await
.unwrap();
assert_eq!(response.body.alias, "Test Server");
assert_eq!(response.body.fingerprint, "server-fingerprint");
assert!(!response.body.download);
let info = client
.info(ProtocolType::Http, "127.0.0.1", server.port)
.await
.unwrap();
assert_eq!(info.alias, "Test Server");
assert_eq!(info.fingerprint, "server-fingerprint");
}
#[tokio::test]
async fn test_register_over_ipv6() {
let server = start_test_server(None, true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let response = client
.register(ProtocolType::Http, "::1", server.port, sender_info())
.await
.unwrap();
assert_eq!(response.body.alias, "Test Server");
}
#[tokio::test]
async fn test_full_upload_flow() {
let server = start_test_server(None, true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let file_a = file_dto("file-a", "a.bin", 100_000);
let file_b = file_dto("file-b", "b.bin", 5);
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
None,
)
.await
.unwrap();
assert_eq!(result.status_code, 200);
let response = result.response.unwrap();
assert_eq!(response.files.len(), 2);
let bytes_a: Vec<u8> = (0..100_000u32).map(|i| i as u8).collect();
let bytes_b = b"hello".to_vec();
upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
&response.files["file-a"],
&bytes_a,
)
.await
.unwrap();
upload_bytes(
&client,
server.port,
&response.session_id,
"file-b",
&response.files["file-b"],
&bytes_b,
)
.await
.unwrap();
let received = server.received.lock().await;
assert_eq!(received["file-a"], bytes_a);
assert_eq!(received["file-b"], bytes_b);
drop(received);
// The session should have ended after all files were uploaded.
tokio::time::sleep(Duration::from_millis(100)).await;
let session_ends = server.session_ends.lock().await;
assert_eq!(
*session_ends,
vec![(response.session_id.clone(), SessionEndReasonV2::Finished)]
);
// The session is gone, so uploading again is rejected.
let result = upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
&response.files["file-a"],
b"again",
)
.await;
assert_status(result, 403);
}
#[tokio::test]
async fn test_upload_saved_to_path_by_server() {
let save_dir = std::env::temp_dir().join(format!("localsend-test-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&save_dir).await.unwrap();
let server = start_test_server(None, true, Some(save_dir.clone())).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let file_a = file_dto("file-a", "a.bin", 100_000);
let file_b = file_dto("file-b", "b.bin", 5);
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
None,
)
.await
.unwrap();
let response = result.response.unwrap();
let bytes_a: Vec<u8> = (0..100_000u32).map(|i| i as u8).collect();
let bytes_b = b"hello".to_vec();
upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
&response.files["file-a"],
&bytes_a,
)
.await
.unwrap();
upload_bytes(
&client,
server.port,
&response.session_id,
"file-b",
&response.files["file-b"],
&bytes_b,
)
.await
.unwrap();
// The test harness reads the files back after the server reported the result.
tokio::time::sleep(Duration::from_millis(100)).await;
let received = server.received.lock().await;
assert_eq!(received["file-a"], bytes_a);
assert_eq!(received["file-b"], bytes_b);
drop(received);
let session_ends = server.session_ends.lock().await;
assert_eq!(
*session_ends,
vec![(response.session_id.clone(), SessionEndReasonV2::Finished)]
);
let _ = tokio::fs::remove_dir_all(&save_dir).await;
}
#[tokio::test]
async fn test_upload_with_invalid_token() {
let server = start_test_server(None, true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let file = file_dto("file-a", "a.bin", 5);
let response = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
None,
)
.await
.unwrap()
.response
.unwrap();
let result = upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
"wrong-token",
b"hello",
)
.await;
assert_status(result, 403);
// The correct token still works afterwards.
upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
&response.files["file-a"],
b"hello",
)
.await
.unwrap();
}
#[tokio::test]
async fn test_upload_missing_parameters() {
let server = start_test_server(None, true, None).await;
let response = localsend::reqwest::Client::new()
.post(format!(
"http://127.0.0.1:{}/api/localsend/v2/upload?sessionId=abc",
server.port
))
.body("data")
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 400);
}
#[tokio::test]
async fn test_second_session_blocked_and_cancel() {
let server = start_test_server(None, true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let file = file_dto("file-a", "a.bin", 5);
let response = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file.clone()]),
None,
)
.await
.unwrap()
.response
.unwrap();
// A second session is blocked while the first one is active.
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file.clone()]),
None,
)
.await;
assert_status(result, 409);
client
.cancel(
ProtocolType::Http,
"127.0.0.1",
server.port,
&response.session_id,
)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let session_ends = server.session_ends.lock().await.clone();
assert_eq!(
session_ends,
vec![(response.session_id.clone(), SessionEndReasonV2::Cancelled)]
);
// After cancelling, a new session can be created.
client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
None,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_prepare_upload_declined() {
let server = start_test_server(None, false, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let file = file_dto("file-a", "a.bin", 5);
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file.clone()]),
None,
)
.await;
assert_status(result, 403);
// A declined request must not block subsequent sessions.
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
None,
)
.await;
assert_status(result, 403);
}
#[tokio::test]
async fn test_pin() {
let server = start_test_server(Some("123456".to_string()), true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let file = file_dto("file-a", "a.bin", 5);
// Missing PIN.
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file.clone()]),
None,
)
.await;
assert_status(result, 401);
// Wrong PIN.
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file.clone()]),
Some("000000"),
)
.await;
assert_status(result, 401);
// Correct PIN.
client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
Some("123456"),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_pin_too_many_attempts() {
let server = start_test_server(Some("123456".to_string()), true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let file = file_dto("file-a", "a.bin", 5);
for _ in 0..3 {
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file.clone()]),
Some("000000"),
)
.await;
assert_status(result, 401);
}
// Blocked even with the correct PIN.
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
Some("123456"),
)
.await;
assert_status(result, 429);
}
+534
View File
@@ -0,0 +1,534 @@
#![cfg(feature = "http")]
use bytes::Bytes;
use localsend::http::client::{ClientError, LsHttpClientV2};
use localsend::http::dto::ProtocolType;
use localsend::http::server::v2::ServerEventV2;
use localsend::http::server::web::WebSendConfig;
use localsend::http::server::web::{WebSendEvent, WebSendI18n};
use localsend::http::server::{start_with_port, ServerConfigV2};
use localsend::http::state::ClientInfo;
use localsend::model::transfer::{FileContent, FileDto};
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU16, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::sync::{mpsc, oneshot};
struct TestServer {
port: u16,
/// Number of `PrepareDownload` events received.
prepare_download_events: Arc<AtomicU32>,
_stop_tx: oneshot::Sender<()>,
}
/// The content sources backing the offered files, used by the test event
/// handler to answer `FileDownload` events.
#[derive(Clone)]
enum TestFileContent {
Bytes(Bytes),
Path(PathBuf),
}
impl TestFileContent {
/// Streams the content into `tx`, mimicking how an application would
/// serve in-memory content or a file from disk.
async fn stream(self, tx: mpsc::Sender<Bytes>) {
match self {
TestFileContent::Bytes(bytes) => {
let _ = tx.send(bytes).await;
}
TestFileContent::Path(path) => {
let mut file = tokio::fs::File::open(&path)
.await
.expect("Failed to open test file");
let mut buffer = vec![0u8; 4096];
loop {
let bytes_read = file.read(&mut buffer).await.expect("Failed to read");
if bytes_read == 0 {
break; // EOF
}
if tx
.send(Bytes::copy_from_slice(&buffer[..bytes_read]))
.await
.is_err()
{
break; // client disconnected
}
}
}
}
}
}
async fn start_test_server(
web_send: Option<(WebSendConfig, HashMap<String, TestFileContent>)>,
accept: bool,
) -> TestServer {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
let prepare_download_events = Arc::new(AtomicU32::new(0));
let (web_send, contents) = match web_send {
Some((config, contents)) => (Some(config), contents),
None => (None, HashMap::new()),
};
// Web send emits its own event type, independent of the v2 protocol events.
let (web_event_tx, mut web_event_rx) = mpsc::channel::<WebSendEvent>(16);
tokio::spawn({
let prepare_download_events = prepare_download_events.clone();
async move {
while let Some(event) = web_event_rx.recv().await {
match event {
WebSendEvent::PrepareDownload { decision_tx, .. } => {
prepare_download_events.fetch_add(1, Ordering::SeqCst);
let _ = decision_tx.send(accept);
}
WebSendEvent::FileDownload {
file_id,
content_tx,
..
} => {
let content = contents
.get(&file_id)
.expect("FileDownload for unknown file")
.clone();
tokio::spawn(async move {
let (tx, rx) = mpsc::channel::<Bytes>(16);
if content_tx.send(FileContent::Stream(rx)).is_err() {
return;
}
content.stream(tx).await;
});
}
}
}
}
});
// v2 stays enabled so the `/info` endpoint (which advertises `download`) can be
// exercised. Its event channel is unused by these tests.
let (v2_event_tx, _v2_event_rx) = tokio::sync::mpsc::channel::<ServerEventV2>(16);
let (stop_tx, stop_rx) = oneshot::channel::<()>();
// Web send is configured independently of the v2 endpoints.
let web_send = web_send.map(|mut config| {
config.event_tx = web_event_tx;
config
});
start_with_port(
port,
None, // plain HTTP
ClientInfo {
alias: "Test Server".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
token: "server-fingerprint".to_string(),
},
Some(ServerConfigV2 {
pin: None,
event_tx: v2_event_tx,
}),
web_send,
stop_rx,
)
.await
.expect("Failed to start server");
wait_until_reachable(port).await;
TestServer {
port,
prepare_download_events,
_stop_tx: stop_tx,
}
}
/// Returns a free port.
///
/// A counter is used instead of binding to port 0 because the OS may hand out
/// the same just-freed ephemeral port to multiple tests running in parallel.
fn free_port() -> u16 {
static PORT_COUNTER: AtomicU16 = AtomicU16::new(41551);
loop {
let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
}
async fn wait_until_reachable(port: u16) {
for _ in 0..100 {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("Server did not become reachable on port {port}");
}
fn file_dto(id: &str, name: &str, size: u64) -> FileDto {
FileDto {
id: id.to_string(),
file_name: name.to_string(),
size,
file_type: "application/octet-stream".to_string(),
sha256: None,
preview: None,
metadata: None,
}
}
/// Creates a web send config with an in-memory text file and a file on disk.
///
/// Returns the config, the content sources for the test event handler and
/// the path of the file on disk (the caller should delete it).
fn web_send_config(
pin: Option<String>,
) -> (
WebSendConfig,
HashMap<String, TestFileContent>,
PathBuf,
Vec<u8>,
) {
let disk_content: Vec<u8> = (0..100_000u32).map(|i| i as u8).collect();
let disk_path =
std::env::temp_dir().join(format!("localsend-web-send-{}", uuid::Uuid::new_v4()));
std::fs::write(&disk_path, &disk_content).expect("Failed to write test file");
// The config only carries the metadata; the content is streamed by the
// test event handler when the server emits `FileDownload`.
let files = HashMap::from([
(
"file-text".to_string(),
file_dto("file-text", "message.txt", 5),
),
(
"file-disk".to_string(),
file_dto("file-disk", "dir/data.bin", disk_content.len() as u64),
),
]);
let contents = HashMap::from([
(
"file-text".to_string(),
TestFileContent::Bytes(Bytes::from_static(b"hello")),
),
(
"file-disk".to_string(),
TestFileContent::Path(disk_path.clone()),
),
]);
// The event channel is a placeholder; `start_test_server` replaces it with
// the one whose receiver handles the web send events.
let (event_tx, _event_rx) = tokio::sync::mpsc::channel::<WebSendEvent>(16);
(
WebSendConfig {
files,
pin,
i18n: WebSendI18n::default(),
event_tx,
},
contents,
disk_path,
disk_content,
)
}
fn assert_status(result: Result<impl Sized, ClientError>, expected_status: u16) {
match result {
Err(ClientError::StatusCode(err)) => assert_eq!(err.status, expected_status),
Err(err) => panic!("Expected status code {expected_status}, got error: {err:?}"),
Ok(_) => panic!("Expected status code {expected_status}, got success"),
}
}
#[tokio::test]
async fn test_web_page() {
let (config, contents, disk_path, _) = web_send_config(None);
let server = start_test_server(Some((config, contents)), true).await;
let client = localsend::reqwest::Client::new();
let base_url = format!("http://127.0.0.1:{}", server.port);
let response = client.get(&base_url).send().await.unwrap();
assert_eq!(response.status().as_u16(), 200);
assert!(response.text().await.unwrap().contains("LocalSend"));
let response = client
.get(format!("{base_url}/main.js"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 200);
assert_eq!(
response.headers()["content-type"],
"text/javascript; charset=utf-8"
);
let response = client
.get(format!("{base_url}/i18n.json"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 200);
let i18n = response.json::<HashMap<String, String>>().await.unwrap();
assert_eq!(i18n["enterPin"], "Enter PIN");
assert!(i18n.contains_key("waiting"));
// The register/info endpoints advertise the download API.
let info = LsHttpClientV2::try_new_without_cert()
.unwrap()
.info(ProtocolType::Http, "127.0.0.1", server.port)
.await
.unwrap();
assert!(info.download);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_web_page_disabled() {
let server = start_test_server(None, true).await;
let client = localsend::reqwest::Client::new();
let base_url = format!("http://127.0.0.1:{}", server.port);
let response = client.get(&base_url).send().await.unwrap();
assert_eq!(response.status().as_u16(), 403);
let response = client
.post(format!("{base_url}/api/localsend/v2/prepare-download"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 403);
let info = LsHttpClientV2::try_new_without_cert()
.unwrap()
.info(ProtocolType::Http, "127.0.0.1", server.port)
.await
.unwrap();
assert!(!info.download);
}
#[tokio::test]
async fn test_full_download_flow() {
let (config, contents, disk_path, disk_content) = web_send_config(None);
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let response = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await
.unwrap();
assert_eq!(response.info.alias, "Test Server");
assert_eq!(response.files.len(), 2);
assert_eq!(response.files["file-text"].file_name, "message.txt");
assert_eq!(server.prepare_download_events.load(Ordering::SeqCst), 1);
// Download the in-memory file.
let mut bytes = Vec::new();
client
.download_to_writer(
ProtocolType::Http,
"127.0.0.1",
server.port,
&response.session_id,
"file-text",
&mut bytes,
)
.await
.unwrap();
assert_eq!(bytes, b"hello");
// Download the file from disk (streamed).
let download_response = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
&response.session_id,
"file-disk",
)
.await
.unwrap();
assert_eq!(
download_response.headers()["content-length"],
disk_content.len().to_string().as_str()
);
assert_eq!(
download_response.headers()["content-disposition"],
"attachment; filename=\"dir-data.bin\""
);
assert_eq!(
download_response.bytes().await.unwrap().as_ref(),
disk_content.as_slice()
);
// The session can be reused without a new permission request (e.g. page reload).
let reused = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
Some(&response.session_id),
None,
)
.await
.unwrap();
assert_eq!(reused.session_id, response.session_id);
assert_eq!(server.prepare_download_events.load(Ordering::SeqCst), 1);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_prepare_download_rejected() {
let (config, contents, disk_path, _) = web_send_config(None);
let server = start_test_server(Some((config, contents)), false).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let result = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await;
assert_status(result, 403);
// The rejected session must not allow downloads.
let local_ip: IpAddr = "127.0.0.1".parse().unwrap();
let result = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
&local_ip.to_string(),
"file-text",
)
.await;
assert_status(result, 403);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_download_invalid_session() {
let (config, contents, disk_path, _) = web_send_config(None);
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let result = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
"unknown-session",
"file-text",
)
.await;
assert_status(result, 403);
// Valid session but unknown file.
let response = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await
.unwrap();
let result = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
&response.session_id,
"unknown-file",
)
.await;
assert_status(result, 403);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_pin() {
let (config, contents, disk_path, _) = web_send_config(Some("123456".to_string()));
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
// Missing PIN.
let result = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await;
assert_status(result, 401);
// Wrong PIN.
let result = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("000000"),
)
.await;
assert_status(result, 401);
// Correct PIN.
client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("123456"),
)
.await
.unwrap();
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_pin_too_many_attempts() {
let (config, contents, disk_path, _) = web_send_config(Some("123456".to_string()));
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
for _ in 0..3 {
let result = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("000000"),
)
.await;
assert_status(result, 401);
}
// Blocked even with the correct PIN.
let result = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("123456"),
)
.await;
assert_status(result, 429);
let _ = std::fs::remove_file(disk_path);
}