mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
refactor: move core to packages/core
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+3529
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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();
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod cert;
|
||||
pub mod hash;
|
||||
pub mod nonce;
|
||||
pub mod token;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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: ¶ms,
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod discovery;
|
||||
pub mod transfer;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod base64;
|
||||
pub(crate) mod time;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod signaling;
|
||||
#[cfg(feature = "webrtc")]
|
||||
pub mod webrtc;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user