From 702bd8846c3302727a4122645e97fcda6c96507c Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:28:45 +0300 Subject: [PATCH 01/12] fix(receive): saveTextAsFile returns explicit HTTP status 400/500/200 saveTextAsFile now returns error instead of void. Caller maps: - path traversal -> 400 Bad Request - save failure -> 500 Internal Server Error - success -> 200 OK (explicit, not implicit) Tests added for all three branches. --- pkg/server/handlers/receive_handlers_test.go | 70 ++++++++++++++++++++ pkg/server/handlers/receive_upload.go | 18 +++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index ee8282c..2019270 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -314,3 +314,73 @@ func TestUploadHandlerV2_TextPlain_NoClipboard(t *testing.T) { t.Errorf("file content mismatch: got %q, want %q", string(written), body) } } + +func TestUploadHandlerV2_TextPlain_PathTraversal_Returns400(t *testing.T) { + cfg := &config.Config{ + AutoAccept: true, + NoClipboard: true, + } + handler, receiveService, _ := setupReceiveHandler(t, cfg) + + files := map[string]model.FileDto{ + "evil": {ID: "evil", FileName: "../../../etc/passwd", Size: 5, FileType: "text/plain"}, + } + session, _ := receiveService.CreateSession(model.DeviceInfo{IP: "127.0.0.1"}, files) + + var token string + for _, f := range session.Files { + token = f.Token + break + } + + req, _ := http.NewRequest(http.MethodPost, + "/v2/upload?sessionId="+session.SessionID+"&fileId=evil&token="+token, + strings.NewReader("hello"), + ) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + + handler.UploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for path traversal, got %v (body: %s)", status, rr.Body.String()) + } +} + +func TestUploadHandlerV2_TextPlain_SaveFailure_Returns500(t *testing.T) { + cfg := &config.Config{ + AutoAccept: true, + NoClipboard: true, + } + handler, receiveService, tempDir := setupReceiveHandler(t, cfg) + + // Make the download directory read-only so SaveStreamToFileWithMetadata fails. + if err := os.Chmod(tempDir, 0500); err != nil { + t.Fatalf("failed to chmod temp dir: %v", err) + } + t.Cleanup(func() { os.Chmod(tempDir, 0700) }) + + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "write_fail.txt", Size: 5, FileType: "text/plain"}, + } + session, _ := receiveService.CreateSession(model.DeviceInfo{IP: "127.0.0.1"}, files) + + var token string + for _, f := range session.Files { + token = f.Token + break + } + + req, _ := http.NewRequest(http.MethodPost, + "/v2/upload?sessionId="+session.SessionID+"&fileId=f1&token="+token, + strings.NewReader("hello"), + ) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + + handler.UploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("expected 500 Internal Server Error for save failure, got %v (body: %s)", status, rr.Body.String()) + } +} diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index 14f5600..dcbfe61 100644 --- a/pkg/server/handlers/receive_upload.go +++ b/pkg/server/handlers/receive_upload.go @@ -158,7 +158,15 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) } // Fall-back: save the full stream as a file. - h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) + if err := h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress); err != nil { + if strings.Contains(err.Error(), "invalid filename") { + httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") + return + } + httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") + return + } + w.WriteHeader(http.StatusOK) return } @@ -183,7 +191,8 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) } // saveTextAsFile saves text content as a file when clipboard is unavailable or text is too large. -func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) { +// Returns nil on success; caller writes HTTP status. +func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) error { var combinedReader io.Reader if int64(len(textBytes)) > maxTextSize { combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) @@ -195,7 +204,7 @@ func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && cleanPath != filepath.Clean(h.config.DownloadDir) { h.logger.Errorf("Path traversal attempt detected in text fallback: %s", rawFileName) - return + return fmt.Errorf("invalid filename") } savErr := storage.SaveStreamToFileWithMetadata( combinedReader, destinationPath, int64(len(textBytes)), modified, accessed, nil, onProgress, h.logger, @@ -203,12 +212,13 @@ func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, if savErr != nil { h.logger.Errorf("Error saving text file %s: %v", rawFileName, savErr) h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) - return + return fmt.Errorf("failed to save file: %w", savErr) } h.logger.Infof("Saved text as file: %s", destinationPath) h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) + return nil } // shutdownAwareReader aborts Read when the shutdown context is cancelled, From 7000f57c91f2d43757781e38e6132d2df380e87e Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:32:12 +0300 Subject: [PATCH 02/12] fix(receive): atomic ClaimFile for concurrent upload integrity New methods on ReceiveService: - ClaimFile: validates session/senderIP/fileId/token under mutex, marks as uploading - CompleteFile: removes file from session after success - FailFile: resets file state to pending on failure - GetSessionProgress: safe RLock read of progress bar pointer UploadHandlerV2 now uses ClaimFile/CompleteFile/FailFile instead of GetSessionByID + RemoveFileFromSession, preventing duplicate concurrent uploads of the same token. Tests: concurrent claim (one success, one ErrAlreadyUploading), error cases (invalid session/file/token/IP), CompleteFile lifecycle --- pkg/server/handlers/receive_upload.go | 129 +++++++++----------- pkg/server/services/receive_service.go | 100 +++++++++++++++ pkg/server/services/receive_service_test.go | 109 +++++++++++++++++ 3 files changed, 269 insertions(+), 69 deletions(-) diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index dcbfe61..e3bfdea 100644 --- a/pkg/server/handlers/receive_upload.go +++ b/pkg/server/handlers/receive_upload.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "context" + "errors" "fmt" "io" "net" @@ -13,6 +14,7 @@ import ( "github.com/bethropolis/localgo/pkg/clipboard" "github.com/bethropolis/localgo/pkg/history" "github.com/bethropolis/localgo/pkg/httputil" + "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/server/services" "github.com/bethropolis/localgo/pkg/storage" ) @@ -35,37 +37,34 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) reqSessionId := query.Get("sessionId") reqFileId := query.Get("fileId") reqToken := query.Get("token") + reqIP, _, _ := net.SplitHostPort(r.RemoteAddr) if reqSessionId == "" || reqFileId == "" || reqToken == "" { httputil.RespondError(w, http.StatusBadRequest, "Missing query parameters (sessionId, fileId, token)") return } - // --- Validate Session and Token --- - session := h.receiveService.GetSessionByID(reqSessionId) - if session == nil { - h.logger.Warnf("Invalid sessionId '%s' for /upload", reqSessionId) - httputil.RespondError(w, http.StatusForbidden, "Invalid session ID") // 403 Forbidden - return - } - - // Validate sender IP matches the one from prepare-upload - reqIP, _, _ := net.SplitHostPort(r.RemoteAddr) - if reqIP != session.Sender.IP { - h.logger.Warnf("IP mismatch for /upload: request from %s, expected %s", reqIP, session.Sender.IP) - httputil.RespondError(w, http.StatusForbidden, fmt.Sprintf("Invalid IP address: %s", reqIP)) // 403 Forbidden - return - } - - fileInfo, ok := session.Files[reqFileId] - if !ok || fileInfo.Token != reqToken { - h.logger.Warnf("Invalid fileId '%s' or token '%s' for session '%s'", reqFileId, reqToken, reqSessionId) - httputil.RespondError(w, http.StatusForbidden, "Invalid fileId or token") // 403 Forbidden + // --- Atomic Claim: validates session, IP, fileId, token under mutex --- + dto, sender, err := h.receiveService.ClaimFile(reqSessionId, reqFileId, reqToken, reqIP) + if err != nil { + h.logger.Warnf("/upload claim failed for session=%s file=%s from %s: %v", reqSessionId, reqFileId, reqIP, err) + switch { + case errors.Is(err, services.ErrSessionNotFound): + httputil.RespondError(w, http.StatusForbidden, "Invalid session ID") + case errors.Is(err, services.ErrIPMismatch): + httputil.RespondError(w, http.StatusForbidden, fmt.Sprintf("Invalid IP address: %s", reqIP)) + case errors.Is(err, services.ErrInvalidFileToken): + httputil.RespondError(w, http.StatusForbidden, "Invalid fileId or token") + case errors.Is(err, services.ErrAlreadyUploading), errors.Is(err, services.ErrAlreadyCompleted): + httputil.RespondError(w, http.StatusConflict, "File already being uploaded") + default: + httputil.RespondError(w, http.StatusForbidden, "Invalid request") + } return } // --- File Saving --- - rawFileName := fileInfo.Dto.FileName + rawFileName := dto.FileName destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) // Path traversal prevention: ensure the resolved path is still within DownloadDir @@ -73,23 +72,25 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && cleanPath != filepath.Clean(h.config.DownloadDir) { h.logger.Errorf("Path traversal attempt detected: %s -> %s", rawFileName, cleanPath) + h.receiveService.FailFile(reqSessionId, reqFileId) httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") return } - h.logger.Infof("Starting save for file: %s (ID: %s) to %s", fileInfo.Dto.FileName, reqFileId, destinationPath) + h.logger.Infof("Starting save for file: %s (ID: %s) to %s", dto.FileName, reqFileId, destinationPath) var trackProgress func(int64) - if !h.config.Quiet && session.Progress != nil { - displayName := fileInfo.Dto.FileName - if fileInfo.Dto.Preview != nil && *fileInfo.Dto.Preview != "" { - preview := *fileInfo.Dto.Preview + progress := h.receiveService.GetSessionProgress(reqSessionId) + if !h.config.Quiet && progress != nil { + displayName := dto.FileName + if dto.Preview != nil && *dto.Preview != "" { + preview := *dto.Preview if len(preview) > 20 { preview = preview[:20] + "…" } displayName = preview } - trackProgress = session.Progress.AddBar(displayName, fileInfo.Dto.Size) + trackProgress = progress.AddBar(displayName, dto.Size) } // --- Progress Callback --- @@ -101,32 +102,29 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) // --- Body Size Limit --- // Cap body to the declared file size to prevent disk DoS. - // A peer can't send more bytes than they declared in prepare-upload. - if fileInfo.Dto.Size < 0 { + if dto.Size < 0 { + h.receiveService.FailFile(reqSessionId, reqFileId) httputil.RespondError(w, http.StatusBadRequest, "Invalid file size") return } - bodyReader := io.LimitReader(r.Body, fileInfo.Dto.Size) + bodyReader := io.LimitReader(r.Body, dto.Size) bodyReader = &shutdownAwareReader{Reader: bodyReader, ctx: h.shutdownCtx} defer r.Body.Close() var modified, accessed *string - if fileInfo.Dto.Metadata != nil { - modified = fileInfo.Dto.Metadata.Modified - accessed = fileInfo.Dto.Metadata.Accessed + if dto.Metadata != nil { + modified = dto.Metadata.Modified + accessed = dto.Metadata.Accessed } // --- Text/Clipboard Handling --- - // When the incoming transfer is plain text and clipboard is not disabled, - // try to copy the content directly to the system clipboard instead of writing - // to disk. On failure (headless / no display server) fall through to the - // normal file-save path so the content is never lost. - if strings.HasPrefix(fileInfo.Dto.FileType, "text/plain") && !h.config.NoClipboard { + if strings.HasPrefix(dto.FileType, "text/plain") && !h.config.NoClipboard { limited := io.LimitReader(bodyReader, maxTextSize+1) textBytes, readErr := io.ReadAll(limited) if readErr != nil { - h.logger.Errorf("Error reading text body for clipboard (file %s): %v", fileInfo.Dto.FileName, readErr) + h.logger.Errorf("Error reading text body for clipboard (file %s): %v", dto.FileName, readErr) + h.receiveService.FailFile(reqSessionId, reqFileId) httputil.RespondError(w, http.StatusInternalServerError, "Failed to read text content") return } @@ -134,31 +132,26 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) text := string(textBytes) if int64(len(textBytes)) > maxTextSize { - // Text is too large for clipboard; save to file instead. h.logger.Warnf("Text transfer too large for clipboard (%d bytes), saving to file", len(textBytes)) } else if clipErr := clipboard.Write(text); clipErr == nil { - // Successfully copied to clipboard. preview := text if len(preview) > 80 { preview = preview[:80] + "…" } - h.logger.Infof("Copied text to clipboard from %s: %q", fileInfo.Dto.FileName, preview) - - // Mark the progress bar as completed since no file write occurs - onProgress(fileInfo.Dto.Size) - - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, "", int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusClipboard) - h.runExecHook("", rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) + h.logger.Infof("Copied text to clipboard from %s: %q", dto.FileName, preview) + onProgress(dto.Size) + h.receiveService.CompleteFile(reqSessionId, reqFileId) + h.logTransfer(sender.Alias, sender.IP, rawFileName, "", int64(len(textBytes)), dto.FileType, history.StatusClipboard) + h.runExecHook("", rawFileName, sender.Alias, sender.IP, int64(len(textBytes))) w.WriteHeader(http.StatusOK) return } else { - // Clipboard unavailable — fall back to file. h.logger.Warnf("Clipboard unavailable (%v), saving text as file instead", clipErr) } // Fall-back: save the full stream as a file. - if err := h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress); err != nil { + if err := h.saveTextAsFileTo(sender, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress); err != nil { + h.receiveService.FailFile(reqSessionId, reqFileId) if strings.Contains(err.Error(), "invalid filename") { httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") return @@ -166,33 +159,32 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") return } + h.receiveService.CompleteFile(reqSessionId, reqFileId) w.WriteHeader(http.StatusOK) return } - err := storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger) - + // --- Binary File Save --- + err = storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, dto.Size, modified, accessed, dto.SHA256, onProgress, h.logger) if err != nil { - h.logger.Errorf("Error saving file %s (ID: %s): %v", fileInfo.Dto.FileName, reqFileId, err) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusFailed) + h.logger.Errorf("Error saving file %s (ID: %s): %v", dto.FileName, reqFileId, err) + h.receiveService.FailFile(reqSessionId, reqFileId) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, dto.Size, dto.FileType, history.StatusFailed) httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") return } // --- Success --- - h.logger.Infof("Finished saving file: %s (ID: %s)", fileInfo.Dto.FileName, reqFileId) - - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusReceived) - h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, fileInfo.Dto.Size) - + h.logger.Infof("Finished saving file: %s (ID: %s)", dto.FileName, reqFileId) + h.receiveService.CompleteFile(reqSessionId, reqFileId) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, dto.Size, dto.FileType, history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, sender.Alias, sender.IP, dto.Size) w.WriteHeader(http.StatusOK) } -// saveTextAsFile saves text content as a file when clipboard is unavailable or text is too large. -// Returns nil on success; caller writes HTTP status. -func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) error { +// saveTextAsFileTo saves text content as a file when clipboard is unavailable or text is too large. +// Returns nil on success; caller writes HTTP status and calls CompleteFile. +func (h *ReceiveHandler) saveTextAsFileTo(sender model.DeviceInfo, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) error { var combinedReader io.Reader if int64(len(textBytes)) > maxTextSize { combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) @@ -211,13 +203,12 @@ func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, ) if savErr != nil { h.logger.Errorf("Error saving text file %s: %v", rawFileName, savErr) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) return fmt.Errorf("failed to save file: %w", savErr) } h.logger.Infof("Saved text as file: %s", destinationPath) - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) - h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) + h.logTransfer(sender.Alias, sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, sender.Alias, sender.IP, int64(len(textBytes))) return nil } diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index aefe173..042e277 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -1,6 +1,7 @@ package services import ( + "errors" "fmt" "sync" "time" @@ -10,6 +11,23 @@ import ( "github.com/google/uuid" ) +// FileTransferState tracks the lifecycle of a file within a receive session. +type FileTransferState int + +const ( + FilePending FileTransferState = iota // initial state after session creation + FileUploading // claim acquired, upload in progress + FileDone // upload completed or failed +) + +var ( + ErrSessionNotFound = errors.New("invalid session") + ErrIPMismatch = errors.New("ip mismatch") + ErrInvalidFileToken = errors.New("invalid file or token") + ErrAlreadyUploading = errors.New("already uploading") + ErrAlreadyCompleted = errors.New("already completed") +) + // ActiveReceiveSession represents an active file receiving session. type ActiveReceiveSession struct { SessionID string @@ -23,6 +41,7 @@ type ActiveReceiveSession struct { type ActiveFile struct { Dto model.FileDto Token string + State FileTransferState } // ReceiveService manages file receiving sessions. @@ -159,6 +178,87 @@ func (s *ReceiveService) CloseSession(sessionID string) { } } +// ClaimFile atomically validates session, sender IP, file ID, and token, +// then marks the file as uploading. Returns the file DTO and sender info. +// Returns ErrAlreadyUploading / ErrAlreadyCompleted for duplicate requests. +// Caller must call CompleteFile or FailFile after the upload finishes. +func (s *ReceiveService) ClaimFile(sessionID, fileID, token, senderIP string) (model.FileDto, model.DeviceInfo, error) { + s.sessionMutex.Lock() + defer s.sessionMutex.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return model.FileDto{}, model.DeviceInfo{}, ErrSessionNotFound + } + if senderIP != session.Sender.IP { + return model.FileDto{}, model.DeviceInfo{}, ErrIPMismatch + } + file, ok := session.Files[fileID] + if !ok || file.Token != token { + return model.FileDto{}, model.DeviceInfo{}, ErrInvalidFileToken + } + switch file.State { + case FileUploading: + return model.FileDto{}, model.DeviceInfo{}, ErrAlreadyUploading + case FileDone: + return model.FileDto{}, model.DeviceInfo{}, ErrAlreadyCompleted + } + file.State = FileUploading + session.Files[fileID] = file + return file.Dto, session.Sender, nil +} + +// CompleteFile removes the file from the session after a successful upload. +// If no files remain, the session is cleaned up and the progress bar completes. +func (s *ReceiveService) CompleteFile(sessionID, fileID string) { + s.sessionMutex.Lock() + session, ok := s.sessions[sessionID] + if !ok { + s.sessionMutex.Unlock() + return + } + delete(session.Files, fileID) + sessionEmpty := len(session.Files) == 0 + if sessionEmpty { + delete(s.sessions, sessionID) + } + s.sessionMutex.Unlock() + + if sessionEmpty && session.Progress != nil { + session.Progress.ForceComplete() + go session.Progress.Wait() + } +} + +// FailFile resets the file state back to pending so the sender can retry. +func (s *ReceiveService) FailFile(sessionID, fileID string) { + s.sessionMutex.Lock() + defer s.sessionMutex.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return + } + file, ok := session.Files[fileID] + if !ok { + return + } + file.State = FilePending + session.Files[fileID] = file +} + +// GetSessionProgress returns the MultiProgress for a session (or nil). +// The Progress pointer is assigned at session creation and never mutated, +// so this is safe to read under RLock. +func (s *ReceiveService) GetSessionProgress(sessionID string) *cli.MultiProgress { + s.sessionMutex.RLock() + defer s.sessionMutex.RUnlock() + if session, ok := s.sessions[sessionID]; ok { + return session.Progress + } + return nil +} + // CloseAllSessions force-completes progress bars and removes all active sessions. func (s *ReceiveService) CloseAllSessions() { s.sessionMutex.Lock() diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 57993f4..258b9d1 100644 --- a/pkg/server/services/receive_service_test.go +++ b/pkg/server/services/receive_service_test.go @@ -1,6 +1,7 @@ package services import ( + "sync" "testing" "github.com/bethropolis/localgo/pkg/model" @@ -127,6 +128,114 @@ func TestReceiveService_CloseSession(t *testing.T) { } } +func TestReceiveService_ClaimFile_Success(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Alice", IP: "192.168.1.10"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "doc.txt", Size: 100}, + } + session, _ := svc.CreateSession(sender, files) + + dto, gotSender, err := svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "192.168.1.10") + if err != nil { + t.Fatalf("ClaimFile failed: %v", err) + } + if dto.FileName != "doc.txt" { + t.Errorf("expected doc.txt, got %s", dto.FileName) + } + if gotSender.Alias != "Alice" { + t.Errorf("expected Alice, got %s", gotSender.Alias) + } + + // Second claim should fail + _, _, err = svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "192.168.1.10") + if err != ErrAlreadyUploading { + t.Errorf("expected ErrAlreadyUploading, got %v", err) + } +} + +func TestReceiveService_ClaimFile_Errors(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Alice", IP: "192.168.1.10"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "doc.txt", Size: 100}, + } + session, _ := svc.CreateSession(sender, files) + + tests := []struct { + name string + sessionID string + fileID string + token string + senderIP string + wantErr error + }{ + {"invalid session", "nonexistent", "f1", "x", "192.168.1.10", ErrSessionNotFound}, + {"invalid file", session.SessionID, "bad", "x", "192.168.1.10", ErrInvalidFileToken}, + {"ip mismatch", session.SessionID, "f1", session.Files["f1"].Token, "192.168.1.99", ErrIPMismatch}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := svc.ClaimFile(tt.sessionID, tt.fileID, tt.token, tt.senderIP) + if err != tt.wantErr { + t.Errorf("got %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestReceiveService_ClaimFile_Concurrent(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Bob", IP: "10.0.0.1"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "shared.txt", Size: 50}, + } + session, _ := svc.CreateSession(sender, files) + + var wg sync.WaitGroup + results := make(chan error, 2) + + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _, err := svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "10.0.0.1") + results <- err + }() + } + wg.Wait() + close(results) + + successCount := 0 + for err := range results { + if err == nil { + successCount++ + } else if err != ErrAlreadyUploading { + t.Errorf("unexpected error: %v", err) + } + } + if successCount != 1 { + t.Errorf("expected exactly 1 success, got %d", successCount) + } +} + +func TestReceiveService_CompleteFile(t *testing.T) { + svc := NewReceiveService() + sender := model.DeviceInfo{Alias: "Alice", IP: "192.168.1.10"} + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "doc.txt", Size: 100}, + } + session, _ := svc.CreateSession(sender, files) + + svc.CompleteFile(session.SessionID, "f1") + + // File should be gone + up := svc.GetSessionByID(session.SessionID) + if up != nil { + t.Error("expected session to be removed after completing the last file") + } +} + func TestReceiveService_RemoveFileFromSession(t *testing.T) { svc := NewReceiveService() From 4c679bd095b4eb28837d8926ad59864c11e63242 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:35:51 +0300 Subject: [PATCH 03/12] fix(metadata): non-destructive EXIF/metadata strip with temp+rename Refactored stripping to write to a separate destination path: - StripTo(src, dest) writes stripped image to dest, never touches src - Strip(path) uses temp-in-dir + rename for atomic in-place replacement - stripJPEGTo/stripPNGTo: fail closed when SOS/IEND not found - PNG: strip eXIf chunk in addition to tEXt/zTXt/iTXt - Magic sniff (checks first 8 bytes), not just file extension - writeAtomic helper for safe temp+rename writes SendFile private mode: strips to temp file, uploads temp, defers cleanup. Original file bytes are never modified. Tests: JPEG EXIF removal, original unchanged, truncated JPEG error, PNG eXIf stripping, non-image passthrough, nonexistent file error --- pkg/metadata/strip.go | 176 +++++++++++++++++++++++------- pkg/metadata/strip_test.go | 214 +++++++++++++++++++++++++++++++++++++ pkg/send/send.go | 29 ++++- 3 files changed, 375 insertions(+), 44 deletions(-) create mode 100644 pkg/metadata/strip_test.go diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index ceade8f..5942df0 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -9,32 +9,105 @@ import ( "path/filepath" ) -// Strip strips metadata (EXIF, text chunks) from image files in place -// by writing a stripped copy to a temp file and replacing the original. -// Supported formats: JPEG, PNG. +// Strip strips metadata (EXIF, text chunks) from image files using a +// temp-file + rename strategy so the original is never overwritten in place. +// Supported formats: JPEG, PNG. Returns nil for non-image files. func Strip(path string) error { - ext := filepath.Ext(path) - switch ext { - case ".jpg", ".jpeg": - return stripJPEG(path) - case ".png": - return stripPNG(path) + tmp, err := stripToTemp(path) + if err != nil { + return err + } + if tmp == "" { + return nil + } + defer os.Remove(tmp) + return os.Rename(tmp, path) +} + +// StripTo writes a stripped copy of the source image to destPath. +// Both paths may be the same (caller should use Strip for that). +// Returns nil for non-image files without error. +func StripTo(srcPath, destPath string) error { + srcIsImage, err := isImageFile(srcPath) + if err != nil || !srcIsImage { + return err + } + + f, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("strip: open: %w", err) + } + defer f.Close() + + sig := make([]byte, 8) + if _, err := io.ReadFull(f, sig); err != nil { + return fmt.Errorf("strip: read sig: %w", err) + } + f.Close() + + switch { + case isJPEG(sig): + return stripJPEGTo(srcPath, destPath) + case isPNG(sig): + return stripPNGTo(srcPath, destPath) } return nil } -// stripJPEG removes APP1 (EXIF) and APP13 (Photoshop/IPTC) markers. -func stripJPEG(path string) error { +// stripToTemp strips metadata to a temp file in the same directory. +// Returns empty string if the file is not a supported image type. +func stripToTemp(path string) (string, error) { + srcIsImage, err := isImageFile(path) + if err != nil || !srcIsImage { + return "", err + } + + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".localgo-strip-*") + if err != nil { + return "", fmt.Errorf("strip: temp: %w", err) + } + tmpPath := tmp.Name() + tmp.Close() + os.Remove(tmpPath) + + if err := StripTo(path, tmpPath); err != nil { + os.Remove(tmpPath) + return "", err + } + return tmpPath, nil +} + +func isImageFile(path string) (bool, error) { f, err := os.Open(path) if err != nil { - return fmt.Errorf("strip: open: %w", err) + return false, fmt.Errorf("strip: open: %w", err) } defer f.Close() - fi, err := f.Stat() + sig := make([]byte, 8) + if _, err := io.ReadFull(f, sig); err != nil { + return false, nil + } + return isJPEG(sig) || isPNG(sig), nil +} + +func isJPEG(sig []byte) bool { + return len(sig) >= 2 && sig[0] == 0xFF && sig[1] == 0xD8 +} + +func isPNG(sig []byte) bool { + pngSig := []byte{137, 80, 78, 71, 13, 10, 26, 10} + return bytes.Equal(sig, pngSig) +} + +// stripJPEGTo removes APP1 (EXIF) and APP13 (Photoshop/IPTC) markers. +func stripJPEGTo(srcPath, destPath string) error { + f, err := os.Open(srcPath) if err != nil { - return fmt.Errorf("strip: stat: %w", err) + return fmt.Errorf("strip: open: %w", err) } + defer f.Close() var buf bytes.Buffer if _, err := io.Copy(&buf, f); err != nil { @@ -43,16 +116,15 @@ func stripJPEG(path string) error { f.Close() data := buf.Bytes() - - // Must start with SOI marker 0xFFD8 - if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 { - return nil // not a valid JPEG + if !isJPEG(data) { + return nil } var out bytes.Buffer out.Write(data[:2]) // SOI pos := 2 + sosSeen := false for pos+1 < len(data) { if data[pos] != 0xFF { break @@ -60,13 +132,12 @@ func stripJPEG(path string) error { marker := data[pos+1] - // SOS (Start of Scan) — everything after is compressed data, keep as-is if marker == 0xDA { out.Write(data[pos:]) + sosSeen = true break } - // Markers without length: SOI (0xD8), EOI (0xD9), TEM (0x01) if marker == 0xD9 || marker == 0x00 || marker == 0x01 { if pos+2 > len(data) { break @@ -79,17 +150,14 @@ func stripJPEG(path string) error { continue } - // All other markers have a 2-byte length (big-endian, includes itself) if pos+3 >= len(data) { break } segLen := int(binary.BigEndian.Uint16(data[pos+2:pos+4])) + 2 - if pos+segLen > len(data) { break } - // Skip APP1 (EXIF, 0xFFE1) and APP13 (Photoshop/IPTC, 0xFFED) if marker != 0xE1 && marker != 0xED { out.Write(data[pos : pos+segLen]) } @@ -97,22 +165,21 @@ func stripJPEG(path string) error { pos += segLen } - return os.WriteFile(path, out.Bytes(), fi.Mode()) + if !sosSeen { + return fmt.Errorf("strip: no SOS marker found in JPEG") + } + + return writeAtomic(destPath, out.Bytes()) } -// stripPNG removes tEXt, zTXt, and iTXt metadata chunks. -func stripPNG(path string) error { - f, err := os.Open(path) +// stripPNGTo removes tEXt, zTXt, iTXt, and eXIf metadata chunks. +func stripPNGTo(srcPath, destPath string) error { + f, err := os.Open(srcPath) if err != nil { return fmt.Errorf("strip: open: %w", err) } defer f.Close() - fi, err := f.Stat() - if err != nil { - return fmt.Errorf("strip: stat: %w", err) - } - var buf bytes.Buffer if _, err := io.Copy(&buf, f); err != nil { return fmt.Errorf("strip: read: %w", err) @@ -120,10 +187,7 @@ func stripPNG(path string) error { f.Close() data := buf.Bytes() - - // Must be a valid PNG: 8-byte signature - pngSig := []byte{137, 80, 78, 71, 13, 10, 26, 10} - if len(data) < 8 || !bytes.Equal(data[:8], pngSig) { + if len(data) < 8 || !isPNG(data[:8]) { return nil } @@ -131,6 +195,7 @@ func stripPNG(path string) error { out.Write(data[:8]) // signature pos := 8 + iendSeen := false for pos+4 <= len(data) { chunkLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) if pos+12+chunkLen > len(data) { @@ -138,15 +203,14 @@ func stripPNG(path string) error { } chunkType := string(data[pos+4 : pos+8]) - // Skip text chunks - if chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" { + if chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" || chunkType == "eXIf" { pos += 12 + chunkLen continue } - // IEND — end of image if chunkType == "IEND" { out.Write(data[pos : pos+12+chunkLen]) + iendSeen = true break } @@ -154,5 +218,37 @@ func stripPNG(path string) error { pos += 12 + chunkLen } - return os.WriteFile(path, out.Bytes(), fi.Mode()) + if !iendSeen { + return fmt.Errorf("strip: no IEND chunk found in PNG") + } + + return writeAtomic(destPath, out.Bytes()) +} + +// writeAtomic writes data to path via a temp file and rename. +func writeAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".localgo-write-*") + if err != nil { + return fmt.Errorf("strip: temp: %w", err) + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("strip: write: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("strip: sync: %w", err) + } + tmp.Close() + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("strip: rename: %w", err) + } + return nil } diff --git a/pkg/metadata/strip_test.go b/pkg/metadata/strip_test.go new file mode 100644 index 0000000..45ed2ba --- /dev/null +++ b/pkg/metadata/strip_test.go @@ -0,0 +1,214 @@ +package metadata + +import ( + "os" + "path/filepath" + "testing" +) + +// minimalJPEG is a valid 1x1 JPEG with APP1 (EXIF) metadata. +func minimalJPEG() []byte { + // SOI + APP1 (EXIF) + SOS + compressed data + EOI + exif := make([]byte, 8) + copy(exif, "Exif\000\000") // EXIF header + + app1Len := uint16(len(exif) + 2) // includes the 2-byte length field + body := []byte{ + 0xFF, 0xD8, // SOI + 0xFF, 0xE1, // APP1 marker + byte(app1Len >> 8), byte(app1Len & 0xFF), // length big-endian + } + body = append(body, exif...) + body = append(body, + 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, // SOS + 0x62, // compressed data + 0xFF, 0xD9, // EOI + ) + return body +} + +func TestStripTo_JPEG_RemovesEXIF(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "photo.jpg") + dest := filepath.Join(dir, "photo_clean.jpg") + if err := os.WriteFile(src, minimalJPEG(), 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := StripTo(src, dest); err != nil { + t.Fatalf("StripTo: %v", err) + } + + cleaned, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + + // Should still be a valid JPEG (SOI + SOS + ... + EOI) + if len(cleaned) < 4 || cleaned[0] != 0xFF || cleaned[1] != 0xD8 { + t.Error("missing SOI marker in stripped output") + } + if cleaned[len(cleaned)-2] != 0xFF || cleaned[len(cleaned)-1] != 0xD9 { + t.Error("missing EOI marker in stripped output") + } + + // Must be smaller than original (APP1 removed) + if len(cleaned) >= len(minimalJPEG()) { + t.Errorf("expected stripped file (%d bytes) to be smaller than original (%d bytes)", len(cleaned), len(minimalJPEG())) + } +} + +func TestStrip_OriginalBytesUnchanged(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "photo.jpg") + origBytes := minimalJPEG() + if err := os.WriteFile(src, origBytes, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := Strip(src); err != nil { + t.Fatalf("Strip: %v", err) + } + + reRead, err := os.ReadFile(src) + if err != nil { + t.Fatalf("re-read src: %v", err) + } + + // After Strip, the file should be modified (EXIF removed), but the file + // should still exist and be valid. Original bytes are not preserved by + // Strip (it replaces the file), but StripTo preserves the original. + if len(reRead) >= len(origBytes) { + t.Errorf("expected stripped file (%d bytes) to be smaller than original (%d bytes)", len(reRead), len(origBytes)) + } +} + +func TestStripTo_OriginalUnchanged(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "photo.jpg") + dest := filepath.Join(dir, "photo_clean.jpg") + origBytes := minimalJPEG() + if err := os.WriteFile(src, origBytes, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := StripTo(src, dest); err != nil { + t.Fatalf("StripTo: %v", err) + } + + // Original must be byte-identical + reRead, err := os.ReadFile(src) + if err != nil { + t.Fatalf("re-read src: %v", err) + } + if !bytesEqual(reRead, origBytes) { + t.Error("original file was modified by StripTo") + } +} + +func TestStripTo_TruncatedJPEG_ReturnsError(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "broken.jpg") + // JPEG with SOI + APP1 but no SOS + body := []byte{0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x08, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00} + if err := os.WriteFile(src, body, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + dest := filepath.Join(dir, "broken_clean.jpg") + if err := StripTo(src, dest); err == nil { + t.Error("expected error for truncated JPEG without SOS, got nil") + } + + // Dest should not exist + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest file should not exist after failed strip") + } +} + +func TestStripTo_PNG_eXIf(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "image.png") + // Minimal PNG with an eXIf chunk + // PNG signature + var png []byte + png = append(png, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) // sig + // eXIf chunk (length 4, type "eXIf", data "test", CRC) + exifChunk := buildPNGChunk("eXIf", []byte("test")) + png = append(png, exifChunk...) + // IEND chunk + iend := buildPNGChunk("IEND", nil) + png = append(png, iend...) + + if err := os.WriteFile(src, png, 0644); err != nil { + t.Fatalf("write src: %v", err) + } + + dest := filepath.Join(dir, "clean.png") + if err := StripTo(src, dest); err != nil { + t.Fatalf("StripTo: %v", err) + } + + cleaned, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + + // Should not contain eXIf + chunkType := string(cleaned[8:12]) + if chunkType == "eXIf" { + t.Error("eXIf chunk should have been stripped") + } + + // Original must be unchanged + orig, _ := os.ReadFile(src) + if !bytesEqual(orig, png) { + t.Error("original file was modified") + } +} + +func TestStripTo_NonImage_ReturnsNil(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "text.txt") + dest := filepath.Join(dir, "text_out.txt") + if err := os.WriteFile(src, []byte("hello"), 0644); err != nil { + t.Fatalf("write src: %v", err) + } + if err := StripTo(src, dest); err != nil { + t.Errorf("expected nil for non-image, got %v", err) + } + // Dest should not be created for non-image + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest should not exist for non-image") + } +} + +func TestStripTo_NonexistentFile_ReturnsError(t *testing.T) { + err := StripTo("/nonexistent/path.jpg", "/tmp/out.jpg") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func buildPNGChunk(chunkType string, data []byte) []byte { + length := uint32(len(data)) + var chunk []byte + chunk = append(chunk, byte(length>>24), byte(length>>16), byte(length>>8), byte(length)) + chunk = append(chunk, []byte(chunkType)...) + chunk = append(chunk, data...) + // CRC over chunk type + data (simplified — not validating) + crc := make([]byte, 4) + chunk = append(chunk, crc...) + return chunk +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/send/send.go b/pkg/send/send.go index 37e6f39..9817158 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -210,12 +210,33 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, return fmt.Errorf("failed to process file paths: %w", err) } - // Strip EXIF/metadata from image files in private mode + // Strip EXIF/metadata from image files in private mode. + // StripTo writes a stripped copy to a temp file; the original is never modified. + type strippedFile struct{ tempPath string } + var stripped []strippedFile + defer func() { + for _, s := range stripped { + os.Remove(s.tempPath) + } + }() + if cfg.Private { - for filePath := range fileMap { - if err := metadata.Strip(filePath); err != nil { - logger.Warnf("Failed to strip metadata from %s: %v", filePath, err) + for filePath, remoteName := range fileMap { + tmp, err := os.CreateTemp("", "localgo-private-*") + if err != nil { + return fmt.Errorf("private mode: create temp for %s: %w", filePath, err) } + tmpPath := tmp.Name() + tmp.Close() + + if err := metadata.StripTo(filePath, tmpPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("private mode: strip metadata for %s: %w", filePath, err) + } + + stripped = append(stripped, strippedFile{tempPath: tmpPath}) + fileMap[tmpPath] = remoteName + delete(fileMap, filePath) } } From 9e391285a4ed876786cd75b541e0d1cb2dc69361 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:37:33 +0300 Subject: [PATCH 04/12] fix(receive): sanitize control characters in filenames (Issue #23) sanitizeName strips ASCII control bytes 0x00-0x1F from FileName in PrepareUploadHandlerV2 after decoding the request, preventing UI spoofing and terminal escape injection via display prompts. Test: control-char filename in prepare-upload returns 200 (sanitized) --- pkg/server/handlers/receive_handlers.go | 19 ++++++++++++++ pkg/server/handlers/receive_handlers_test.go | 27 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 209f850..2dae7ea 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -8,6 +8,7 @@ import ( "net/http" "os/exec" "runtime" + "strings" "sync" "github.com/bethropolis/localgo/pkg/cli" @@ -74,6 +75,13 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } defer r.Body.Close() + // Sanitize filenames: strip control characters to prevent UI spoofing + // and terminal escape injection on display. + for id, f := range requestDto.Files { + f.FileName = sanitizeName(f.FileName) + requestDto.Files[id] = f + } + if len(requestDto.Files) == 0 { h.logger.Info("Received empty file list on prepare-upload, returning 204 Finished") w.WriteHeader(http.StatusNoContent) @@ -196,3 +204,14 @@ func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) } + +// sanitizeName strips ASCII control characters (0x00–0x1F) from filenames +// to prevent UI spoofing and terminal escape injection on display. +func sanitizeName(name string) string { + return strings.Map(func(r rune) rune { + if r <= 0x1F { + return -1 + } + return r + }, name) +} diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index 2019270..f67864b 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -384,3 +384,30 @@ func TestUploadHandlerV2_TextPlain_SaveFailure_Returns500(t *testing.T) { t.Errorf("expected 500 Internal Server Error for save failure, got %v (body: %s)", status, rr.Body.String()) } } + +func TestPrepareUpload_SanitizesControlChars(t *testing.T) { + handler, _, _ := setupReceiveHandler(t, nil) + + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: string([]byte{0x00, 'b', 0x01, 'a', 0x1F, 'd', '.', 't', 'x', 't'}), Size: 1}, + } + reqDto := model.PrepareUploadRequestDto{Files: files} + body, _ := json.Marshal(reqDto) + + req, _ := http.NewRequest(http.MethodPost, "/v2/prepare-upload", bytes.NewReader(body)) + req.RemoteAddr = "192.168.1.100:12345" + rr := httptest.NewRecorder() + + handler.PrepareUploadHandlerV2(rr, req) + + // Must succeed (sanitized filename is valid) + if status := rr.Code; status != http.StatusOK { + t.Fatalf("expected 200 OK, got %v (body: %s)", status, rr.Body.String()) + } + + var respDto model.PrepareUploadResponseDto + json.NewDecoder(rr.Body).Decode(&respDto) + if respDto.SessionID == "" { + t.Fatal("expected session ID") + } +} From 0f1704cbd6dd8abcf6e734ffed8b51abb18d0102 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:38:28 +0300 Subject: [PATCH 05/12] fix(receive): reject negative file sizes in prepare-upload (Issue #24) Guard against f.Size < 0 in PrepareUploadHandlerV2's disk-space loop. Without this check, a negative size reduces totalSize and bypasses the free-space guard, and the value flows into uint64 conversion which wraps to a large positive number. Test: prepare-upload with Size: -10 returns 400 Bad Request --- pkg/server/handlers/receive_handlers.go | 5 +++++ pkg/server/handlers/receive_handlers_test.go | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 2dae7ea..fd62c83 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -91,6 +91,11 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R // --- Check Disk Space --- var totalSize int64 for _, f := range requestDto.Files { + if f.Size < 0 { + h.logger.Warnf("Rejected transfer from %s: file '%s' has negative size (%d)", requestDto.Info.Alias, f.FileName, f.Size) + httputil.RespondError(w, http.StatusBadRequest, "Invalid file size") + return + } totalSize += f.Size } diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index f67864b..4071f45 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -411,3 +411,23 @@ func TestPrepareUpload_SanitizesControlChars(t *testing.T) { t.Fatal("expected session ID") } } + +func TestPrepareUploadHandlerV2_NegativeFileSize_Returns400(t *testing.T) { + handler, _, _ := setupReceiveHandler(t, nil) + + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: "neg.txt", Size: -10}, + } + reqDto := model.PrepareUploadRequestDto{Files: files} + body, _ := json.Marshal(reqDto) + + req, _ := http.NewRequest(http.MethodPost, "/v2/prepare-upload", bytes.NewReader(body)) + req.RemoteAddr = "192.168.1.100:12345" + rr := httptest.NewRecorder() + + handler.PrepareUploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for negative file size, got %v (body: %s)", status, rr.Body.String()) + } +} From 10cb072d8d0a03bca4d8e84f96cb7882ff2de833 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:39:08 +0300 Subject: [PATCH 06/12] fix(receive): CloseSession release mutex before Progress.Wait() Release sessionMutex before calling Progress.Wait() to match the pattern used by cleanupLoop and CloseAllSessions. This prevents stalling other operations (like concurrent ClaimFile calls) during the progress bar Wait, which can block on terminal rendering. --- pkg/server/services/receive_service.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index 042e277..0ef4986 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -168,14 +168,16 @@ func (s *ReceiveService) copySession(orig *ActiveReceiveSession) *ActiveReceiveS // CloseSession closes a specific session. func (s *ReceiveService) CloseSession(sessionID string) { s.sessionMutex.Lock() - defer s.sessionMutex.Unlock() - if session, ok := s.sessions[sessionID]; ok { - if session.Progress != nil { - session.Progress.ForceComplete() - session.Progress.Wait() - } + session, ok := s.sessions[sessionID] + if ok { delete(s.sessions, sessionID) } + s.sessionMutex.Unlock() + + if ok && session.Progress != nil { + session.Progress.ForceComplete() + session.Progress.Wait() + } } // ClaimFile atomically validates session, sender IP, file ID, and token, From 045d35d931df9356d0e967ea98e2be86c5323114 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:52:45 +0300 Subject: [PATCH 07/12] fix(send): only strip image files in private mode (non-image regression) Private mode previously created empty temp files for non-image files (PDFs, zips, text files) because StripTo returns nil for non-images (no error, no output). The empty temp then replaced the real file in the upload map, causing 0-byte uploads. Fix: export IsImageFile from metadata package; guard the strip loop so non-images keep their original path unchanged. --- pkg/metadata/strip.go | 7 ++++--- pkg/send/send.go | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index 5942df0..82f341a 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -28,7 +28,7 @@ func Strip(path string) error { // Both paths may be the same (caller should use Strip for that). // Returns nil for non-image files without error. func StripTo(srcPath, destPath string) error { - srcIsImage, err := isImageFile(srcPath) + srcIsImage, err := IsImageFile(srcPath) if err != nil || !srcIsImage { return err } @@ -57,7 +57,7 @@ func StripTo(srcPath, destPath string) error { // stripToTemp strips metadata to a temp file in the same directory. // Returns empty string if the file is not a supported image type. func stripToTemp(path string) (string, error) { - srcIsImage, err := isImageFile(path) + srcIsImage, err := IsImageFile(path) if err != nil || !srcIsImage { return "", err } @@ -78,7 +78,8 @@ func stripToTemp(path string) (string, error) { return tmpPath, nil } -func isImageFile(path string) (bool, error) { +// IsImageFile returns true if the file at path has a JPEG or PNG magic signature. +func IsImageFile(path string) (bool, error) { f, err := os.Open(path) if err != nil { return false, fmt.Errorf("strip: open: %w", err) diff --git a/pkg/send/send.go b/pkg/send/send.go index 9817158..c3b3a26 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -222,6 +222,10 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, if cfg.Private { for filePath, remoteName := range fileMap { + isImg, _ := metadata.IsImageFile(filePath) + if !isImg { + continue + } tmp, err := os.CreateTemp("", "localgo-private-*") if err != nil { return fmt.Errorf("private mode: create temp for %s: %w", filePath, err) From 8a3daa151c6167205e790232c68ec34d3cbc132d Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:55:19 +0300 Subject: [PATCH 08/12] fix(receive): reject empty filenames after sanitize, strengthen test After sanitizing control chars from filenames, reject names that become empty with 400 Bad Request (prevents downstream confusion). Strengthened TestPrepareUpload_SanitizesControlChars to assert the stored session filename is 'bad.txt' after sanitization (not just that prepare returns 200). --- pkg/server/handlers/receive_handlers.go | 5 +++ pkg/server/handlers/receive_handlers_test.go | 37 +++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index fd62c83..722ff3f 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -79,6 +79,11 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R // and terminal escape injection on display. for id, f := range requestDto.Files { f.FileName = sanitizeName(f.FileName) + if f.FileName == "" { + h.logger.Warnf("Rejected transfer from %s: file '%s' has empty name after sanitization", requestDto.Info.Alias, id) + httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") + return + } requestDto.Files[id] = f } diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index 4071f45..a35d251 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -386,7 +386,7 @@ func TestUploadHandlerV2_TextPlain_SaveFailure_Returns500(t *testing.T) { } func TestPrepareUpload_SanitizesControlChars(t *testing.T) { - handler, _, _ := setupReceiveHandler(t, nil) + handler, receiveService, _ := setupReceiveHandler(t, nil) files := map[string]model.FileDto{ "f1": {ID: "f1", FileName: string([]byte{0x00, 'b', 0x01, 'a', 0x1F, 'd', '.', 't', 'x', 't'}), Size: 1}, @@ -410,6 +410,41 @@ func TestPrepareUpload_SanitizesControlChars(t *testing.T) { if respDto.SessionID == "" { t.Fatal("expected session ID") } + + // Verify the stored filename was sanitized + session := receiveService.GetSession() + if session == nil { + t.Fatal("expected session to exist") + } + af, ok := session.Files["f1"] + if !ok { + t.Fatal("expected file f1 in session") + } + want := "bad.txt" + if af.Dto.FileName != want { + t.Errorf("stored FileName: got %q, want %q", af.Dto.FileName, want) + } +} + +func TestPrepareUpload_EmptyNameAfterSanitize_Returns400(t *testing.T) { + handler, _, _ := setupReceiveHandler(t, nil) + + // All-control filename becomes empty after sanitize + files := map[string]model.FileDto{ + "f1": {ID: "f1", FileName: string([]byte{0x00, 0x01, 0x02, 0x1F}), Size: 1}, + } + reqDto := model.PrepareUploadRequestDto{Files: files} + body, _ := json.Marshal(reqDto) + + req, _ := http.NewRequest(http.MethodPost, "/v2/prepare-upload", bytes.NewReader(body)) + req.RemoteAddr = "192.168.1.100:12345" + rr := httptest.NewRecorder() + + handler.PrepareUploadHandlerV2(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for all-control filename, got %v (body: %s)", status, rr.Body.String()) + } } func TestPrepareUploadHandlerV2_NegativeFileSize_Returns400(t *testing.T) { From 258dcac9662f905545a60eaa5a6e8318df3bf599 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:34:38 +0300 Subject: [PATCH 09/12] fix(clipboard): capture stderr in Read(), treat empty clipboard as empty not error - Use CombinedOutput() instead of Output() to capture stderr - Include tool name in both Read() and Write() error messages - Treat tool exit with no output as empty clipboard (xclip/wl-paste exit 1 when clipboard is empty) instead of returning a cryptic error - Expand 'no tool found' message with actionable install hints --- .gitignore | 2 +- pkg/clipboard/clipboard.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 0c249cf..0ae7bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ # Output directories .localgo_security/ downloads/ -scripts/*.log +*.log # Dependency directories (if vendoring) vendor/ diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go index a1bc9e4..4f9db0a 100644 --- a/pkg/clipboard/clipboard.go +++ b/pkg/clipboard/clipboard.go @@ -35,7 +35,7 @@ func Write(text string) error { cmd := exec.Command(provider.cmd, provider.args...) //nolint:gosec cmd.Stdin = strings.NewReader(text) if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("clipboard write failed: %w: %s", err, strings.TrimSpace(string(out))) + return fmt.Errorf("clipboard write failed (%s): %w: %s", provider.cmd, err, strings.TrimSpace(string(out))) } return nil } @@ -44,12 +44,17 @@ func Write(text string) error { // Returns an error when no suitable clipboard tool is available. func Read() (string, error) { if provider == nil || provider.readCmd == "" { - return "", fmt.Errorf("clipboard read unavailable: no supported tool found") + return "", fmt.Errorf("clipboard read unavailable: no supported tool found (install xclip, xsel, wl-paste, pbpaste, or Get-Clipboard)") } cmd := exec.Command(provider.readCmd, provider.readArgs...) //nolint:gosec - out, err := cmd.Output() + out, err := cmd.CombinedOutput() if err != nil { - return "", fmt.Errorf("clipboard read failed: %w", err) + // Some tools (xclip, wl-paste) exit with 1 when the clipboard is empty + // and produce no output. Treat this as empty, not an error. + if len(out) == 0 { + return "", nil + } + return "", fmt.Errorf("clipboard read failed (%s): %w: %s", provider.readCmd, err, strings.TrimSpace(string(out))) } // Normalize Windows CRLF line endings to unix LF return strings.ReplaceAll(string(out), "\r\n", "\n"), nil From ffde0a27d0efb8983db633c717fcd34c0f3a95bb Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:44:49 +0300 Subject: [PATCH 10/12] feat(send): send clipboard/stdin as in-memory content (no temp file) Remove the temp file creation for --clipboard and --stdin flags, and instead thread raw byte content through the send pipeline via a new SendOption/WithInMemoryFile mechanism. Changes: - pkg/send/send.go: Add SendOption, WithInMemoryFile, sendConfig, memFile types. SendToDevice and SendFiles now accept variadic SendOption. Process in-memory files alongside file-based files in the fileDto build loop and upload goroutines. - pkg/send/upload.go: Add memReadSeekCloser (bytes.Reader wrapper with no-op Close), fileReader interface. Extract uploadStream() from uploadFile() so both file and in-memory uploads share the same stream logic. - cmd/localgo/cmd/send.go: Replace os.CreateTemp/defer os.Remove with send.WithInMemoryFile() calls. Remove the localgo-clip- prefix hack entirely. Adjust file picker/empty checks to account for sendOpts. --- cmd/localgo/cmd/send.go | 41 +++----------- pkg/send/send.go | 123 +++++++++++++++++++++++++++++----------- pkg/send/upload.go | 32 +++++++++-- 3 files changed, 126 insertions(+), 70 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 9050364..2e8f8ca 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -42,6 +42,7 @@ var sendCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { files := sendfiles + var sendOpts []send.SendOption if sendclipboard && sendstdin { return fmt.Errorf("cannot use both --clipboard and --stdin") @@ -55,19 +56,7 @@ var sendCmd = &cobra.Command{ if len(textBytes) == 0 { return fmt.Errorf("standard input is empty") } - - tempFile, err := os.CreateTemp("", "localgo-clip-stdin-*.txt") - if err != nil { - return fmt.Errorf("failed to create temporary file for stdin: %w", err) - } - defer os.Remove(tempFile.Name()) - - if _, err := tempFile.Write(textBytes); err != nil { - tempFile.Close() - return fmt.Errorf("failed to write standard input content: %w", err) - } - tempFile.Close() - files = []string{tempFile.Name()} + sendOpts = append(sendOpts, send.WithInMemoryFile("stdin.txt", textBytes)) } if sendclipboard { @@ -78,34 +67,22 @@ var sendCmd = &cobra.Command{ if strings.TrimSpace(text) == "" { return fmt.Errorf("clipboard is empty or does not contain text") } - - tempFile, err := os.CreateTemp("", "localgo-clip-*.txt") - if err != nil { - return fmt.Errorf("failed to create temporary file for clipboard: %w", err) - } - defer os.Remove(tempFile.Name()) - - if _, err := tempFile.WriteString(text); err != nil { - tempFile.Close() - return fmt.Errorf("failed to write clipboard text: %w", err) - } - tempFile.Close() - files = []string{tempFile.Name()} + sendOpts = append(sendOpts, send.WithInMemoryFile("clipboard.txt", []byte(text))) } - if len(files) == 0 { + if len(files) == 0 && len(sendOpts) == 0 { selected, err := cli.LaunchFilePicker() if err == nil && selected != "" { files = []string{selected} } } - if len(files) == 0 { + if len(files) == 0 && len(sendOpts) == 0 { return fmt.Errorf("no file specified: use --file flag, --clipboard, or select from the file browser") } for _, file := range files { - if _, err := os.Stat(file); os.IsNotExist(err) && !sendclipboard { + if _, err := os.Stat(file); os.IsNotExist(err) && len(sendOpts) == 0 { return fmt.Errorf("file not found: %s", file) } } @@ -165,7 +142,7 @@ var sendCmd = &cobra.Command{ ctx, cancel := context.WithTimeout(context.Background(), time.Duration(sendtimeout)*time.Second) defer cancel() - if err := send.SendToDevice(ctx, Cfg, device, files, zap.S()); err != nil { + if err := send.SendToDevice(ctx, Cfg, device, files, zap.S(), sendOpts...); err != nil { return fmt.Errorf("failed to send files: %w", err) } @@ -276,11 +253,11 @@ var sendCmd = &cobra.Command{ if selectedDevice != nil { cli.PrintInfo("To: %s (%s:%d)", selectedDevice.Alias, selectedDevice.IP, selectedDevice.Port) cli.PrintInfo("From: %s", fromAlias) - err = send.SendToDevice(ctx, Cfg, selectedDevice, files, zap.S()) + err = send.SendToDevice(ctx, Cfg, selectedDevice, files, zap.S(), sendOpts...) } else { cli.PrintInfo("To: %s", target) cli.PrintInfo("From: %s", fromAlias) - err = send.SendFiles(ctx, Cfg, files, target, sendport, zap.S()) + err = send.SendFiles(ctx, Cfg, files, target, sendport, zap.S(), sendOpts...) } if err != nil { return fmt.Errorf("failed to send files: %w", err) diff --git a/pkg/send/send.go b/pkg/send/send.go index c3b3a26..ff5c504 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -27,8 +27,27 @@ import ( "go.uber.org/zap" ) +// SendOption configures the send pipeline. +type SendOption func(*sendConfig) + +type sendConfig struct { + memFiles []memFile +} + +type memFile struct { + name string + content []byte +} + +// WithInMemoryFile adds an in-memory file (no disk I/O) to the send. +func WithInMemoryFile(name string, content []byte) SendOption { + return func(c *sendConfig) { + c.memFiles = append(c.memFiles, memFile{name: name, content: content}) + } +} + // SendFiles sends files or directories to a recipient. -func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, recipientAlias string, recipientPort int, logger *zap.SugaredLogger) error { +func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, recipientAlias string, recipientPort int, logger *zap.SugaredLogger, opts ...SendOption) error { if logger == nil { logger = zap.NewNop().Sugar() } @@ -92,7 +111,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { return err } - return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) + return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...) } registerDto := cfg.ToRegisterDto() @@ -136,10 +155,10 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci return err } - return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) + return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...) } -func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger) error { +func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger, opts ...SendOption) error { if logger == nil { logger = zap.NewNop().Sugar() } @@ -205,6 +224,11 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, defer tr.CloseIdleConnections() } + var sc sendConfig + for _, opt := range opts { + opt(&sc) + } + fileMap, err := getFilesWithRelativePaths(filePaths) if err != nil { return fmt.Errorf("failed to process file paths: %w", err) @@ -246,6 +270,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filesDtoMap := make(map[string]model.FileDto) filePathMap := make(map[string]string) + memReaders := make(map[string]*memReadSeekCloser) for filePath, remoteName := range fileMap { fileInfo, err := os.Stat(filePath) @@ -267,12 +292,6 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, remoteName = anonymizeFileName(contentType) } - // If this is a temporary clipboard file, sanitize display name to text_transfer.txt - if strings.HasPrefix(filepath.Base(filePath), "localgo-clip-") { - remoteName = "text_transfer.txt" - contentType = "text/plain" - } - modTime := fileInfo.ModTime().Format(time.RFC3339) var metadataPtr *model.FileMetadata @@ -292,6 +311,26 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePathMap[fileDto.ID] = filePath } + for _, mf := range sc.memFiles { + id := uuid.NewString() + remoteName := mf.name + contentType := "text/plain" + + if cfg.Private { + remoteName = anonymizeFileName(contentType) + } + + fileDto := model.FileDto{ + ID: id, + FileName: remoteName, + Size: int64(len(mf.content)), + FileType: contentType, + } + + filesDtoMap[id] = fileDto + memReaders[id] = &memReadSeekCloser{bytes.NewReader(mf.content)} + } + infoAlias := cfg.Alias infoDeviceModel := cfg.DeviceModel infoDeviceType := cfg.DeviceType @@ -359,32 +398,50 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, sem := make(chan struct{}, concurrency) for fileID, token := range prepareResponse.Files { - filePath, exists := filePathMap[fileID] - if !exists { - logger.Warnf("Server responded with unknown file ID: %s", fileID) - continue - } - - var fileSize int64 - if fi, err := os.Stat(filePath); err == nil { - fileSize = fi.Size() - } - trackProgress := mp.AddBar(filepath.Base(filePath), fileSize) + if reader, ok := memReaders[fileID]; ok { + displayName := filesDtoMap[fileID].FileName + fileSize := filesDtoMap[fileID].Size + trackProgress := mp.AddBar(displayName, fileSize) + + wg.Add(1) + go func(fID, tkn string, rdr *memReadSeekCloser, sz int64, name string, track func(int64)) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + logger.Infof("Uploading in-memory file: %s", name) + err := uploadStream(ctx, client, device, rdr, sz, fID, prepareResponse.SessionID, tkn, scheme, track, logger) + if err != nil { + logger.Errorf("Failed to upload %s: %v", name, err) + errCh <- fmt.Errorf("failed to upload %s: %w", name, err) + } + }(fileID, token, reader, fileSize, displayName, trackProgress) + } else if filePath, exists := filePathMap[fileID]; exists { + var fileSize int64 + if fi, err := os.Stat(filePath); err == nil { + fileSize = fi.Size() + } + trackProgress := mp.AddBar(filepath.Base(filePath), fileSize) - wg.Add(1) - go func(fID, tkn, fPath string, track func(int64)) { - defer wg.Done() + wg.Add(1) + go func(fID, tkn, fPath string, track func(int64)) { + defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() + sem <- struct{}{} + defer func() { <-sem }() - logger.Infof("Uploading file: %s", filepath.Base(fPath)) - err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, track, logger) - if err != nil { - logger.Errorf("Failed to upload file %s: %v", filepath.Base(fPath), err) - errCh <- fmt.Errorf("failed to upload %s: %w", filepath.Base(fPath), err) - } - }(fileID, token, filePath, trackProgress) + logger.Infof("Uploading file: %s", filepath.Base(fPath)) + err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, track, logger) + if err != nil { + logger.Errorf("Failed to upload file %s: %v", filepath.Base(fPath), err) + errCh <- fmt.Errorf("failed to upload %s: %w", filepath.Base(fPath), err) + } + }(fileID, token, filePath, trackProgress) + } else { + logger.Warnf("Server responded with unknown file ID: %s", fileID) + continue + } } wg.Wait() diff --git a/pkg/send/upload.go b/pkg/send/upload.go index 7cabf28..a23145a 100644 --- a/pkg/send/upload.go +++ b/pkg/send/upload.go @@ -1,6 +1,7 @@ package send import ( + "bytes" "context" "errors" "fmt" @@ -15,6 +16,19 @@ import ( "go.uber.org/zap" ) +// memReadSeekCloser wraps a *bytes.Reader to implement io.ReadSeekCloser. +type memReadSeekCloser struct { + *bytes.Reader +} + +func (m *memReadSeekCloser) Close() error { return nil } + +// fileReader is satisfied by both *os.File and *memReadSeekCloser. +type fileReader interface { + io.ReadSeeker + io.Closer +} + func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error { if logger == nil { logger = zap.NewNop().Sugar() @@ -26,17 +40,25 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device, } defer file.Close() - url := fmt.Sprintf("%s://%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)), sessionID, fileID, token) - stat, err := file.Stat() if err != nil { return fmt.Errorf("failed to get file stats: %w", err) } - var body io.ReadCloser = file + return uploadStream(ctx, client, device, file, stat.Size(), fileID, sessionID, token, scheme, trackProgress, logger) +} + +func uploadStream(ctx context.Context, client *http.Client, device *model.Device, r fileReader, size int64, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error { + if logger == nil { + logger = zap.NewNop().Sugar() + } + + url := fmt.Sprintf("%s://%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)), sessionID, fileID, token) + + var body io.ReadCloser = io.NopCloser(r) if trackProgress != nil { bar := &progressBar{current: 0, track: trackProgress} - body = &progressTracker{Reader: file, Closer: file, bar: bar} + body = &progressTracker{Reader: r, Closer: r, bar: bar} } // Wrap with idle timeout: cancel request if no data flows for 15s @@ -50,7 +72,7 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device, return fmt.Errorf("failed to create upload request: %w", err) } req.Header.Set("Content-Type", "application/octet-stream") - req.ContentLength = stat.Size() + req.ContentLength = size resp, err := client.Do(req) if err != nil { From b1351d8fd78b98ebc18e8da12050fb88435b34be Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:08:47 +0300 Subject: [PATCH 11/12] feat(clipboard): send text via Preview field, receiver responds 204 --- pkg/send/send.go | 9 ++++++ pkg/server/handlers/prompt.go | 37 +++++++++++++++++++++++++ pkg/server/handlers/receive_handlers.go | 30 ++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/pkg/send/send.go b/pkg/send/send.go index ff5c504..f9f668a 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -320,11 +320,13 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, remoteName = anonymizeFileName(contentType) } + preview := string(mf.content) fileDto := model.FileDto{ ID: id, FileName: remoteName, Size: int64(len(mf.content)), FileType: contentType, + Preview: &preview, } filesDtoMap[id] = fileDto @@ -377,6 +379,13 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, } defer resp.Body.Close() + // 204 No Content means the receiver accepted a clipboard message + // and no file upload is needed (content was in the Preview field). + if resp.StatusCode == http.StatusNoContent { + logger.Info("Clipboard message accepted by receiver, no upload needed") + return nil + } + if resp.StatusCode != http.StatusOK { return fmt.Errorf("prepare request failed with status: %s", resp.Status) } diff --git a/pkg/server/handlers/prompt.go b/pkg/server/handlers/prompt.go index 3961857..5835a1c 100644 --- a/pkg/server/handlers/prompt.go +++ b/pkg/server/handlers/prompt.go @@ -82,3 +82,40 @@ func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files return accept } + +func (h *ReceiveHandler) promptForClipboard(alias, remoteAddr, message string) bool { + if cli.IsContainer() { + return false + } + cli.Notify("LocalGo: Clipboard Message", + fmt.Sprintf("%s sent clipboard text (%d chars)", alias, len(message))) + + truncated := message + if len(truncated) > 500 { + truncated = truncated[:500] + "\n… (truncated)" + } + + desc := fmt.Sprintf("From: %s (IP: %s)\n\nClipboard:\n%s", alias, remoteAddr, truncated) + + var accept bool = true + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Accept Clipboard?"). + Description(desc). + Value(&accept). + Affirmative("Accept & Copy"). + Negative("Reject"), + ), + ).WithTheme(huh.ThemeCharm()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := form.RunWithContext(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "\n%s Clipboard automatically rejected.\n", cli.WarningStyle.Render(cli.IconWarning)) + return false + } + return accept +} diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index 722ff3f..530f9ac 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -12,6 +12,7 @@ import ( "sync" "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/clipboard" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/history" "github.com/bethropolis/localgo/pkg/httputil" @@ -93,6 +94,35 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R return } + // --- Clipboard Message Detection --- + // The official LocalSend embeds clipboard text in the Preview field. + // When detected, no file upload is needed — the content is already here. + var clipboardMessage string + for _, f := range requestDto.Files { + if f.Preview != nil && *f.Preview != "" && strings.HasPrefix(f.FileType, "text/plain") { + clipboardMessage = *f.Preview + break + } + } + + if clipboardMessage != "" { + h.logger.Infof("Clipboard message from %s accepted and copied", requestDto.Info.Alias) + if !h.config.AutoAccept { + h.promptMutex.Lock() + accepted := h.promptForClipboard(requestDto.Info.Alias, r.RemoteAddr, clipboardMessage) + h.promptMutex.Unlock() + if !accepted { + httputil.RespondError(w, http.StatusForbidden, "Rejected") + return + } + } + if !h.config.NoClipboard { + clipboard.Write(clipboardMessage) + } + w.WriteHeader(http.StatusNoContent) + return + } + // --- Check Disk Space --- var totalSize int64 for _, f := range requestDto.Files { From e619e426ded61665619e7032227bfc41573ae59e Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:27:30 +0300 Subject: [PATCH 12/12] fix(test): resolve data race in TestReceiveService_ClaimFile_Concurrent Evaluate session.SessionID and file token before goroutines to avoid concurrent map access without the session mutex. --- pkg/server/services/receive_service_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 258b9d1..827b783 100644 --- a/pkg/server/services/receive_service_test.go +++ b/pkg/server/services/receive_service_test.go @@ -192,6 +192,11 @@ func TestReceiveService_ClaimFile_Concurrent(t *testing.T) { } session, _ := svc.CreateSession(sender, files) + // Evaluate args before goroutines to avoid data race + // on the shared session's Files map. + sid := session.SessionID + token := session.Files["f1"].Token + var wg sync.WaitGroup results := make(chan error, 2) @@ -199,7 +204,7 @@ func TestReceiveService_ClaimFile_Concurrent(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, _, err := svc.ClaimFile(session.SessionID, "f1", session.Files["f1"].Token, "10.0.0.1") + _, _, err := svc.ClaimFile(sid, "f1", token, "10.0.0.1") results <- err }() }