| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296 |
- // 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)
- }
|