joecalsend/src/transfer.rs

448 lines
13 KiB
Rust
Raw Normal View History

2025-08-03 22:45:38 +00:00
use std::{collections::BTreeMap, net::SocketAddr, path::PathBuf, time::Duration};
2025-07-04 22:15:52 +00:00
use axum::{
Json,
2025-07-04 22:15:52 +00:00
body::Bytes,
extract::{ConnectInfo, Query, State},
http::StatusCode,
response::IntoResponse,
2025-07-04 00:00:11 +00:00
};
use julid::Julid;
2025-08-01 16:16:05 +00:00
use log::{debug, error, info, warn};
2025-08-14 23:25:32 +00:00
use reqwest::Client;
2025-07-04 00:00:11 +00:00
use serde::{Deserialize, Serialize};
2025-08-14 04:05:30 +00:00
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
2025-07-04 00:00:11 +00:00
2025-07-04 22:15:52 +00:00
use crate::{
2025-08-14 23:25:32 +00:00
JocalEvent, JocalService, Peers, ReceiveDialog, ReceiveRequest, SendingType, Sessions,
2025-07-04 22:15:52 +00:00
error::{LocalSendError, Result},
2025-07-06 23:02:11 +00:00
models::{Device, FileMetadata},
2025-07-04 22:15:52 +00:00
};
2025-07-06 21:15:08 +00:00
#[derive(Deserialize, Serialize)]
pub struct Session {
pub session_id: String,
pub files: BTreeMap<String, FileMetadata>,
pub file_tokens: BTreeMap<String, String>,
2025-07-06 23:02:11 +00:00
pub receiver: Device,
pub sender: Device,
2025-07-06 21:15:08 +00:00
pub status: SessionStatus,
pub addr: SocketAddr,
}
#[derive(PartialEq, Deserialize, Serialize)]
pub enum SessionStatus {
Pending,
Active,
Completed,
Failed,
Cancelled,
}
2025-07-04 00:00:11 +00:00
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUploadResponse {
pub session_id: String,
pub files: BTreeMap<String, String>,
2025-07-04 00:00:11 +00:00
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUploadRequest {
2025-07-06 23:02:11 +00:00
pub info: Device,
pub files: BTreeMap<String, FileMetadata>,
2025-07-04 00:00:11 +00:00
}
2025-08-06 21:09:37 +00:00
impl JocalService {
2025-08-03 23:43:25 +00:00
pub async fn send_file(&self, peer: &str, file_path: PathBuf) -> Result<()> {
2025-08-14 23:25:32 +00:00
let content = SendingType::File(file_path);
self.send_content(peer, content).await
2025-07-04 00:00:11 +00:00
}
2025-08-04 17:52:58 +00:00
pub async fn send_text(&self, peer: &str, text: &str) -> Result<()> {
2025-08-14 23:25:32 +00:00
let content = SendingType::Text(text.to_owned());
self.send_content(peer, content).await
2025-08-04 17:52:58 +00:00
}
2025-08-05 00:20:28 +00:00
pub async fn cancel_upload(&self, session_id: &str) -> Result<()> {
2025-07-04 00:00:11 +00:00
let sessions = self.sessions.lock().await;
2025-08-05 00:20:28 +00:00
let session = sessions
.get(session_id)
.ok_or(LocalSendError::SessionNotFound)?;
2025-07-04 00:00:11 +00:00
let request = self
.client
2025-07-05 17:12:09 +00:00
.post(format!(
2025-08-05 00:20:28 +00:00
"{}://{}/api/localsend/v2/cancel?sessionId={session_id}",
session.receiver.protocol, session.addr
2025-07-04 00:00:11 +00:00
))
.send()
.await?;
if request.status() != 200 {
return Err(LocalSendError::CancelFailed);
}
Ok(())
}
2025-08-05 00:20:28 +00:00
2025-08-14 23:25:32 +00:00
// spawns a tokio task to wait for responses
async fn send_content(&self, peer: &str, content: SendingType) -> Result<()> {
let (metadata, bytes) = match content {
SendingType::File(path) => {
let contents = tokio::fs::read(&path).await?;
let bytes = Bytes::from(contents);
(FileMetadata::from_path(&path)?, bytes)
}
SendingType::Text(text) => (FileMetadata::from_text(&text)?, Bytes::from(text)),
};
2025-08-05 00:20:28 +00:00
2025-08-14 23:25:32 +00:00
let mut files = BTreeMap::new();
files.insert(metadata.id.clone(), metadata.clone());
let ourself = self.config.device.clone();
let client = self.client.clone();
let tx = self.transfer_event_tx.clone();
let peer = peer.to_string();
let sessions = self.sessions.clone();
let peers = self.peers.clone();
tokio::task::spawn(async move {
fn send_tx(msg: JocalEvent, tx: &UnboundedSender<JocalEvent>) {
if let Err(e) = tx.send(msg.clone()) {
log::error!("got error sending {msg:?} to frontend: {e:?}");
}
}
2025-08-05 00:20:28 +00:00
2025-08-14 23:25:32 +00:00
let prepare_response =
do_prepare_upload(ourself, &client, &peer, &peers, &sessions, files).await;
let prepare_response = match prepare_response {
Ok(r) => r,
Err(e) => {
log::debug!("got error from remote receiver: {e:?}");
send_tx(JocalEvent::SendDenied, &tx);
return;
}
};
send_tx(JocalEvent::SendApproved(metadata.id.clone()), &tx);
let token = match prepare_response.files.get(&metadata.id) {
Some(t) => t,
None => {
log::warn!("");
send_tx(
JocalEvent::SendFailed {
error: "missing token in prepare response from remote".into(),
},
&tx,
);
return;
}
};
let content_id = &metadata.id;
let session_id = prepare_response.session_id;
let resp = do_send_bytes(sessions, client, &session_id, content_id, token, bytes).await;
match resp {
Ok(_) => {
send_tx(
JocalEvent::SendSuccess {
content: content_id.to_owned(),
session: session_id,
},
&tx,
);
}
Err(e) => {
send_tx(
JocalEvent::SendFailed {
error: format!("{e:?}"),
},
&tx,
);
}
}
});
2025-08-05 00:20:28 +00:00
Ok(())
}
2025-07-04 00:00:11 +00:00
}
pub async fn handle_prepare_upload(
2025-08-06 21:09:37 +00:00
State(service): State<JocalService>,
2025-07-04 00:00:11 +00:00
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<PrepareUploadRequest>,
) -> impl IntoResponse {
info!(
"Received upload request from {} at {addr:?}",
req.info.alias
);
2025-07-04 00:00:11 +00:00
let id = Julid::new();
let (tx, mut rx) = unbounded_channel();
let request = ReceiveRequest {
alias: req.info.alias.clone(),
2025-08-01 16:16:05 +00:00
files: req.files.clone(),
tx,
};
2025-08-03 05:34:35 +00:00
match service
2025-08-01 16:16:05 +00:00
.transfer_event_tx
2025-08-14 00:48:55 +00:00
.send(JocalEvent::ReceiveRequest { id, request })
2025-08-01 16:16:05 +00:00
{
Ok(_) => {}
2025-08-01 16:16:05 +00:00
Err(e) => {
error!("error sending transfer event to app: {e:?}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
2025-08-03 19:16:38 +00:00
let Some(confirmation) = rx.recv().await else {
2025-07-28 20:46:50 +00:00
// the frontend must have dropped the tx before trying to send a reply back
2025-08-03 19:16:38 +00:00
warn!("could not read content receive response from the frontend");
2025-07-28 20:46:50 +00:00
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
2025-07-04 00:00:11 +00:00
if confirmation != ReceiveDialog::Approve {
2025-07-28 20:46:50 +00:00
return StatusCode::FORBIDDEN.into_response();
}
2025-07-04 00:00:11 +00:00
2025-07-28 20:46:50 +00:00
let session_id = id.as_string();
2025-07-04 00:00:11 +00:00
let file_tokens: BTreeMap<String, String> = req
2025-07-28 20:46:50 +00:00
.files
.keys()
.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(),
2025-08-08 20:07:37 +00:00
receiver: service.config.device.clone(),
2025-07-28 20:46:50 +00:00
sender: req.info.clone(),
status: SessionStatus::Active,
addr,
};
2025-08-03 05:34:35 +00:00
service
2025-07-28 20:46:50 +00:00
.sessions
.lock()
.await
.insert(session_id.clone(), session);
(
StatusCode::OK,
Json(PrepareUploadResponse {
session_id,
files: file_tokens,
}),
)
.into_response()
2025-07-04 00:00:11 +00:00
}
pub async fn handle_receive_upload(
2025-07-04 00:00:11 +00:00
Query(params): Query<UploadParams>,
2025-08-06 21:09:37 +00:00
State(service): State<JocalService>,
2025-07-04 00:00:11 +00:00
body: Bytes,
) -> impl IntoResponse {
// Extract query parameters
let session_id = &params.session_id;
let file_id = &params.file_id;
let token = &params.token;
// Get session and validate
2025-08-03 05:34:35 +00:00
let mut sessions_lock = service.sessions.lock().await;
2025-07-04 00:00:11 +00:00
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(),
)
2025-07-04 22:15:52 +00:00
.into_response();
2025-07-04 00:00:11 +00:00
}
};
let download_dir = &service.config.download_dir;
2025-07-04 00:00:11 +00:00
// Create directory if it doesn't exist
if let Err(e) = tokio::fs::create_dir_all(download_dir).await {
2025-07-04 00:00:11 +00:00
return (
StatusCode::INTERNAL_SERVER_ERROR,
2025-07-05 17:12:09 +00:00
format!("Failed to create directory: {e}"),
2025-07-04 00:00:11 +00:00
)
.into_response();
}
// Create file path
let file_path = service.config.download_dir.join(&file_metadata.file_name);
2025-07-04 00:00:11 +00:00
// Write file
if let Err(e) = tokio::fs::write(&file_path, body).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
2025-07-05 17:12:09 +00:00
format!("Failed to write file: {e}"),
2025-07-04 00:00:11 +00:00
)
.into_response();
}
2025-08-01 23:59:31 +00:00
if let Ok(id) = Julid::from_str(session_id) {
2025-08-14 00:48:55 +00:00
service.send_event(JocalEvent::ReceivedInbound(id));
};
2025-07-04 00:00:11 +00:00
StatusCode::OK.into_response()
}
// Query parameters struct
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadParams {
session_id: String,
file_id: String,
token: String,
}
2025-08-08 20:56:50 +00:00
pub async fn handle_cancel(
2025-07-04 00:00:11 +00:00
Query(params): Query<CancelParams>,
2025-08-06 21:09:37 +00:00
State(service): State<JocalService>,
2025-07-04 00:00:11 +00:00
) -> impl IntoResponse {
2025-08-03 05:34:35 +00:00
let mut sessions_lock = service.sessions.lock().await;
2025-07-04 00:00:11 +00:00
let session = match sessions_lock.get_mut(&params.session_id) {
Some(session) => session,
None => return StatusCode::BAD_REQUEST.into_response(),
};
2025-08-01 23:33:00 +00:00
debug!("got cancel request for {}", params.session_id);
2025-07-04 00:00:11 +00:00
session.status = SessionStatus::Cancelled;
2025-08-01 23:59:31 +00:00
if let Ok(id) = Julid::from_str(&params.session_id) {
2025-08-14 23:25:32 +00:00
service.send_event(JocalEvent::Cancelled { session_id: id });
2025-08-01 23:33:00 +00:00
};
2025-07-04 00:00:11 +00:00
StatusCode::OK.into_response()
}
// Cancel parameters struct
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelParams {
session_id: String,
}
2025-08-14 04:05:30 +00:00
2025-08-14 23:25:32 +00:00
// free function that can be called inside a future in tokio::task::spawn()
async fn do_send_bytes(
sessions: Sessions,
client: Client,
session_id: &str,
content_id: &str,
token: &String,
body: Bytes,
) -> Result<()> {
let sessions = sessions.lock().await;
let session = sessions.get(session_id).unwrap();
if session.status != SessionStatus::Active {
return Err(LocalSendError::SessionInactive);
}
if session.file_tokens.get(content_id) != Some(token) {
return Err(LocalSendError::InvalidToken);
}
let request = client
.post(format!(
"{}://{}/api/localsend/v2/upload?sessionId={session_id}&fileId={content_id}&token={token}",
session.receiver.protocol, session.addr))
.body(body);
debug!("Uploading bytes: {request:?}");
let response = request.send().await?;
if response.status() != 200 {
log::trace!("non-200 remote response: {response:?}");
return Err(LocalSendError::UploadFailed);
}
Ok(())
}
// free function that can be called inside a future in tokio::task::spawn()
2025-08-14 04:05:30 +00:00
async fn do_prepare_upload(
ourself: Device,
2025-08-14 23:25:32 +00:00
client: &reqwest::Client,
2025-08-14 04:05:30 +00:00
peer: &str,
2025-08-14 23:25:32 +00:00
peers: &Peers,
sessions: &Sessions,
2025-08-14 04:05:30 +00:00
files: BTreeMap<String, FileMetadata>,
) -> Result<PrepareUploadResponse> {
let Some((addr, device)) = peers.lock().await.get(peer).cloned() else {
return Err(LocalSendError::PeerNotFound);
};
log::debug!("preparing upload request");
let request = client
.post(format!(
"{}://{}/api/localsend/v2/prepare-upload",
device.protocol, addr
))
.json(&PrepareUploadRequest {
info: ourself.clone(),
files: files.clone(),
})
.timeout(Duration::from_secs(30));
debug!("sending '{request:?}' to peer at {addr:?}");
// tokio::spawn(future);
let response = request.send().await?;
debug!("Response: {response:?}");
let response: PrepareUploadResponse = match response.json().await {
Err(e) => {
error!("got error deserializing response: {e:?}");
return Err(LocalSendError::RequestError(e));
}
Ok(r) => r,
};
debug!("decoded response: {response:?}");
let session = Session {
session_id: response.session_id.clone(),
files,
file_tokens: response.files.clone(),
receiver: device,
sender: ourself.clone(),
status: SessionStatus::Active,
addr,
};
sessions
.lock()
.await
.insert(response.session_id.clone(), session);
Ok(response)
}