// Package send implements the LocalSend v2.2 sending side. // // It is a trimmed, zero-external-dependency port of the official localgo // send pipeline: given a known target device (from discovery) and a list of // local file paths, it performs /prepare-upload and then streams each file // over /upload using the LocalSend v2.2 protocol (mTLS client cert + optional // server fingerprint pinning). package send import ( "context" "crypto/rand" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "TengTuToolbox/localsend/crypto" "TengTuToolbox/localsend/logging" "TengTuToolbox/localsend/model" ) // ProtocolVersion is the LocalSend protocol version implemented here. const ProtocolVersion = "2.2" // SendConfig carries the sender identity + security context. type SendConfig struct { Alias string Port int // local listen port (announced to peers) SecurityContext *crypto.StoredSecurityContext HttpsEnabled bool Concurrency int // max parallel uploads (0 = default 4) } // SendFiles sends the given local file/directory paths to a target device. func SendFiles(ctx context.Context, cfg *SendConfig, device *model.Device, filePaths []string, logger *logging.Logger) error { if logger == nil { logger = logging.NewQuiet() } if cfg == nil { return errors.New("send: nil SendConfig") } if device == nil { return errors.New("send: nil target device") } if len(filePaths) == 0 { return errors.New("send: no files to send") } if cfg.Port <= 0 { cfg.Port = model.DefaultPort } client, scheme, err := newHTTPClient(cfg, device) if err != nil { return err } fileMap, err := getFilesWithRelativePaths(filePaths) if err != nil { return fmt.Errorf("failed to process file paths: %w", err) } filesDtoMap, filePathMap, err := buildFileDTOs(fileMap) if err != nil { return err } fingerprint := cfg.SecurityContext.CertificateHash prepareDto := model.PrepareUploadRequestDto{ Info: model.InfoDto{ Alias: cfg.Alias, Version: ProtocolVersion, DeviceType: model.DeviceTypeDesktop, Fingerprint: fingerprint, Port: device.Port, Protocol: model.ProtocolType(scheme), Download: true, }, Files: filesDtoMap, } jsonData, err := json.Marshal(prepareDto) if err != nil { return fmt.Errorf("failed to marshal prepare dto: %w", err) } prepareResponse, err := doPrepare(ctx, client, device, scheme, jsonData) if err != nil { return err } concurrency := cfg.Concurrency if concurrency <= 0 { concurrency = 4 } sem := make(chan struct{}, concurrency) var wg sync.WaitGroup errCh := make(chan error, len(prepareResponse.Files)) for fileID, token := range prepareResponse.Files { filePath, ok := filePathMap[fileID] if !ok { logger.Warnf("Server responded with unknown file ID: %s", fileID) continue } displayName := filepath.Base(filePath) if remoteName, ok := fileMap[filePath]; ok && remoteName != "" { displayName = remoteName } wg.Add(1) go func(fID, tkn, fPath, name string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() logger.Infof("Uploading file: %s", name) if err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, logger); err != nil { logger.Errorf("Failed to upload %s: %v", name, err) errCh <- fmt.Errorf("failed to upload %s: %w", name, err) } }(fileID, token, filePath, displayName) } wg.Wait() close(errCh) var uploadErrors []error for err := range errCh { uploadErrors = append(uploadErrors, err) } if len(uploadErrors) > 0 { return fmt.Errorf("encountered %d upload error(s), first error: %w", len(uploadErrors), uploadErrors[0]) } logger.Info("All files uploaded successfully!") return nil } // newHTTPClient builds an http.Client that presents our mTLS client cert and, // when the target fingerprint is known, pins the server certificate. func newHTTPClient(cfg *SendConfig, device *model.Device) (*http.Client, string, error) { var getClientCert func(*tls.CertificateRequestInfo) (*tls.Certificate, error) if cfg.SecurityContext != nil { fn, err := cfg.SecurityContext.ClientCertificateFunc() if err != nil { return nil, "", fmt.Errorf("failed to load client TLS certificate: %w", err) } getClientCert = fn } scheme := "http" if device.Protocol == model.ProtocolTypeHTTPS { scheme = "https" tlsConfig := &tls.Config{ InsecureSkipVerify: true, GetClientCertificate: getClientCert, } if device.Fingerprint != "" { expected := device.Fingerprint tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { if len(state.PeerCertificates) == 0 { return errors.New("no peer certificates presented") } cert := state.PeerCertificates[0] hash := sha256.Sum256(cert.Raw) actual := hex.EncodeToString(hash[:]) if !strings.EqualFold(actual, expected) { return fmt.Errorf("TLS certificate fingerprint mismatch: expected %s, got %s", expected, actual) } return nil } } tr := &http.Transport{TLSClientConfig: tlsConfig} return &http.Client{Transport: tr}, scheme, nil } // HTTP fallback (unlikely for LocalSend but supported) return &http.Client{}, scheme, nil } // doPrepare posts /prepare-upload and decodes the session response. func doPrepare(ctx context.Context, client *http.Client, device *model.Device, scheme string, jsonData []byte) (*model.PrepareUploadResponseDto, error) { endpoint := fmt.Sprintf("%s://%s/api/localsend/v2/prepare-upload", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port))) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(jsonData))) if err != nil { return nil, fmt.Errorf("failed to create prepare request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to send prepare request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("prepare request failed with status: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) } var prepareResponse model.PrepareUploadResponseDto if err := json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(&prepareResponse); err != nil { return nil, fmt.Errorf("failed to decode prepare response: %w", err) } return &prepareResponse, nil } // getFilesWithRelativePaths expands a list of paths (files or dirs) into a // map of localPath -> remote relative name (slash-separated). func getFilesWithRelativePaths(paths []string) (map[string]string, error) { result := make(map[string]string) for _, p := range paths { p = filepath.Clean(p) info, err := os.Stat(p) if err != nil { return nil, err } if info.IsDir() { baseDir := filepath.Dir(p) err = filepath.Walk(p, func(path string, fInfo os.FileInfo, err error) error { if err != nil { return err } if !fInfo.IsDir() { rel, err := filepath.Rel(baseDir, path) if err == nil { result[path] = filepath.ToSlash(rel) } else { result[path] = filepath.Base(path) } } return nil }) if err != nil { return nil, err } } else { result[p] = filepath.Base(p) } } return result, nil } // buildFileDTOs builds the FileDto map and the fileID -> localPath map. func buildFileDTOs(fileMap map[string]string) (map[string]model.FileDto, map[string]string, error) { filesDtoMap := make(map[string]model.FileDto) filePathMap := make(map[string]string) for filePath, remoteName := range fileMap { fileInfo, err := os.Stat(filePath) if err != nil { return nil, nil, fmt.Errorf("failed to get file info for %s: %w", filePath, err) } file, err := os.Open(filePath) if err != nil { return nil, nil, fmt.Errorf("failed to open file for detection %s: %w", filePath, err) } buffer := make([]byte, 512) n, _ := file.Read(buffer) file.Close() contentType := http.DetectContentType(buffer[:n]) modTime := fileInfo.ModTime().Format(time.RFC3339) fileDto := model.FileDto{ ID: randomID(), FileName: remoteName, Size: fileInfo.Size(), FileType: contentType, Metadata: &model.FileMetadata{Modified: &modTime}, } filesDtoMap[fileDto.ID] = fileDto filePathMap[fileDto.ID] = filePath } return filesDtoMap, filePathMap, nil } // randomID generates a random hex string (used as file/session IDs, no uuid dep). func randomID() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%x", time.Now().UnixNano()) } return hex.EncodeToString(b) }