can receive files and text
This commit is contained in:
commit
c422bbcd00
20 changed files with 3597 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
204
3P-LICENSE.txt
Normal file
204
3P-LICENSE.txt
Normal file
|
@ -0,0 +1,204 @@
|
|||
Portions of this software originate from https://github.com/wylited/localsend and are used under the
|
||||
terms of the Apache License, as provided below:
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2022-2024 wylited
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
2531
Cargo.lock
generated
Normal file
2531
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "joecalsend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
chrono = "0.4"
|
||||
julid-rs = { version = "1", default-features = false, features = ["serde"] }
|
||||
mime = "0.3"
|
||||
mime_guess = "2"
|
||||
native-dialog = "0.9"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha256 = "1.6"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] }
|
||||
tower-http = { version = "0.6", features = ["limit"] }
|
55
src/discovery/http.rs
Normal file
55
src/discovery/http.rs
Normal file
|
@ -0,0 +1,55 @@
|
|||
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, State},
|
||||
Extension, Json,
|
||||
};
|
||||
|
||||
use crate::{models::device::DeviceInfo, Client};
|
||||
|
||||
impl Client {
|
||||
pub async fn announce_http(&self, ip: Option<SocketAddr>) -> crate::error::Result<()> {
|
||||
if let Some(ip) = ip {
|
||||
let url = format!("http://{}/api/localsend/v2/register", ip);
|
||||
let client = reqwest::Client::new();
|
||||
client.post(&url).json(&self.device).send().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn announce_http_legacy(&self) -> crate::error::Result<()> {
|
||||
// send the reqwest to all local ip addresses from 192.168.0.0 to 192.168.255.255
|
||||
let mut address_list = Vec::new();
|
||||
for j in 0..256 {
|
||||
for k in 0..256 {
|
||||
address_list.push(format!("192.168.{:03}.{}:53317", j, k));
|
||||
}
|
||||
}
|
||||
|
||||
for ip in address_list {
|
||||
let url = format!("http://{}/api/localsend/v2/register", ip);
|
||||
self.http_client
|
||||
.post(&url)
|
||||
.json(&self.device)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_device(
|
||||
State(peers): State<Arc<Mutex<HashMap<String, (SocketAddr, DeviceInfo)>>>>,
|
||||
Extension(client): Extension<DeviceInfo>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(device): Json<DeviceInfo>,
|
||||
) -> Json<DeviceInfo> {
|
||||
let mut addr = addr;
|
||||
addr.set_port(device.port);
|
||||
peers
|
||||
.lock()
|
||||
.await
|
||||
.insert(device.fingerprint.clone(), (addr, device.clone()));
|
||||
Json(client)
|
||||
}
|
42
src/discovery/mod.rs
Normal file
42
src/discovery/mod.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use crate::{models::device::DeviceInfo, Client};
|
||||
|
||||
pub mod http;
|
||||
pub mod multicast;
|
||||
|
||||
impl Client {
|
||||
pub async fn announce(&self, socket: Option<SocketAddr>) -> crate::error::Result<()> {
|
||||
self.announce_http(socket).await?;
|
||||
self.announce_multicast().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_device(&self, message: &str, src: SocketAddr) {
|
||||
if let Ok(device) = serde_json::from_str::<DeviceInfo>(message) {
|
||||
if device.fingerprint == self.device.fingerprint {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut src = src;
|
||||
src.set_port(device.port); // Update the port to the one the device sent
|
||||
|
||||
let mut peers = self.peers.lock().await;
|
||||
peers.insert(device.fingerprint.clone(), (src.clone(), device.clone()));
|
||||
|
||||
if device.announce != Some(true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Announce in return upon receiving a valid device message and it wants announcements
|
||||
if let Err(e) = self.announce_multicast().await {
|
||||
eprintln!("Error during multicast announcement: {}", e);
|
||||
}
|
||||
if let Err(e) = self.announce_http(Some(src)).await {
|
||||
eprintln!("Error during HTTP announcement: {}", e);
|
||||
};
|
||||
} else {
|
||||
eprintln!("Received invalid message: {}", message);
|
||||
}
|
||||
}
|
||||
}
|
29
src/discovery/multicast.rs
Normal file
29
src/discovery/multicast.rs
Normal file
|
@ -0,0 +1,29 @@
|
|||
use crate::Client;
|
||||
|
||||
impl Client {
|
||||
pub async fn announce_multicast(&self) -> crate::error::Result<()> {
|
||||
let msg = self.device.to_json()?;
|
||||
let addr = self.multicast_addr.clone();
|
||||
self.socket.send_to(msg.as_bytes(), addr).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn listen_multicast(&self) -> crate::error::Result<()> {
|
||||
let mut buf = [0; 65536];
|
||||
println!("Socket local addr: {:?}", self.socket.local_addr()?);
|
||||
println!("Listening on multicast addr: {}", self.multicast_addr);
|
||||
|
||||
loop {
|
||||
match self.socket.recv_from(&mut buf).await {
|
||||
Ok((size, src)) => {
|
||||
let received_msg = String::from_utf8_lossy(&buf[..size]);
|
||||
self.process_device(&received_msg, src).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error receiving message: {}", e);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
43
src/error.rs
Normal file
43
src/error.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LocalSendError {
|
||||
#[error("IO error: {0}")]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Request error: {0}")]
|
||||
RequestError(#[from] reqwest::Error),
|
||||
|
||||
#[error("Async join error: {0}")]
|
||||
JoinError(#[from] tokio::task::JoinError),
|
||||
|
||||
#[error("Invalid PIN")]
|
||||
InvalidPin,
|
||||
|
||||
#[error("Session blocked")]
|
||||
SessionBlocked,
|
||||
|
||||
#[error("Too many requests")]
|
||||
TooManyRequests,
|
||||
|
||||
#[error("Not a file")]
|
||||
NotAFile,
|
||||
|
||||
#[error("Peer not found")]
|
||||
PeerNotFound,
|
||||
|
||||
#[error("Upload failed")]
|
||||
UploadFailed,
|
||||
|
||||
#[error("Invalid token")]
|
||||
InvalidToken,
|
||||
|
||||
#[error("Session inactive")]
|
||||
SessionInactive,
|
||||
|
||||
#[error("Cancel Failed")]
|
||||
CancelFailed,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, LocalSendError>;
|
120
src/lib.rs
Normal file
120
src/lib.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
pub mod discovery;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod server;
|
||||
pub mod transfer;
|
||||
|
||||
use crate::models::device::DeviceInfo;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use transfer::session::Session;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
pub device: DeviceInfo,
|
||||
pub socket: Arc<UdpSocket>,
|
||||
pub multicast_addr: SocketAddrV4,
|
||||
pub port: u16,
|
||||
pub peers: Arc<Mutex<HashMap<String, (SocketAddr, DeviceInfo)>>>,
|
||||
pub sessions: Arc<Mutex<HashMap<String, Session>>>, // Session ID to Session
|
||||
pub http_client: reqwest::Client,
|
||||
pub download_dir: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn default() -> crate::error::Result<Self> {
|
||||
let device = DeviceInfo::default();
|
||||
let socket = UdpSocket::bind("0.0.0.0:53317").await?;
|
||||
socket.set_multicast_loop_v4(true)?;
|
||||
socket.set_multicast_ttl_v4(255)?;
|
||||
socket.join_multicast_v4(Ipv4Addr::new(224, 0, 0, 167), Ipv4Addr::new(0, 0, 0, 0))?;
|
||||
let multicast_addr = SocketAddrV4::new(Ipv4Addr::new(224, 0, 0, 167), 53317);
|
||||
let port = 53317;
|
||||
let peers = Arc::new(Mutex::new(HashMap::new()));
|
||||
let http_client = reqwest::Client::new();
|
||||
let sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let download_dir = "/home/localsend".to_string();
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
socket: socket.into(),
|
||||
multicast_addr,
|
||||
port,
|
||||
peers,
|
||||
http_client,
|
||||
sessions,
|
||||
download_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn with_config(
|
||||
info: DeviceInfo,
|
||||
port: u16,
|
||||
download_dir: String,
|
||||
) -> crate::error::Result<Self> {
|
||||
let socket = UdpSocket::bind(format!("0.0.0.0:{}", port.clone())).await?;
|
||||
socket.set_multicast_loop_v4(true)?;
|
||||
socket.set_multicast_ttl_v4(255)?;
|
||||
socket.join_multicast_v4(Ipv4Addr::new(224, 0, 0, 167), Ipv4Addr::new(0, 0, 0, 0))?;
|
||||
let multicast_addr = SocketAddrV4::new(Ipv4Addr::new(224, 0, 0, 167), port);
|
||||
let peers = Arc::new(Mutex::new(HashMap::new()));
|
||||
let http_client = reqwest::Client::new();
|
||||
let sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
Ok(Self {
|
||||
device: info,
|
||||
socket: socket.into(),
|
||||
multicast_addr,
|
||||
port,
|
||||
peers,
|
||||
http_client,
|
||||
sessions,
|
||||
download_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
&self,
|
||||
) -> crate::error::Result<(JoinHandle<()>, JoinHandle<()>, JoinHandle<()>)> {
|
||||
let server_handle = {
|
||||
let client = self.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client.start_http_server().await {
|
||||
eprintln!("HTTP server error: {e}");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let udp_handle = {
|
||||
let client = self.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client.listen_multicast().await {
|
||||
eprintln!("UDP listener error: {}", e);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let announcement_handle = {
|
||||
let client = self.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Err(e) = client.announce(None).await {
|
||||
eprintln!("Announcement error: {}", e);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Ok((server_handle, udp_handle, announcement_handle))
|
||||
}
|
||||
|
||||
pub async fn refresh_peers(&self) {
|
||||
let mut peers = self.peers.lock().await;
|
||||
peers.clear();
|
||||
}
|
||||
}
|
17
src/main.rs
Normal file
17
src/main.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use joecalsend::{models::device::DeviceInfo, Client};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let device = DeviceInfo::default();
|
||||
dbg!(device);
|
||||
|
||||
let client = Client::with_config(
|
||||
DeviceInfo::default(),
|
||||
53317,
|
||||
"/home/ardent/joecalsend".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (h1, h2, h3) = client.start().await.unwrap();
|
||||
tokio::join!(h1, h2, h3);
|
||||
}
|
60
src/models/device.rs
Normal file
60
src/models/device.rs
Normal file
|
@ -0,0 +1,60 @@
|
|||
use julid::Julid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeviceType {
|
||||
Mobile,
|
||||
Desktop,
|
||||
Web,
|
||||
Headless,
|
||||
Server,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeviceInfo {
|
||||
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 fingerprint: String,
|
||||
pub port: u16,
|
||||
pub protocol: String,
|
||||
#[serde(default)]
|
||||
pub download: bool,
|
||||
#[serde(default)]
|
||||
pub announce: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Protocol {
|
||||
Http,
|
||||
Https,
|
||||
}
|
||||
|
||||
impl Default for DeviceInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
alias: "RustSend".to_string(),
|
||||
version: "2.1".to_string(),
|
||||
device_model: None,
|
||||
device_type: Some(DeviceType::Headless),
|
||||
fingerprint: Julid::new().to_string(),
|
||||
port: 53317,
|
||||
protocol: "http".to_string(),
|
||||
download: false,
|
||||
announce: Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceInfo {
|
||||
pub fn to_json(&self) -> crate::error::Result<String> {
|
||||
Ok(serde_json::to_string(self)?)
|
||||
}
|
||||
}
|
68
src/models/file.rs
Normal file
68
src/models/file.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use crate::error::LocalSendError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileMetadata {
|
||||
pub id: String,
|
||||
pub file_name: String,
|
||||
pub size: u64,
|
||||
pub file_type: String, // mime type
|
||||
#[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<FileMetadataExt>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileMetadataExt {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modified: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub accessed: Option<String>,
|
||||
}
|
||||
|
||||
impl FileMetadata {
|
||||
pub fn from_path(path: &Path) -> crate::error::Result<Self> {
|
||||
let metadata = path.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(LocalSendError::NotAFile);
|
||||
}
|
||||
|
||||
let id = path.to_str().unwrap().to_string();
|
||||
let file_name = path.file_name().unwrap().to_str().unwrap().to_string();
|
||||
let size = metadata.len();
|
||||
|
||||
let file_type = mime_guess::from_path(path)
|
||||
.first()
|
||||
.map(|mime| mime.to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()); // Default type if none found
|
||||
|
||||
let sha256 = Some(sha256::try_digest(path)?);
|
||||
|
||||
let metadata = Some(FileMetadataExt {
|
||||
modified: metadata.modified().ok().map(|t| format_datetime(t)),
|
||||
accessed: metadata.accessed().ok().map(|t| format_datetime(t)),
|
||||
});
|
||||
|
||||
Ok(FileMetadata {
|
||||
id,
|
||||
file_name,
|
||||
size,
|
||||
file_type,
|
||||
sha256,
|
||||
preview: None,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_datetime(system_time: SystemTime) -> String {
|
||||
let datetime: DateTime<Utc> = system_time.into();
|
||||
datetime.to_rfc3339()
|
||||
}
|
3
src/models/mod.rs
Normal file
3
src/models/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod device;
|
||||
pub mod file;
|
||||
pub mod session;
|
1
src/models/session.rs
Normal file
1
src/models/session.rs
Normal file
|
@ -0,0 +1 @@
|
|||
|
57
src/server/http.rs
Normal file
57
src/server/http.rs
Normal file
|
@ -0,0 +1,57 @@
|
|||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
|
||||
use crate::{
|
||||
discovery::http::register_device,
|
||||
transfer::upload::{register_prepare_upload, register_upload},
|
||||
Client,
|
||||
};
|
||||
|
||||
impl Client {
|
||||
pub async fn start_http_server(&self) -> crate::error::Result<()> {
|
||||
let app = self.create_router();
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
|
||||
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
println!("HTTP server listening on {}", addr);
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_router(&self) -> Router {
|
||||
let peers = self.peers.clone();
|
||||
let device = self.device.clone();
|
||||
|
||||
Router::new()
|
||||
.route("/api/localsend/v2/register", post(register_device))
|
||||
.route(
|
||||
"/api/localsend/v2/info",
|
||||
get(move || {
|
||||
let device = device.clone();
|
||||
async move { Json(device) }
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/localsend/v2/prepare-upload",
|
||||
post(register_prepare_upload),
|
||||
)
|
||||
.route("/api/localsend/v2/upload", post(register_upload))
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(1024 * 1024 * 1024))
|
||||
.layer(Extension(self.device.clone()))
|
||||
.layer(Extension(self.sessions.clone()))
|
||||
.layer(Extension(self.download_dir.clone()))
|
||||
.with_state(peers)
|
||||
}
|
||||
}
|
1
src/server/mod.rs
Normal file
1
src/server/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod http;
|
1
src/transfer/download.rs
Normal file
1
src/transfer/download.rs
Normal file
|
@ -0,0 +1 @@
|
|||
|
3
src/transfer/mod.rs
Normal file
3
src/transfer/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod download;
|
||||
pub mod session;
|
||||
pub mod upload;
|
25
src/transfer/session.rs
Normal file
25
src/transfer/session.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use std::{collections::HashMap, net::SocketAddr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::{device::DeviceInfo, file::FileMetadata};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Session {
|
||||
pub session_id: String,
|
||||
pub files: HashMap<String, FileMetadata>,
|
||||
pub file_tokens: HashMap<String, String>,
|
||||
pub receiver: DeviceInfo,
|
||||
pub sender: DeviceInfo,
|
||||
pub status: SessionStatus,
|
||||
pub addr: SocketAddr,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Deserialize, Serialize)]
|
||||
pub enum SessionStatus {
|
||||
Pending,
|
||||
Active,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
317
src/transfer/upload.rs
Normal file
317
src/transfer/upload.rs
Normal file
|
@ -0,0 +1,317 @@
|
|||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{ConnectInfo, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Extension;
|
||||
use axum::{response::IntoResponse, Json};
|
||||
|
||||
use crate::error::{LocalSendError, Result};
|
||||
use crate::transfer::session::{Session, SessionStatus};
|
||||
use crate::{
|
||||
models::{device::DeviceInfo, file::FileMetadata},
|
||||
Client,
|
||||
};
|
||||
use julid::Julid;
|
||||
use native_dialog::MessageDialogBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareUploadResponse {
|
||||
pub session_id: String,
|
||||
pub files: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareUploadRequest {
|
||||
pub info: DeviceInfo,
|
||||
pub files: HashMap<String, FileMetadata>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn prepare_upload(
|
||||
&self,
|
||||
peer: String,
|
||||
files: HashMap<String, FileMetadata>,
|
||||
) -> Result<PrepareUploadResponse> {
|
||||
if !self.peers.lock().await.contains_key(&peer) {
|
||||
return Err(LocalSendError::PeerNotFound);
|
||||
}
|
||||
|
||||
let peer = self.peers.lock().await.get(&peer).unwrap().clone();
|
||||
println!("Peer: {:?}", peer);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&format!(
|
||||
"{}://{}/api/localsend/v2/prepare-upload",
|
||||
peer.1.protocol,
|
||||
peer.0.clone()
|
||||
))
|
||||
.json(&PrepareUploadRequest {
|
||||
info: self.device.clone(),
|
||||
files: files.clone(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
println!("Response: {:?}", response);
|
||||
|
||||
let response: PrepareUploadResponse = response.json().await?;
|
||||
|
||||
let session = Session {
|
||||
session_id: response.session_id.clone(),
|
||||
files,
|
||||
file_tokens: response.files.clone(),
|
||||
receiver: peer.1,
|
||||
sender: self.device.clone(),
|
||||
status: SessionStatus::Active,
|
||||
addr: peer.0,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(response.session_id.clone(), session);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
&self,
|
||||
session_id: String,
|
||||
file_id: String,
|
||||
token: String,
|
||||
body: Bytes,
|
||||
) -> Result<()> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions.get(&session_id).unwrap();
|
||||
|
||||
if session.status != SessionStatus::Active {
|
||||
return Err(LocalSendError::SessionInactive);
|
||||
}
|
||||
|
||||
if session.file_tokens.get(&file_id) != Some(&token) {
|
||||
return Err(LocalSendError::InvalidToken);
|
||||
}
|
||||
|
||||
let request = self
|
||||
.http_client
|
||||
.post(&format!(
|
||||
"{}://{}/api/localsend/v2/upload?sessionId={}&fileId={}&token={}",
|
||||
session.receiver.protocol, session.addr, session_id, file_id, token
|
||||
))
|
||||
//.post(&format!("https://webhook.site/2f23a529-b687-4375-ad5f-54906ab26ac7?session_id={}&file_id={}&token={}", session_id, file_id, token))
|
||||
.body(body);
|
||||
|
||||
println!("Uploading file: {:?}", request);
|
||||
let response = request.send().await?;
|
||||
|
||||
if response.status() != 200 {
|
||||
println!("Upload failed: {:?}", response);
|
||||
return Err(LocalSendError::UploadFailed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_file(&self, peer: String, file_path: PathBuf) -> Result<()> {
|
||||
// Generate file metadata
|
||||
let file_metadata = FileMetadata::from_path(&file_path)?;
|
||||
|
||||
// Prepare files map
|
||||
let mut files = HashMap::new();
|
||||
files.insert(file_metadata.id.clone(), file_metadata.clone());
|
||||
|
||||
// Prepare upload
|
||||
let prepare_response = self.prepare_upload(peer, files).await?;
|
||||
|
||||
// Get file token
|
||||
let token = prepare_response
|
||||
.files
|
||||
.get(&file_metadata.id)
|
||||
.ok_or(LocalSendError::InvalidToken)?;
|
||||
|
||||
// Read file contents
|
||||
let file_contents = tokio::fs::read(&file_path).await?;
|
||||
let bytes = Bytes::from(file_contents);
|
||||
|
||||
// Upload file
|
||||
self.upload(
|
||||
prepare_response.session_id,
|
||||
file_metadata.id,
|
||||
token.clone(),
|
||||
bytes,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel_upload(&self, session_id: String) -> Result<()> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions.get(&session_id).unwrap();
|
||||
|
||||
let request = self
|
||||
.http_client
|
||||
.post(&format!(
|
||||
"{}://{}/api/localsend/v2/cancel?sessionId={}",
|
||||
session.receiver.protocol, session.addr, session_id
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if request.status() != 200 {
|
||||
return Err(LocalSendError::CancelFailed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_prepare_upload(
|
||||
Extension(client): Extension<DeviceInfo>,
|
||||
Extension(sessions): Extension<Arc<Mutex<HashMap<String, Session>>>>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(req): Json<PrepareUploadRequest>,
|
||||
) -> impl IntoResponse {
|
||||
println!("Received upload request from alias: {}", req.info.alias);
|
||||
|
||||
let result = MessageDialogBuilder::default()
|
||||
.set_title(&req.info.alias)
|
||||
.set_text("Do you want to receive files from this device?")
|
||||
.confirm()
|
||||
.show()
|
||||
.unwrap();
|
||||
|
||||
if result {
|
||||
let session_id = Julid::new().to_string();
|
||||
|
||||
let file_tokens: HashMap<String, String> = req
|
||||
.files
|
||||
.iter()
|
||||
.map(|(id, _)| (id.clone(), Julid::new().to_string())) // Replace with actual token logic
|
||||
.collect();
|
||||
|
||||
let session = Session {
|
||||
session_id: session_id.clone(),
|
||||
files: req.files.clone(),
|
||||
file_tokens: file_tokens.clone(),
|
||||
receiver: client.clone(),
|
||||
sender: req.info.clone(),
|
||||
status: SessionStatus::Active,
|
||||
addr,
|
||||
};
|
||||
|
||||
sessions.lock().await.insert(session_id.clone(), session);
|
||||
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(PrepareUploadResponse {
|
||||
session_id,
|
||||
files: file_tokens,
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
} else {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_upload(
|
||||
Query(params): Query<UploadParams>,
|
||||
Extension(sessions): Extension<Arc<Mutex<HashMap<String, Session>>>>,
|
||||
Extension(download_dir): Extension<String>,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
// Extract query parameters
|
||||
let session_id = ¶ms.session_id;
|
||||
let file_id = ¶ms.file_id;
|
||||
let token = ¶ms.token;
|
||||
|
||||
// Get session and validate
|
||||
let mut sessions_lock = sessions.lock().await;
|
||||
let session = match sessions_lock.get_mut(session_id) {
|
||||
Some(session) => session,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
if session.status != SessionStatus::Active {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
// Validate token
|
||||
if session.file_tokens.get(file_id) != Some(&token.to_string()) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
// Get file metadata
|
||||
let file_metadata = match session.files.get(file_id) {
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"File not found".to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if let Err(e) = tokio::fs::create_dir_all(&*download_dir).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create directory: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Create file path
|
||||
let file_path = format!("{}/{}", download_dir, file_metadata.file_name);
|
||||
|
||||
// Write file
|
||||
if let Err(e) = tokio::fs::write(&file_path, body).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to write file: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// Query parameters struct
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UploadParams {
|
||||
session_id: String,
|
||||
file_id: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
pub async fn register_cancel(
|
||||
Query(params): Query<CancelParams>,
|
||||
Extension(sessions): Extension<Arc<Mutex<HashMap<String, Session>>>>,
|
||||
) -> impl IntoResponse {
|
||||
let mut sessions_lock = sessions.lock().await;
|
||||
let session = match sessions_lock.get_mut(¶ms.session_id) {
|
||||
Some(session) => session,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
session.status = SessionStatus::Cancelled;
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// Cancel parameters struct
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelParams {
|
||||
session_id: String,
|
||||
}
|
Loading…
Reference in a new issue