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/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/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 diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index ceade8f..82f341a 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -9,32 +9,106 @@ 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 +} + +// 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 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 +117,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 +133,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 +151,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 +166,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 +188,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 +196,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 +204,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 +219,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..f9f668a 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,22 +224,53 @@ 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) } - // 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 { + 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) + } + 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) } } 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) @@ -242,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 @@ -267,6 +311,28 @@ 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) + } + + preview := string(mf.content) + fileDto := model.FileDto{ + ID: id, + FileName: remoteName, + Size: int64(len(mf.content)), + FileType: contentType, + Preview: &preview, + } + + filesDtoMap[id] = fileDto + memReaders[id] = &memReadSeekCloser{bytes.NewReader(mf.content)} + } + infoAlias := cfg.Alias infoDeviceModel := cfg.DeviceModel infoDeviceType := cfg.DeviceType @@ -313,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) } @@ -334,32 +407,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 { 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 209f850..530f9ac 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -8,9 +8,11 @@ import ( "net/http" "os/exec" "runtime" + "strings" "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" @@ -74,15 +76,61 @@ 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) + 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 + } + if len(requestDto.Files) == 0 { h.logger.Info("Received empty file list on prepare-upload, returning 204 Finished") w.WriteHeader(http.StatusNoContent) 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 { + 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 } @@ -196,3 +244,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 ee8282c..a35d251 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -314,3 +314,155 @@ 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()) + } +} + +func TestPrepareUpload_SanitizesControlChars(t *testing.T) { + 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}, + } + 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") + } + + // 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) { + 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()) + } +} diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go index 14f5600..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,56 +132,59 @@ 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. - h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) + 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 + } + 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. -func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) { +// 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) @@ -195,20 +196,20 @@ 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, ) 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 + 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 } // shutdownAwareReader aborts Read when the shutdown context is cancelled, diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index aefe173..0ef4986 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. @@ -148,15 +167,98 @@ func (s *ReceiveService) copySession(orig *ActiveReceiveSession) *ActiveReceiveS // CloseSession closes a specific session. func (s *ReceiveService) CloseSession(sessionID string) { + s.sessionMutex.Lock() + 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, +// 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() - if session, ok := s.sessions[sessionID]; ok { - if session.Progress != nil { - session.Progress.ForceComplete() - session.Progress.Wait() - } + + 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. diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 57993f4..827b783 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,119 @@ 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) + + // 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) + + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _, err := svc.ClaimFile(sid, "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()