From d4c4c62109e321f3b715691873308e7fc028d54f Mon Sep 17 00:00:00 2001 From: shumin Date: Tue, 28 Apr 2026 21:44:34 +0800 Subject: [PATCH] feat: add file transfer and file management --- crates/sshx-core/proto/sshx.proto | 69 ++++ crates/sshx-server/src/grpc.rs | 27 ++ crates/sshx-server/src/session.rs | 38 +- crates/sshx-server/src/web.rs | 10 +- crates/sshx-server/src/web/files.rs | 140 +++++++ crates/sshx-server/src/web/protocol.rs | 26 ++ crates/sshx-server/src/web/socket.rs | 35 +- crates/sshx-server/tests/common/mod.rs | 3 + crates/sshx-server/tests/file_transfer.rs | 75 ++++ crates/sshx/src/controller.rs | 75 ++++ crates/sshx/src/files.rs | 208 ++++++++++ crates/sshx/src/lib.rs | 1 + src/lib/Session.svelte | 62 +++ src/lib/protocol.ts | 14 + src/lib/ui/FileManager.svelte | 479 ++++++++++++++++++++++ src/lib/ui/FileTree.svelte | 110 +++++ src/lib/ui/Toolbar.svelte | 5 + 17 files changed, 1373 insertions(+), 4 deletions(-) create mode 100644 crates/sshx-server/src/web/files.rs create mode 100644 crates/sshx-server/tests/file_transfer.rs create mode 100644 crates/sshx/src/files.rs create mode 100644 src/lib/ui/FileManager.svelte create mode 100644 src/lib/ui/FileTree.svelte diff --git a/crates/sshx-core/proto/sshx.proto b/crates/sshx-core/proto/sshx.proto index 111d0dda..aff04b0e 100644 --- a/crates/sshx-core/proto/sshx.proto +++ b/crates/sshx-core/proto/sshx.proto @@ -72,6 +72,10 @@ message ClientUpdate { uint32 closed_shell = 4; // Acknowledge that a shell was closed. fixed64 pong = 14; // Response for latency measurement. string error = 15; + FileListResponse file_list = 16; + FileChunkResponse file_chunk = 17; + FileDeletedResponse file_deleted = 18; + FileRenamedResponse file_renamed = 19; } } @@ -85,6 +89,11 @@ message ServerUpdate { TerminalSize resize = 5; // Resize a terminal window. fixed64 ping = 14; // Request a pong, with the timestamp. string error = 15; + FileListRequest list_files = 16; + FileDownloadRequest download_file = 17; + FileUploadRequest upload_file = 18; + FileDeleteRequest delete_file = 19; + FileRenameRequest rename_file = 20; } } @@ -118,3 +127,63 @@ message SerializedShell { uint32 winsize_rows = 8; uint32 winsize_cols = 9; } + +message FileEntry { + string name = 1; + bool is_dir = 2; + uint64 size = 3; + int64 modified = 4; +} + +message FileListRequest { + uint32 id = 1; + string path = 2; +} + +message FileDownloadRequest { + uint32 id = 1; + string path = 2; +} + +message FileUploadRequest { + uint32 id = 1; + string path = 2; + bytes data = 3; + bool done = 4; +} + +message FileDeleteRequest { + uint32 id = 1; + string path = 2; +} + +message FileRenameRequest { + uint32 id = 1; + string path = 2; + string new_name = 3; +} + +message FileListResponse { + uint32 id = 1; + repeated FileEntry entries = 2; + string path = 3; +} + +message FileChunkResponse { + uint32 id = 1; + bytes data = 2; + bool done = 3; + uint64 size = 4; +} + +message FileDeletedResponse { + uint32 id = 1; + string error = 2; + string path = 3; +} + +message FileRenamedResponse { + uint32 id = 1; + string new_path = 2; + string error = 3; +} diff --git a/crates/sshx-server/src/grpc.rs b/crates/sshx-server/src/grpc.rs index d68cb61b..aa5170ef 100644 --- a/crates/sshx-server/src/grpc.rs +++ b/crates/sshx-server/src/grpc.rs @@ -17,6 +17,7 @@ use tonic::{Request, Response, Status, Streaming}; use tracing::{error, info, warn}; use crate::session::{Metadata, Session}; +use crate::web::protocol::WsFileEntry; use crate::ServerState; /// Interval for synchronizing sequence numbers with the client. @@ -218,6 +219,32 @@ async fn handle_update(tx: &ServerTx, session: &Session, update: ClientUpdate) - // TODO: Propagate these errors to listeners on the web interface? error!(?err, "error received from client"); } + Some(ClientMessage::FileList(resp)) => { + let entries: Vec = resp.entries.into_iter().map(|e| WsFileEntry { + name: e.name, + is_dir: e.is_dir, + size: e.size, + modified: e.modified, + }).collect(); + session.send_file_list(resp.path, entries); + } + Some(ClientMessage::FileChunk(resp)) => { + session.forward_chunk(resp.id, resp.data, resp.done); + } + Some(ClientMessage::FileDeleted(resp)) => { + if resp.error.is_empty() { + session.send_file_changed(resp.path, "deleted".into()); + } else { + session.send_file_error(resp.path, resp.error); + } + } + Some(ClientMessage::FileRenamed(resp)) => { + if resp.error.is_empty() { + session.send_file_changed(resp.new_path, "renamed".into()); + } else { + session.send_file_error(resp.new_path, resp.error); + } + } None => (), // Heartbeat message, ignored. } true diff --git a/crates/sshx-server/src/session.rs b/crates/sshx-server/src/session.rs index b0d0eefc..49e31430 100644 --- a/crates/sshx-server/src/session.rs +++ b/crates/sshx-server/src/session.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use anyhow::{bail, Context, Result}; use bytes::Bytes; use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; +use tokio::sync::mpsc; use sshx_core::{ proto::{server_update::ServerMessage, SequenceNumbers}, IdCounter, Sid, Uid, @@ -18,7 +19,7 @@ use tokio_stream::Stream; use tracing::{debug, warn}; use crate::utils::Shutdown; -use crate::web::protocol::{WsServer, WsUser, WsWinsize}; +use crate::web::protocol::{WsFileEntry, WsServer, WsUser, WsWinsize}; mod snapshot; @@ -77,6 +78,9 @@ pub struct Session { /// Set when this session has been closed and removed. shutdown: Shutdown, + + /// Pending download channels keyed by file operation ID. + pending_downloads: Mutex>>, } /// Internal state for each shell. @@ -118,6 +122,7 @@ impl Session { update_rx, sync_notify: Notify::new(), shutdown: Shutdown::new(), + pending_downloads: Mutex::new(HashMap::new()), } } @@ -373,6 +378,37 @@ impl Session { self.broadcast.send(WsServer::ShellLatency(latency)).ok(); } + /// Send a file listing to all WebSocket clients. + pub fn send_file_list(&self, path: String, entries: Vec) { + self.broadcast.send(WsServer::FileList(path, entries)).ok(); + } + + /// Send a file change notification to all WebSocket clients. + pub fn send_file_changed(&self, path: String, kind: String) { + self.broadcast.send(WsServer::FileChanged(path, kind)).ok(); + } + + /// Send a file operation error to all WebSocket clients. + pub fn send_file_error(&self, path: String, error: String) { + self.broadcast.send(WsServer::FileError(path, error)).ok(); + } + + /// Register a channel to receive download chunks for a file operation. + pub fn register_download(&self, id: u32, tx: mpsc::Sender) { + self.pending_downloads.lock().insert(id, tx); + } + + /// Forward a file chunk to the registered download channel. + pub fn forward_chunk(&self, id: u32, data: Bytes, done: bool) { + let mut pending = self.pending_downloads.lock(); + if let Some(tx) = pending.get(&id) { + let removed = tx.try_send(data).is_err() || done; + if removed { + pending.remove(&id); + } + } + } + /// Register a backend client heartbeat, refreshing the timestamp. pub fn access(&self) { *self.last_accessed.lock() = Instant::now(); diff --git a/crates/sshx-server/src/web.rs b/crates/sshx-server/src/web.rs index d744393d..d63a2860 100644 --- a/crates/sshx-server/src/web.rs +++ b/crates/sshx-server/src/web.rs @@ -2,12 +2,13 @@ use std::sync::Arc; -use axum::routing::{any, get_service}; +use axum::routing::{any, get, get_service}; use axum::Router; use tower_http::services::{ServeDir, ServeFile}; use crate::ServerState; +pub mod files; pub mod protocol; mod socket; @@ -30,5 +31,10 @@ pub fn app() -> Router> { /// Routes for the backend web API server. fn backend() -> Router> { - Router::new().route("/s/{name}", any(socket::get_session_ws)) + Router::new() + .route("/s/{name}", any(socket::get_session_ws)) + .route( + "/s/{name}/files", + get(files::download_file).post(files::upload_file), + ) } diff --git a/crates/sshx-server/src/web/files.rs b/crates/sshx-server/src/web/files.rs new file mode 100644 index 00000000..ecea015a --- /dev/null +++ b/crates/sshx-server/src/web/files.rs @@ -0,0 +1,140 @@ +//! HTTP file transfer endpoints for downloading and uploading files +//! through a session's CLI connection. + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use base64::prelude::{Engine as _, BASE64_STANDARD}; +use bytes::Bytes; +use serde::Deserialize; +use subtle::ConstantTimeEq; + +use sshx_core::proto::server_update::ServerMessage; +use sshx_core::proto::{FileDownloadRequest, FileUploadRequest}; + +use crate::ServerState; + +/// Query parameters for file transfer endpoints. +#[derive(Deserialize)] +pub struct FileParams { + path: String, +} + +/// Validates the session authentication token from the `X-SSHX-Key` header. +async fn validate_auth( + state: &ServerState, + name: &str, + headers: &HeaderMap, +) -> Result, StatusCode> { + let session = state.lookup(name).ok_or(StatusCode::NOT_FOUND)?; + + let key = headers + .get("X-SSHX-Key") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let key_bytes = BASE64_STANDARD + .decode(key) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + if !bool::from(session.metadata().encrypted_zeros.ct_eq(&key_bytes)) { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(session) +} + +/// HTTP GET handler that streams a file from the CLI via gRPC. +pub async fn download_file( + State(state): State>, + Path(name): Path, + Query(params): Query, + headers: HeaderMap, +) -> Response { + let session = match validate_auth(&state, &name, &headers).await { + Ok(s) => s, + Err(code) => return (code, "Unauthorized").into_response(), + }; + + if params.path.is_empty() { + return (StatusCode::BAD_REQUEST, "path is required").into_response(); + } + + let file_id = session.counter().next_sid().0; + let filename = std::path::Path::new(¶ms.path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("download"); + + let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::(32); + session.register_download(file_id, chunk_tx); + let update_tx = session.update_tx().clone(); + let path = params.path.clone(); + tokio::spawn(async move { + let msg = ServerMessage::DownloadFile(FileDownloadRequest { id: file_id, path }); + let _ = update_tx.send(msg).await; + }); + + let stream = async_stream::stream! { + let mut rx = chunk_rx; + while let Some(chunk) = rx.recv().await { + if chunk.is_empty() { + break; + } + yield Ok::(chunk); + } + }; + + Response::builder() + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ) + .body(Body::from_stream(stream)) + .unwrap() +} + +/// HTTP POST handler that uploads a file to the CLI via gRPC. +pub async fn upload_file( + State(state): State>, + Path(name): Path, + Query(params): Query, + headers: HeaderMap, + body: Bytes, +) -> Response { + let session = match validate_auth(&state, &name, &headers).await { + Ok(s) => s, + Err(code) => return (code, "Unauthorized").into_response(), + }; + + if params.path.is_empty() { + return (StatusCode::BAD_REQUEST, "path is required").into_response(); + } + + let file_id = session.counter().next_sid().0; + let update_tx = session.update_tx().clone(); + const CHUNK_SIZE: usize = 1 << 16; // 64 KiB + let path = params.path.clone(); + + tokio::spawn(async move { + let total = body.len(); + for (i, chunk) in body.chunks(CHUNK_SIZE).enumerate() { + let is_last = i * CHUNK_SIZE + chunk.len() >= total; + let msg = ServerMessage::UploadFile(FileUploadRequest { + id: file_id, + path: path.clone(), + data: chunk.to_vec().into(), + done: is_last, + }); + if update_tx.send(msg).await.is_err() { + break; + } + } + }); + + StatusCode::OK.into_response() +} diff --git a/crates/sshx-server/src/web/protocol.rs b/crates/sshx-server/src/web/protocol.rs index bb5c48a8..1243a288 100644 --- a/crates/sshx-server/src/web/protocol.rs +++ b/crates/sshx-server/src/web/protocol.rs @@ -29,6 +29,20 @@ impl Default for WsWinsize { } } +/// Information about a file entry for directory listing. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WsFileEntry { + /// The name of the file or directory. + pub name: String, + /// Whether this entry is a directory. + pub is_dir: bool, + /// Size of the file in bytes (0 for directories). + pub size: u64, + /// Last modification timestamp (Unix epoch in seconds). + pub modified: i64, +} + /// Real-time message providing information about a user. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -65,6 +79,12 @@ pub enum WsServer { ShellLatency(u64), /// Echo back a timestamp, for the the client's own latency measurement. Pong(u64), + /// Directory listing result. + FileList(String, Vec), + /// Notification of a file change (path, kind). + FileChanged(String, String), + /// File operation error (path, error). + FileError(String, String), /// Alert the client of an application error. Error(String), } @@ -94,6 +114,12 @@ pub enum WsClient { Subscribe(Sid, u64), /// Send a a chat message to the room. Chat(String), + /// Request directory listing. + ListFiles(String), + /// Delete a file or directory. + DeleteFile(String), + /// Rename a file or directory. + RenameFile(String, String), /// Send a ping to the server, for latency measurement. Ping(u64), } diff --git a/crates/sshx-server/src/web/socket.rs b/crates/sshx-server/src/web/socket.rs index 85a43d62..217e90d8 100644 --- a/crates/sshx-server/src/web/socket.rs +++ b/crates/sshx-server/src/web/socket.rs @@ -9,7 +9,10 @@ use axum::extract::{ use axum::response::IntoResponse; use bytes::Bytes; use futures_util::SinkExt; -use sshx_core::proto::{server_update::ServerMessage, NewShell, TerminalInput, TerminalSize}; +use sshx_core::proto::{ + server_update::ServerMessage, FileDeleteRequest, FileListRequest, FileRenameRequest, NewShell, + TerminalInput, TerminalSize, +}; use sshx_core::Sid; use subtle::ConstantTimeEq; use tokio::sync::mpsc; @@ -243,6 +246,36 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc) -> Result< WsClient::Chat(msg) => { session.send_chat(user_id, &msg)?; } + WsClient::ListFiles(path) => { + let id = session.counter().next_sid().0; + update_tx + .send(ServerMessage::ListFiles(FileListRequest { id, path })) + .await?; + } + WsClient::DeleteFile(path) => { + if let Err(e) = session.check_write_permission(user_id) { + send(socket, WsServer::Error(e.to_string())).await?; + continue; + } + let id = session.counter().next_sid().0; + update_tx + .send(ServerMessage::DeleteFile(FileDeleteRequest { id, path })) + .await?; + } + WsClient::RenameFile(path, new_name) => { + if let Err(e) = session.check_write_permission(user_id) { + send(socket, WsServer::Error(e.to_string())).await?; + continue; + } + let id = session.counter().next_sid().0; + update_tx + .send(ServerMessage::RenameFile(FileRenameRequest { + id, + path, + new_name, + })) + .await?; + } WsClient::Ping(ts) => { send(socket, WsServer::Pong(ts)).await?; } diff --git a/crates/sshx-server/tests/common/mod.rs b/crates/sshx-server/tests/common/mod.rs index f86b06c7..c0347c61 100644 --- a/crates/sshx-server/tests/common/mod.rs +++ b/crates/sshx-server/tests/common/mod.rs @@ -190,6 +190,9 @@ impl ClientSocket { WsServer::ShellLatency(_) => {} WsServer::Pong(_) => {} WsServer::Error(err) => self.errors.push(err), + WsServer::FileList(_, _) => {} + WsServer::FileChanged(_, _) => {} + WsServer::FileError(_, _) => {} } } }; diff --git a/crates/sshx-server/tests/file_transfer.rs b/crates/sshx-server/tests/file_transfer.rs new file mode 100644 index 00000000..07f5e235 --- /dev/null +++ b/crates/sshx-server/tests/file_transfer.rs @@ -0,0 +1,75 @@ +use anyhow::Result; +use sshx::encrypt::Encrypt; +use sshx_core::proto::*; +use sshx_server::web::protocol::WsClient; + +use crate::common::*; + +pub mod common; + +#[tokio::test] +async fn test_list_files_rpc() -> Result<()> { + let server = TestServer::new().await; + let mut client = server.grpc_client().await; + + let req = OpenRequest { + origin: "test".into(), + encrypted_zeros: Encrypt::new("test").zeros().into(), + name: "test-list".into(), + write_password_hash: None, + }; + let resp = client.open(req).await?; + let session_name = resp.into_inner().name; + + let mut ws = ClientSocket::connect( + &server.ws_endpoint(&session_name), + "test", + None, + ) + .await?; + ws.flush().await; + assert!(ws.user_id.0 > 0, "should receive user ID"); + + // Send ListFiles to verify message routing doesn't crash + ws.send(WsClient::ListFiles("/".into())).await; + ws.flush().await; + assert!(ws.errors.is_empty(), "list files should not cause errors"); + + Ok(()) +} + +#[tokio::test] +async fn test_file_operations_permission_check() -> Result<()> { + let server = TestServer::new().await; + let mut client = server.grpc_client().await; + + // Create a session with write password protection + let req = OpenRequest { + origin: "test".into(), + encrypted_zeros: Encrypt::new("test").zeros().into(), + name: "test-readonly".into(), + write_password_hash: Some(Encrypt::new("writepass").zeros().into()), + }; + let resp = client.open(req).await?; + let session_name = resp.into_inner().name; + + // Connect without write password (read-only) + let mut ws = ClientSocket::connect( + &server.ws_endpoint(&session_name), + "test", + None, + ) + .await?; + ws.flush().await; + assert!(ws.user_id.0 > 0, "should receive user ID"); + + // DeleteFile should be rejected for read-only user + ws.send(WsClient::DeleteFile("/test.txt".into())).await; + ws.flush().await; + assert!( + !ws.errors.is_empty(), + "should reject delete for read-only user" + ); + + Ok(()) +} diff --git a/crates/sshx/src/controller.rs b/crates/sshx/src/controller.rs index 5eff7674..77d37609 100644 --- a/crates/sshx/src/controller.rs +++ b/crates/sshx/src/controller.rs @@ -241,6 +241,81 @@ impl Controller { ServerMessage::Error(err) => { error!(?err, "error received from server"); } + ServerMessage::ListFiles(req) => { + let sender = self.output_tx.clone(); + let root = std::env::current_dir().unwrap_or_default(); + tokio::spawn(async move { + if let Err(err) = crate::files::handle_file_operation( + ServerMessage::ListFiles(req), + &root, + &sender, + ) + .await + { + error!(?err, "list files failed"); + } + }); + } + ServerMessage::DownloadFile(req) => { + let sender = self.output_tx.clone(); + let root = std::env::current_dir().unwrap_or_default(); + tokio::spawn(async move { + if let Err(err) = crate::files::handle_file_operation( + ServerMessage::DownloadFile(req), + &root, + &sender, + ) + .await + { + error!(?err, "download file failed"); + } + }); + } + ServerMessage::UploadFile(req) => { + let sender = self.output_tx.clone(); + let root = std::env::current_dir().unwrap_or_default(); + tokio::spawn(async move { + if let Err(err) = crate::files::handle_file_operation( + ServerMessage::UploadFile(req), + &root, + &sender, + ) + .await + { + error!(?err, "upload file failed"); + } + }); + } + ServerMessage::DeleteFile(req) => { + let sender = self.output_tx.clone(); + let root = std::env::current_dir().unwrap_or_default(); + tokio::spawn(async move { + if let Err(err) = crate::files::handle_file_operation( + ServerMessage::DeleteFile(req), + &root, + &sender, + ) + .await + { + error!(?err, "delete file failed"); + } + }); + } + ServerMessage::RenameFile(req) => { + let sender = self.output_tx.clone(); + let root = std::env::current_dir().unwrap_or_default(); + tokio::spawn(async move { + if let Err(err) = crate::files::handle_file_operation( + ServerMessage::RenameFile(req), + &root, + &sender, + ) + .await + { + error!(?err, "rename file failed"); + } + }); + } } } } diff --git a/crates/sshx/src/files.rs b/crates/sshx/src/files.rs new file mode 100644 index 00000000..59cdee85 --- /dev/null +++ b/crates/sshx/src/files.rs @@ -0,0 +1,208 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use sshx_core::proto::{ + client_update::ClientMessage, server_update::ServerMessage, FileChunkResponse, + FileDeleteRequest, FileDeletedResponse, FileDownloadRequest, FileEntry, FileListRequest, + FileListResponse, FileRenameRequest, FileRenamedResponse, FileUploadRequest, +}; +use tokio::sync::mpsc; + +const CHUNK_SIZE: usize = 1 << 16; // 64 KiB + +pub async fn handle_file_operation( + msg: ServerMessage, + root_dir: &Path, + sender: &mpsc::Sender, +) -> Result<()> { + match msg { + ServerMessage::ListFiles(req) => list_files(req, root_dir, sender).await, + ServerMessage::DownloadFile(req) => download_file(req, root_dir, sender).await, + ServerMessage::UploadFile(req) => upload_file(req, root_dir, sender).await, + ServerMessage::DeleteFile(req) => delete_file(req, root_dir, sender).await, + ServerMessage::RenameFile(req) => rename_file(req, root_dir, sender).await, + _ => bail!("unexpected message type for file operation"), + } +} + +fn resolve_path(root_dir: &Path, path: &str) -> Result { + let candidate = root_dir.join(path.trim_start_matches('/')); + let root = root_dir.canonicalize().context("failed to resolve root")?; + + // Try full canonicalize; if the path doesn't exist, validate via parent + let canonical = match candidate.canonicalize() { + Ok(c) => c, + Err(_) if !candidate.exists() => { + let parent = candidate + .parent() + .context("path has no parent")? + .canonicalize() + .context("failed to resolve parent")?; + let filename = candidate.file_name().context("path has no filename")?; + parent.join(filename) + } + Err(_) => bail!("cannot access path: {}", path), + }; + + if !canonical.starts_with(&root) { + bail!("path traversal attempt: {}", path); + } + Ok(canonical) +} + +async fn list_files( + req: FileListRequest, + root_dir: &Path, + sender: &mpsc::Sender, +) -> Result<()> { + let dir = if req.path.is_empty() || req.path == "/" { + root_dir.to_path_buf() + } else { + resolve_path(root_dir, &req.path)? + }; + let mut entries = Vec::new(); + let mut read_dir = tokio::fs::read_dir(&dir).await?; + while let Some(entry) = read_dir.next_entry().await? { + let metadata = entry.metadata().await?; + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + entries.push(FileEntry { + name, + is_dir: metadata.is_dir(), + size: metadata.len(), + modified: metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0), + }); + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + let _ = sender + .send(ClientMessage::FileList(FileListResponse { + id: req.id, + entries, + path: req.path, + })) + .await; + Ok(()) +} + +async fn download_file( + req: FileDownloadRequest, + root_dir: &Path, + sender: &mpsc::Sender, +) -> Result<()> { + let path = resolve_path(root_dir, &req.path)?; + if !path.is_file() { + bail!("not a file: {}", req.path); + } + let size = path.metadata()?.len(); + let mut file = tokio::fs::File::open(&path).await?; + let mut buf = vec![0u8; CHUNK_SIZE]; + use tokio::io::AsyncReadExt; + loop { + let n = file.read(&mut buf).await?; + let done = n == 0; + let data = if done { vec![] } else { buf[..n].to_vec() }; + if sender + .send(ClientMessage::FileChunk(FileChunkResponse { + id: req.id, + data: data.into(), + done, + size, + })) + .await + .is_err() + { + break; + } + if done { + break; + } + } + Ok(()) +} + +async fn upload_file( + req: FileUploadRequest, + root_dir: &Path, + sender: &mpsc::Sender, +) -> Result<()> { + let path = resolve_path(root_dir, &req.path)?; + if path.is_dir() { + bail!("cannot upload to a directory: {}", req.path); + } + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + use tokio::io::AsyncWriteExt; + let mut file = tokio::fs::File::create(&path).await?; + file.write_all(&req.data).await?; + if req.done { + file.flush().await?; + drop(file); + } + if req.done { + let _ = sender + .send(ClientMessage::FileChunk(FileChunkResponse { + id: req.id, + data: vec![].into(), + done: true, + size: 0, + })) + .await; + } + Ok(()) +} + +async fn delete_file( + req: FileDeleteRequest, + root_dir: &Path, + sender: &mpsc::Sender, +) -> Result<()> { + let path = resolve_path(root_dir, &req.path)?; + if path == root_dir.canonicalize()? { + bail!("cannot delete root directory"); + } + let result = if path.is_dir() { + tokio::fs::remove_dir_all(&path).await + } else { + tokio::fs::remove_file(&path).await + }; + let error = result.err().map(|e| e.to_string()).unwrap_or_default(); + let path = req.path.clone(); + let _ = sender + .send(ClientMessage::FileDeleted(FileDeletedResponse { + id: req.id, + error, + path, + })) + .await; + Ok(()) +} + +async fn rename_file( + req: FileRenameRequest, + root_dir: &Path, + sender: &mpsc::Sender, +) -> Result<()> { + let old_path = resolve_path(root_dir, &req.path)?; + let new_path = resolve_path(root_dir, &req.new_name)?; + let result = tokio::fs::rename(&old_path, &new_path).await; + let (np, error) = match result { + Ok(()) => (req.new_name.clone(), String::new()), + Err(e) => (String::new(), e.to_string()), + }; + let _ = sender + .send(ClientMessage::FileRenamed(FileRenamedResponse { + id: req.id, + new_path: np, + error, + })) + .await; + Ok(()) +} diff --git a/crates/sshx/src/lib.rs b/crates/sshx/src/lib.rs index 7a95b2cf..70bfea28 100644 --- a/crates/sshx/src/lib.rs +++ b/crates/sshx/src/lib.rs @@ -8,5 +8,6 @@ pub mod controller; pub mod encrypt; +pub mod files; pub mod runner; pub mod terminal; diff --git a/src/lib/Session.svelte b/src/lib/Session.svelte index ca155c9e..2c9a63a9 100644 --- a/src/lib/Session.svelte +++ b/src/lib/Session.svelte @@ -16,6 +16,8 @@ import type { WsClient, WsServer, WsUser, WsWinsize } from "./protocol"; import { makeToast } from "./toast"; import Chat, { type ChatMessage } from "./ui/Chat.svelte"; + import FileManager from "./ui/FileManager.svelte"; + import type { FileEntry } from "./protocol"; import ChooseName from "./ui/ChooseName.svelte"; import NameList from "./ui/NameList.svelte"; import NetworkInfo from "./ui/NetworkInfo.svelte"; @@ -63,6 +65,11 @@ let settingsOpen = false; // @hmr:keep let showNetworkInfo = false; // @hmr:keep + let showFileManager = false; // @hmr:keep + let fileEntries: FileEntry[] = []; + let fileEntriesMap: Record = { "/": [] }; + let currentFilePath = "/"; + onMount(() => { touchZoom = new TouchZoom(fabricEl); touchZoom.onMove(() => { @@ -95,6 +102,7 @@ let encrypt: Encrypt; let srocket: Srocket | null = null; + let encryptedZerosB64 = ""; let connected = false; let exitReason: string | null = null; @@ -136,6 +144,9 @@ encrypt = await Encrypt.new(key); const encryptedZeros = await encrypt.zeros(); + encryptedZerosB64 = btoa( + String.fromCharCode(...new Uint8Array(encryptedZeros)), + ); const writeEncryptedZeros = writePassword ? await (await Encrypt.new(writePassword)).zeros() @@ -202,6 +213,21 @@ } else if (message.pong !== undefined) { const serverLatency = Date.now() - Number(message.pong); serverLatencies = [...serverLatencies, serverLatency].slice(-10); + } else if (message.fileList) { + const [path, entries] = message.fileList; + fileEntriesMap[path] = entries; + fileEntriesMap = fileEntriesMap; // trigger Svelte reactivity + if (path === currentFilePath) { + fileEntries = entries; + } + } else if (message.fileChanged) { + const [_path, _kind] = message.fileChanged; + const parent = + _path.substring(0, _path.lastIndexOf("/")) || "/"; + srocket?.send({ listFiles: parent }); + } else if (message.fileError) { + const [_path, error] = message.fileError; + makeToast({ kind: "error", message: `File error: ${error}` }); } else if (message.error) { console.warn("Server error: " + message.error); } @@ -262,6 +288,22 @@ let counter = 0n; + function handleListFiles(path: string) { + currentFilePath = path; + if (fileEntriesMap[path]) { + fileEntries = fileEntriesMap[path]; + } + srocket?.send({ listFiles: path }); + } + + function handleDeleteFile(path: string) { + srocket?.send({ deleteFile: path }); + } + + function handleRenameFile(event: { path: string; newName: string }) { + srocket?.send({ renameFile: [event.path, event.newName] }); + } + async function handleCreate() { if (hasWriteAccess === false) { makeToast({ @@ -404,6 +446,13 @@ {newMessages} {hasWriteAccess} on:create={handleCreate} + on:files={() => { + showFileManager = !showFileManager; + if (showFileManager) { + showChat = false; + handleListFiles("/"); + } + }} on:chat={() => { showChat = !showChat; newMessages = false; @@ -444,6 +493,19 @@ {/if} + (showFileManager = false)} + on:listFiles={(e) => handleListFiles(e.detail)} + on:deleteFile={(e) => handleDeleteFile(e.detail)} + on:renameFile={(e) => handleRenameFile(e.detail)} + /> + (settingsOpen = false)} /> diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index 08d589d9..3be5ca23 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -17,6 +17,14 @@ export type WsUser = { canWrite: boolean; }; +/** File entry for directory listing. */ +export type FileEntry = { + name: string; + isDir: boolean; + size: number; + modified: number; // Unix timestamp in milliseconds +}; + /** Server message type, see the Rust version. */ export type WsServer = { hello?: [Uid, string]; @@ -28,6 +36,9 @@ export type WsServer = { hear?: [Uid, string, string]; shellLatency?: number | bigint; pong?: number | bigint; + fileList?: [string, FileEntry[]]; + fileChanged?: [string, string]; // [path, "created" | "deleted" | "renamed"] + fileError?: [string, string]; // [path, errorMessage] error?: string; }; @@ -43,5 +54,8 @@ export type WsClient = { data?: [Sid, Uint8Array, bigint]; subscribe?: [Sid, number]; chat?: string; + listFiles?: string; + deleteFile?: string; + renameFile?: [string, string]; ping?: bigint; }; diff --git a/src/lib/ui/FileManager.svelte b/src/lib/ui/FileManager.svelte new file mode 100644 index 00000000..7a3ed2f6 --- /dev/null +++ b/src/lib/ui/FileManager.svelte @@ -0,0 +1,479 @@ + + +{#if open} +
+ + +
+ Files + +
+ + + +
+ + or drop files here + +
+ +
+ {#if entries.length === 0} +

Empty directory

+ {:else} + (selectedPath = e.detail)} + on:toggle={(e) => handleToggle(e.detail)} + on:doubleClick={() => handleDownload()} + /> + {/if} +
+ + {#if dragOver} +
+ +

Drop files to upload

+
+ {/if} + + {#if selectedPath} +
+ + + +
+ {/if} +
+{/if} + + diff --git a/src/lib/ui/FileTree.svelte b/src/lib/ui/FileTree.svelte new file mode 100644 index 00000000..470e157a --- /dev/null +++ b/src/lib/ui/FileTree.svelte @@ -0,0 +1,110 @@ + + +{#each entries as entry (entry.name)} + {@const fullPath = getFullPath(entry.name)} +
entry.isDir ? dispatch("toggle", fullPath) : dispatch("select", fullPath)} + on:dblclick={() => !entry.isDir && dispatch("doubleClick", fullPath)} + role="treeitem" + aria-selected={fullPath === selectedPath} + tabindex="0" + on:keydown={(e) => { + if (e.key === 'Enter') { + entry.isDir ? dispatch("toggle", fullPath) : dispatch("select", fullPath); + } + }} + > + + {#if entry.isDir} + + + + + {:else} + + {/if} + + {entry.name} + {#if !entry.isDir} + {formatSize(entry.size)} + {/if} +
+{/each} + + diff --git a/src/lib/ui/Toolbar.svelte b/src/lib/ui/Toolbar.svelte index 285c5beb..ad5b04c8 100644 --- a/src/lib/ui/Toolbar.svelte +++ b/src/lib/ui/Toolbar.svelte @@ -1,6 +1,7 @@