package com.sshmanager.controller; import com.jcraft.jsch.SftpException; import com.sshmanager.dto.SftpFileInfo; import com.sshmanager.entity.Connection; import com.sshmanager.entity.User; import com.sshmanager.repository.UserRepository; import com.sshmanager.service.ConnectionService; import com.sshmanager.service.SftpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @RestController @RequestMapping("/api/sftp") public class SftpController { private static final Logger log = LoggerFactory.getLogger(SftpController.class); private final ConnectionService connectionService; private final UserRepository userRepository; private final SftpService sftpService; private final Map sessions = new ConcurrentHashMap<>(); private final Map sessionLocks = new ConcurrentHashMap<>(); private final Map transferTasks = new ConcurrentHashMap<>(); private final Map uploadTasks = new ConcurrentHashMap<>(); private final ExecutorService transferTaskExecutor = Executors.newCachedThreadPool(); private final Map> taskEmitters = new ConcurrentHashMap<>(); public SftpController(ConnectionService connectionService, UserRepository userRepository, SftpService sftpService) { this.connectionService = connectionService; this.userRepository = userRepository; this.sftpService = sftpService; } private Long getCurrentUserId(Authentication auth) { User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found")); return user.getId(); } private String sessionKey(Long userId, Long connectionId) { return userId + ":" + connectionId; } private String transferTaskKey(Long userId, String taskId) { return userId + ":" + taskId; } private String uploadTaskKey(Long userId, String taskId) { return userId + ":" + taskId; } private T withSessionLock(String key, Supplier action) { Object lock = sessionLocks.computeIfAbsent(key, k -> new Object()); synchronized (lock) { return action.get(); } } private T withTwoSessionLocks(String keyA, String keyB, Supplier action) { if (keyA.equals(keyB)) { return withSessionLock(keyA, action); } String first = keyA.compareTo(keyB) < 0 ? keyA : keyB; String second = keyA.compareTo(keyB) < 0 ? keyB : keyA; Object firstLock = sessionLocks.computeIfAbsent(first, k -> new Object()); Object secondLock = sessionLocks.computeIfAbsent(second, k -> new Object()); synchronized (firstLock) { synchronized (secondLock) { return action.get(); } } } private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception { String key = sessionKey(userId, connectionId); SftpService.SftpSession session = sessions.get(key); if (session == null || !session.isConnected()) { Connection conn = connectionService.getConnectionForSsh(connectionId, userId); String password = connectionService.getDecryptedPassword(conn); String privateKey = connectionService.getDecryptedPrivateKey(conn); String passphrase = connectionService.getDecryptedPassphrase(conn); session = sftpService.connect(conn, password, privateKey, passphrase); sessions.put(key, session); } cleanupTask.recordAccess(key); return session; } @GetMapping("/list") public ResponseEntity list( @RequestParam Long connectionId, @RequestParam(required = false, defaultValue = ".") String path, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); String key = sessionKey(userId, connectionId); return withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); List files = sftpService.listFiles(session, path); List dtos = files.stream() .map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime)) .collect(Collectors.toList()); return ResponseEntity.ok(dtos); } catch (Exception e) { SftpService.SftpSession existing = sessions.remove(key); if (existing != null) { existing.disconnect(); } throw new RuntimeException(e); } }); } catch (Exception e) { String errorMsg = toSftpErrorMessage(e, path, "list"); log.warn("SFTP list failed: connectionId={}, path={}, error={}", connectionId, path, errorMsg, e); Map err = new HashMap<>(); err.put("error", errorMsg); return ResponseEntity.status(500).body(err); } } private String toSftpErrorMessage(Exception e, String path, String operation) { if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) { return e.getMessage(); } Throwable cur = e; for (int i = 0; i < 10 && cur != null; i++) { if (cur instanceof SftpException) { return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation); } if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) { return cur.getMessage(); } cur = cur.getCause(); } return operation + " failed"; } private ResponseEntity> validateTransferPaths(String sourcePath, String targetPath) { if (sourcePath == null || sourcePath.trim().isEmpty()) { Map err = new HashMap<>(); err.put("error", "sourcePath is required"); return ResponseEntity.badRequest().body(err); } if (targetPath == null || targetPath.trim().isEmpty()) { Map err = new HashMap<>(); err.put("error", "targetPath is required"); return ResponseEntity.badRequest().body(err); } return null; } private void executeTransfer(Long userId, Long sourceConnectionId, String sourcePath, Long targetConnectionId, String targetPath, TransferTaskStatus status) throws Exception { String cleanSourcePath = sourcePath.trim(); String cleanTargetPath = targetPath.trim(); String sourceKey = sessionKey(userId, sourceConnectionId); String targetKey = sessionKey(userId, targetConnectionId); withTwoSessionLocks(sourceKey, targetKey, () -> { try { SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId); SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId); sftpService.transferRemote(sourceSession, cleanSourcePath, targetSession, cleanTargetPath, new SftpService.TransferProgressListener() { @Override public void onStart(long totalBytes) { status.setProgress(0, totalBytes); } @Override public void onProgress(long transferredBytes, long totalBytes) { status.setProgress(transferredBytes, totalBytes); } }); return null; } catch (Exception e) { SftpService.SftpSession source = sessions.remove(sourceKey); if (source != null) { source.disconnect(); } if (!sourceKey.equals(targetKey)) { SftpService.SftpSession target = sessions.remove(targetKey); if (target != null) { target.disconnect(); } } throw new RuntimeException(e); } }); } @GetMapping("/pwd") public ResponseEntity> pwd( @RequestParam Long connectionId, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); String key = sessionKey(userId, connectionId); return withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); String pwd = sftpService.pwd(session); Map result = new HashMap<>(); result.put("path", pwd); return ResponseEntity.ok(result); } catch (Exception e) { SftpService.SftpSession existing = sessions.remove(key); if (existing != null) { existing.disconnect(); } throw new RuntimeException(e); } }); } catch (Exception e) { log.warn("SFTP pwd failed: connectionId={}", connectionId, e); Map err = new HashMap<>(); err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed"); return ResponseEntity.status(500).body(err); } } @GetMapping("/download") public ResponseEntity download( @RequestParam Long connectionId, @RequestParam String path, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); String key = sessionKey(userId, connectionId); String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path; StreamingResponseBody stream = outputStream -> withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); sftpService.download(session, path, outputStream); outputStream.flush(); return null; } catch (Exception e) { SftpService.SftpSession existing = sessions.remove(key); if (existing != null) { existing.disconnect(); } throw new RuntimeException(e); } }); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(stream); } catch (Exception e) { return ResponseEntity.status(500).build(); } } @PostMapping("/upload") public ResponseEntity> upload( @RequestParam Long connectionId, @RequestParam String path, @RequestParam("file") MultipartFile file, Authentication authentication) { java.io.File tempFile = null; try { Long userId = getCurrentUserId(authentication); String taskId = UUID.randomUUID().toString(); String taskKey = uploadTaskKey(userId, taskId); // Save file to persistent location before async processing java.io.File uploadTempDir = new java.io.File("./data/upload-temp"); if (!uploadTempDir.exists()) { uploadTempDir.mkdirs(); } tempFile = new java.io.File(uploadTempDir, taskId + "_" + file.getOriginalFilename()); file.transferTo(tempFile); final java.io.File savedFile = tempFile; UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId, path, file.getOriginalFilename(), file.getSize()); status.setController(this); uploadTasks.put(taskKey, status); Future future = transferTaskExecutor.submit(() -> { status.setStatus("running"); String key = sessionKey(userId, connectionId); try { withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); String remotePath = (path == null || path.isEmpty() || path.equals("/")) ? "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) : (path.endsWith("/") ? path + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) : path + "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1)); AtomicLong transferred = new AtomicLong(0); try (java.io.InputStream in = new java.io.FileInputStream(savedFile)) { sftpService.upload(session, remotePath, in, new SftpService.TransferProgressListener() { @Override public void onStart(long totalBytes) { status.setProgress(0, totalBytes); } @Override public void onProgress(long count, long totalBytes) { long current = transferred.addAndGet(count); status.setProgress(current, status.getTotalBytes()); } }); } status.markSuccess(); return null; } catch (Exception e) { SftpService.SftpSession existing = sessions.remove(key); if (existing != null) { existing.disconnect(); } throw new RuntimeException(e); } finally { // Clean up temp file after upload completes if (savedFile.exists()) { savedFile.delete(); } } }); } catch (Exception e) { status.markError(e.getMessage() != null ? e.getMessage() : "Upload failed"); // Clean up temp file on error if (savedFile.exists()) { savedFile.delete(); } } }); status.setFuture(future); Map result = new HashMap<>(); result.put("taskId", taskId); result.put("message", "Upload started"); return ResponseEntity.ok(result); } catch (Exception e) { // Clean up temp file if initial save failed if (tempFile != null && tempFile.exists()) { tempFile.delete(); } Map error = new HashMap<>(); error.put("error", e.getMessage()); return ResponseEntity.status(500).body(error); } } @DeleteMapping("/delete") public ResponseEntity> delete( @RequestParam Long connectionId, @RequestParam String path, @RequestParam boolean directory, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); String key = sessionKey(userId, connectionId); return withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); sftpService.delete(session, path, directory); Map result = new HashMap<>(); result.put("message", "Deleted"); return ResponseEntity.ok(result); } catch (Exception e) { SftpService.SftpSession existing = sessions.remove(key); if (existing != null) { existing.disconnect(); } throw new RuntimeException(e); } }); } catch (Exception e) { Map error = new HashMap<>(); error.put("error", e.getMessage()); return ResponseEntity.status(500).body(error); } } @PostMapping("/mkdir") public ResponseEntity> mkdir( @RequestParam Long connectionId, @RequestParam String path, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); String key = sessionKey(userId, connectionId); return withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); sftpService.mkdir(session, path); Map result = new HashMap<>(); result.put("message", "Created"); return ResponseEntity.ok(result); } catch (Exception e) { SftpService.SftpSession existing = sessions.remove(key); if (existing != null) { existing.disconnect(); } throw new RuntimeException(e); } }); } catch (Exception e) { Map error = new HashMap<>(); error.put("error", e.getMessage()); return ResponseEntity.status(500).body(error); } } @PostMapping("/rename") public ResponseEntity> rename( @RequestParam Long connectionId, @RequestParam String oldPath, @RequestParam String newPath, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); String key = sessionKey(userId, connectionId); return withSessionLock(key, () -> { try { SftpService.SftpSession session = getOrCreateSession(connectionId, userId); sftpService.rename(session, oldPath, newPath); Map result = new HashMap<>(); result.put("message", "Renamed"); return ResponseEntity.ok(result); } catch (Exception e) { SftpService.SftpSession existing = sessions.remove(key); if (existing != null) { existing.disconnect(); } throw new RuntimeException(e); } }); } catch (Exception e) { Map error = new HashMap<>(); error.put("error", e.getMessage()); return ResponseEntity.status(500).body(error); } } @PostMapping("/transfer-remote") public ResponseEntity> transferRemote( @RequestParam Long sourceConnectionId, @RequestParam String sourcePath, @RequestParam Long targetConnectionId, @RequestParam String targetPath, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); ResponseEntity> validation = validateTransferPaths(sourcePath, targetPath); if (validation != null) { return validation; } TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, sourcePath.trim(), targetPath.trim()); executeTransfer(userId, sourceConnectionId, sourcePath, targetConnectionId, targetPath, status); Map result = new HashMap<>(); result.put("message", "Transferred"); return ResponseEntity.ok(result); } catch (Exception e) { Map error = new HashMap<>(); error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed"); return ResponseEntity.status(500).body(error); } } @PostMapping("/transfer-remote/tasks") public ResponseEntity> createTransferRemoteTask( @RequestParam Long sourceConnectionId, @RequestParam String sourcePath, @RequestParam Long targetConnectionId, @RequestParam String targetPath, Authentication authentication) { Long userId = getCurrentUserId(authentication); ResponseEntity> validation = validateTransferPaths(sourcePath, targetPath); if (validation != null) { Map err = new HashMap<>(); err.putAll(validation.getBody()); return ResponseEntity.status(validation.getStatusCode()).body(err); } TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, sourcePath.trim(), targetPath.trim()); status.setController(this); String taskKey = transferTaskKey(userId, status.getTaskId()); transferTasks.put(taskKey, status); Future future = transferTaskExecutor.submit(() -> { status.setStatus("running"); try { if (Thread.currentThread().isInterrupted()) { status.markCancelled(); return; } executeTransfer(userId, sourceConnectionId, sourcePath, targetConnectionId, targetPath, status); status.markSuccess(); } catch (Exception e) { if (e instanceof InterruptedException || Thread.currentThread().isInterrupted()) { status.markCancelled(); return; } status.markError(toSftpErrorMessage(e, sourcePath, "transfer")); log.warn("SFTP transfer task failed: taskId={}, sourceConnectionId={}, sourcePath={}, targetConnectionId={}, targetPath={}, error={}", status.getTaskId(), sourceConnectionId, sourcePath, targetConnectionId, targetPath, e.getMessage(), e); } }); status.setFuture(future); return ResponseEntity.ok(status.toResponse()); } @GetMapping("/transfer-remote/tasks/{taskId}") public ResponseEntity> getTransferRemoteTask( @PathVariable String taskId, Authentication authentication) { Long userId = getCurrentUserId(authentication); TransferTaskStatus status = transferTasks.get(transferTaskKey(userId, taskId)); if (status == null) { Map error = new HashMap<>(); error.put("error", "Transfer task not found"); return ResponseEntity.status(404).body(error); } return ResponseEntity.ok(status.toResponse()); } @GetMapping("/upload/tasks/{taskId}") public ResponseEntity> getUploadTask( @PathVariable String taskId, Authentication authentication) { Long userId = getCurrentUserId(authentication); UploadTaskStatus status = uploadTasks.get(uploadTaskKey(userId, taskId)); if (status == null) { Map error = new HashMap<>(); error.put("error", "Upload task not found"); return ResponseEntity.status(404).body(error); } return ResponseEntity.ok(status.toResponse()); } @DeleteMapping("/transfer-remote/tasks/{taskId}") public ResponseEntity> cancelTransferRemoteTask( @PathVariable String taskId, Authentication authentication) { Long userId = getCurrentUserId(authentication); TransferTaskStatus status = transferTasks.get(transferTaskKey(userId, taskId)); if (status == null) { Map error = new HashMap<>(); error.put("error", "Transfer task not found"); return ResponseEntity.status(404).body(error); } boolean cancelled = status.cancel(); Map result = status.toResponse(); result.put("cancelRequested", cancelled); if (!cancelled) { result.put("message", "Task already running or finished; current transfer cannot be interrupted safely"); } return ResponseEntity.ok(result); } @GetMapping("/transfer-remote/tasks/{taskId}/progress") public SseEmitter streamTransferProgress( @PathVariable String taskId, Authentication authentication) { Long userId = getCurrentUserId(authentication); String taskKey = transferTaskKey(userId, taskId); TransferTaskStatus status = transferTasks.get(taskKey); SseEmitter emitter = new SseEmitter(300000L); // 5 minutes timeout if (status == null) { try { Map error = new HashMap<>(); error.put("error", "Task not found"); emitter.send(SseEmitter.event().name("error").data(error)); emitter.complete(); } catch (IOException e) { emitter.completeWithError(e); } return emitter; } taskEmitters.computeIfAbsent(taskKey, k -> new CopyOnWriteArrayList<>()).add(emitter); emitter.onCompletion(() -> removeEmitter(taskKey, emitter)); emitter.onTimeout(() -> removeEmitter(taskKey, emitter)); emitter.onError((e) -> removeEmitter(taskKey, emitter)); // Send initial status try { emitter.send(SseEmitter.event().name("progress").data(status.toResponse())); } catch (IOException e) { removeEmitter(taskKey, emitter); } return emitter; } @GetMapping("/upload/tasks/{taskId}/progress") public SseEmitter streamUploadProgress( @PathVariable String taskId, Authentication authentication) { Long userId = getCurrentUserId(authentication); String taskKey = uploadTaskKey(userId, taskId); UploadTaskStatus status = uploadTasks.get(taskKey); SseEmitter emitter = new SseEmitter(300000L); // 5 minutes timeout if (status == null) { try { Map error = new HashMap<>(); error.put("error", "Task not found"); emitter.send(SseEmitter.event().name("error").data(error)); emitter.complete(); } catch (IOException e) { emitter.completeWithError(e); } return emitter; } taskEmitters.computeIfAbsent(taskKey, k -> new CopyOnWriteArrayList<>()).add(emitter); emitter.onCompletion(() -> removeEmitter(taskKey, emitter)); emitter.onTimeout(() -> removeEmitter(taskKey, emitter)); emitter.onError((e) -> removeEmitter(taskKey, emitter)); // Send initial status try { emitter.send(SseEmitter.event().name("progress").data(status.toResponse())); } catch (IOException e) { removeEmitter(taskKey, emitter); } return emitter; } private void removeEmitter(String taskKey, SseEmitter emitter) { CopyOnWriteArrayList emitters = taskEmitters.get(taskKey); if (emitters != null) { emitters.remove(emitter); if (emitters.isEmpty()) { taskEmitters.remove(taskKey); } } } private void broadcastProgress(String taskKey, Map data) { CopyOnWriteArrayList emitters = taskEmitters.get(taskKey); if (emitters == null || emitters.isEmpty()) { return; } List deadEmitters = new java.util.ArrayList<>(); for (SseEmitter emitter : emitters) { try { emitter.send(SseEmitter.event().name("progress").data(data)); } catch (Exception e) { deadEmitters.add(emitter); } } for (SseEmitter dead : deadEmitters) { removeEmitter(taskKey, dead); } } @PostMapping("/disconnect") public ResponseEntity> disconnect( @RequestParam Long connectionId, Authentication authentication) { Long userId = getCurrentUserId(authentication); String key = sessionKey(userId, connectionId); SftpService.SftpSession session = sessions.remove(key); if (session != null) { session.disconnect(); } sessionLocks.remove(key); cleanupTask.removeSession(key); Map result = new HashMap<>(); result.put("message", "Disconnected"); return ResponseEntity.ok(result); } public void cleanupExpiredSessions(int timeoutMinutes) { List expired = cleanupTask.getExpiredSessions(timeoutMinutes); for (String key : expired) { SftpService.SftpSession session = sessions.remove(key); if (session != null) { session.disconnect(); } sessionLocks.remove(key); cleanupTask.removeSession(key); log.info("Cleaned up expired SFTP session: {}", key); } } public void cleanupExpiredTransferTasks(int timeoutMinutes) { long now = System.currentTimeMillis(); long timeoutMillis = timeoutMinutes * 60L * 1000L; transferTasks.entrySet().removeIf(entry -> entry.getValue().isExpired(now, timeoutMillis)); } private final SftpSessionExpiryCleanup cleanupTask = new SftpSessionExpiryCleanup(); public static class SftpSessionExpiryCleanup { private final Map lastAccessTime = new ConcurrentHashMap<>(); public void recordAccess(String key) { lastAccessTime.put(key, System.currentTimeMillis()); } public void removeSession(String key) { lastAccessTime.remove(key); } public List getExpiredSessions(long timeoutMinutes) { long now = System.currentTimeMillis(); long timeoutMillis = timeoutMinutes * 60 * 1000; return lastAccessTime.entrySet().stream() .filter(entry -> now - entry.getValue() > timeoutMillis) .map(Map.Entry::getKey) .collect(Collectors.toList()); } } public static class TransferTaskStatus { private final String taskId; private final Long userId; private final Long sourceConnectionId; private final Long targetConnectionId; private final String sourcePath; private final String targetPath; private final long createdAt; private volatile String status; private volatile String error; private volatile long startedAt; private volatile long finishedAt; private final AtomicLong totalBytes; private final AtomicLong transferredBytes; private volatile Future future; private volatile SftpController controller; public TransferTaskStatus(String taskId, Long userId, Long sourceConnectionId, Long targetConnectionId, String sourcePath, String targetPath) { this.taskId = taskId; this.userId = userId; this.sourceConnectionId = sourceConnectionId; this.targetConnectionId = targetConnectionId; this.sourcePath = sourcePath; this.targetPath = targetPath; this.createdAt = System.currentTimeMillis(); this.status = "queued"; this.totalBytes = new AtomicLong(0); this.transferredBytes = new AtomicLong(0); } public String getTaskId() { return taskId; } public void setController(SftpController controller) { this.controller = controller; } public void setFuture(Future future) { this.future = future; } public void setStatus(String status) { this.status = status; if ("running".equals(status) && startedAt == 0) { startedAt = System.currentTimeMillis(); } notifyProgress(); } public void setProgress(long transferred, long total) { if (startedAt == 0) { startedAt = System.currentTimeMillis(); } transferredBytes.set(Math.max(0, transferred)); totalBytes.set(Math.max(0, total)); notifyProgress(); } public void markSuccess() { long total = totalBytes.get(); if (total > 0) { transferredBytes.set(total); } status = "success"; finishedAt = System.currentTimeMillis(); notifyProgress(); } public void markError(String message) { status = "error"; error = message; finishedAt = System.currentTimeMillis(); notifyProgress(); } public void markCancelled() { status = "cancelled"; finishedAt = System.currentTimeMillis(); notifyProgress(); } private void notifyProgress() { if (controller != null) { String taskKey = controller.transferTaskKey(userId, taskId); controller.broadcastProgress(taskKey, toResponse()); } } public boolean cancel() { if (!"queued".equals(status) && !"running".equals(status)) { return false; } Future currentFuture = future; if (currentFuture != null) { currentFuture.cancel(true); } markCancelled(); return true; } public Map toResponse() { long total = totalBytes.get(); long transferred = transferredBytes.get(); int progress = total > 0 ? (int) Math.min(100, Math.round((transferred * 100.0) / total)) : (("success".equals(status) || "error".equals(status) || "cancelled".equals(status)) ? 100 : 0); Map result = new HashMap<>(); result.put("taskId", taskId); result.put("userId", userId); result.put("sourceConnectionId", sourceConnectionId); result.put("targetConnectionId", targetConnectionId); result.put("sourcePath", sourcePath); result.put("targetPath", targetPath); result.put("status", status); result.put("progress", progress); result.put("transferredBytes", transferred); result.put("totalBytes", total); result.put("createdAt", createdAt); result.put("startedAt", startedAt); result.put("finishedAt", finishedAt); if (error != null && !error.isEmpty()) { result.put("error", error); } return result; } public boolean isExpired(long now, long timeoutMillis) { if ("queued".equals(status) || "running".equals(status)) { return false; } long endTime = finishedAt > 0 ? finishedAt : createdAt; return now - endTime > timeoutMillis; } } public static class UploadTaskStatus { private final String taskId; private final Long userId; private final Long connectionId; private final String path; private final String filename; private final long fileSize; private final long createdAt; private volatile String status; private volatile String error; private volatile long startedAt; private volatile long finishedAt; private final AtomicLong totalBytes; private final AtomicLong transferredBytes; private volatile Future future; private volatile SftpController controller; public UploadTaskStatus(String taskId, Long userId, Long connectionId, String path, String filename, long fileSize) { this.taskId = taskId; this.userId = userId; this.connectionId = connectionId; this.path = path; this.filename = filename; this.fileSize = fileSize; this.createdAt = System.currentTimeMillis(); this.status = "queued"; this.totalBytes = new AtomicLong(fileSize); this.transferredBytes = new AtomicLong(0); } public long getTotalBytes() { return totalBytes.get(); } public void setController(SftpController controller) { this.controller = controller; } public void setFuture(Future future) { this.future = future; } public void setStatus(String status) { this.status = status; if ("running".equals(status) && startedAt == 0) { startedAt = System.currentTimeMillis(); } notifyProgress(); } public void setProgress(long transferred, long total) { if (startedAt == 0) { startedAt = System.currentTimeMillis(); } transferredBytes.set(Math.max(0, transferred)); if (total > 0) { totalBytes.set(Math.max(0, total)); } notifyProgress(); } public void markSuccess() { long total = totalBytes.get(); if (total > 0) { transferredBytes.set(total); } status = "success"; finishedAt = System.currentTimeMillis(); notifyProgress(); } public void markError(String message) { status = "error"; error = message; finishedAt = System.currentTimeMillis(); notifyProgress(); } private void notifyProgress() { if (controller != null) { String taskKey = controller.uploadTaskKey(userId, taskId); controller.broadcastProgress(taskKey, toResponse()); } } public Map toResponse() { long total = totalBytes.get(); long transferred = transferredBytes.get(); int progress = total > 0 ? (int) Math.min(100, Math.round((transferred * 100.0) / total)) : (("success".equals(status) || "error".equals(status)) ? 100 : 0); Map result = new HashMap<>(); result.put("taskId", taskId); result.put("status", status); result.put("progress", progress); result.put("transferredBytes", transferred); result.put("totalBytes", total); result.put("filename", filename); result.put("createdAt", createdAt); result.put("startedAt", startedAt); result.put("finishedAt", finishedAt); if (error != null && !error.isEmpty()) { result.put("error", error); } return result; } public boolean isExpired(long now, long timeoutMillis) { if ("queued".equals(status) || "running".equals(status)) { return false; } long endTime = finishedAt > 0 ? finishedAt : createdAt; return now - endTime > timeoutMillis; } } }