summaryrefslogtreecommitdiffhomepage
path: root/tailsync/tailsyncimpl/service.go
diff options
context:
space:
mode:
Diffstat (limited to 'tailsync/tailsyncimpl/service.go')
-rw-r--r--tailsync/tailsyncimpl/service.go135
1 files changed, 120 insertions, 15 deletions
diff --git a/tailsync/tailsyncimpl/service.go b/tailsync/tailsyncimpl/service.go
index 83a78a7bc..f5ab30752 100644
--- a/tailsync/tailsyncimpl/service.go
+++ b/tailsync/tailsyncimpl/service.go
@@ -5,9 +5,12 @@
package tailsyncimpl
import (
+ "bytes"
"encoding/json"
"fmt"
"io"
+ "mime"
+ "mime/multipart"
"net/http"
"os"
"path/filepath"
@@ -25,10 +28,12 @@ const ConflictDir = ".tailsync-conflicts"
type Service struct {
logf logger.Logf
- mu sync.RWMutex
- roots map[string]*tailsync.Root
- sessions map[string]*sessionRunner
- closed bool
+ mu sync.RWMutex
+ roots map[string]*tailsync.Root
+ sessions map[string]*sessionRunner
+ closed bool
+ transport http.RoundTripper
+ peerURL tailsync.PeerURLFunc
}
// NewService creates a new tailsync Service.
@@ -43,6 +48,13 @@ func NewService(logf logger.Logf) *Service {
}
}
+func (s *Service) SetTransport(transport http.RoundTripper, peerURL tailsync.PeerURLFunc) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.transport = transport
+ s.peerURL = peerURL
+}
+
func (s *Service) SetRoot(root *tailsync.Root) error {
name, err := tailsync.NormalizeRootName(root.Name)
if err != nil {
@@ -131,7 +143,7 @@ func (s *Service) SetSession(session *tailsync.Session) error {
existing.stop()
}
- sr := newSessionRunner(s.logf, session, root)
+ sr := newSessionRunner(s.logf, session, root, s.transport, s.peerURL)
s.sessions[session.Name] = sr
go sr.run()
@@ -196,6 +208,8 @@ func (s *Service) ServeHTTPWithPerms(permissions tailsync.Permissions, w http.Re
s.handleRemotePush(permissions, w, r)
case "pull":
s.handleRemotePull(permissions, w, r)
+ case "file":
+ s.handleRemoteFile(permissions, w, r)
default:
http.Error(w, "unknown action", http.StatusNotFound)
}
@@ -247,12 +261,6 @@ func (s *Service) handleRemoteIndex(permissions tailsync.Permissions, w http.Res
w.Write(data)
}
-// pushRequest is the JSON payload for a push.
-type pushRequest struct {
- RootName string `json:"root"`
- Entries []*tailsync.FileEntry `json:"entries"`
-}
-
func (s *Service) handleRemotePush(permissions tailsync.Permissions, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -273,14 +281,63 @@ func (s *Service) handleRemotePush(permissions tailsync.Permissions, w http.Resp
return
}
- var req pushRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
+ contentType := r.Header.Get("Content-Type")
+ mediaType, params, err := mime.ParseMediaType(contentType)
+ if err != nil || !strings.HasPrefix(mediaType, "multipart/") {
+ http.Error(w, "expected multipart content", http.StatusBadRequest)
+ return
+ }
+
+ mr := multipart.NewReader(r.Body, params["boundary"])
+
+ // First part: JSON metadata.
+ metaPart, err := mr.NextPart()
+ if err != nil {
+ http.Error(w, "read metadata part: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ var entries []*tailsync.FileEntry
+ if err := json.NewDecoder(metaPart).Decode(&entries); err != nil {
+ http.Error(w, "decode metadata: "+err.Error(), http.StatusBadRequest)
return
}
+ metaPart.Close()
+
+ // Build a map of paths that need file data.
+ needsData := make(map[string]bool)
+ for _, entry := range entries {
+ if !entry.Deleted && !entry.IsSymlink {
+ needsData[entry.Path] = true
+ }
+ }
+
+ // Read file data parts.
+ fileData := make(map[string][]byte)
+ for len(needsData) > 0 {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ s.logf("tailsync: push: read file part: %v", err)
+ break
+ }
+ path := part.Header.Get("X-Tailsync-Path")
+ if path == "" {
+ path = part.FileName()
+ }
+ data, err := io.ReadAll(part)
+ part.Close()
+ if err != nil {
+ s.logf("tailsync: push: read file data for %s: %v", path, err)
+ continue
+ }
+ fileData[path] = data
+ delete(needsData, path)
+ }
applied := 0
- for _, entry := range req.Entries {
+ for _, entry := range entries {
if entry.Deleted {
absPath := filepath.Join(root.Path, entry.Path)
if err := os.Remove(absPath); err != nil && !os.IsNotExist(err) {
@@ -289,6 +346,24 @@ func (s *Service) handleRemotePush(permissions tailsync.Permissions, w http.Resp
applied++
continue
}
+ if entry.IsSymlink {
+ applied++
+ continue
+ }
+ data, ok := fileData[entry.Path]
+ if !ok {
+ s.logf("tailsync: push: missing file data for %s", entry.Path)
+ continue
+ }
+ absPath := filepath.Join(root.Path, entry.Path)
+ mode := entry.Mode
+ if mode == 0 {
+ mode = 0o644
+ }
+ if err := fileWriter(absPath, bytes.NewReader(data), mode); err != nil {
+ s.logf("tailsync: push: write %s: %v", entry.Path, err)
+ continue
+ }
applied++
}
@@ -327,6 +402,36 @@ func (s *Service) handleRemotePull(permissions tailsync.Permissions, w http.Resp
json.NewEncoder(w).Encode(entries)
}
+func (s *Service) handleRemoteFile(permissions tailsync.Permissions, w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ rootName := r.URL.Query().Get("root")
+ if permissions.For(rootName) == tailsync.PermissionNone {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ relPath := r.URL.Query().Get("path")
+ if relPath == "" {
+ http.Error(w, "missing path", http.StatusBadRequest)
+ return
+ }
+ s.mu.RLock()
+ root, ok := s.roots[rootName]
+ s.mu.RUnlock()
+ if !ok {
+ http.Error(w, "root not found", http.StatusNotFound)
+ return
+ }
+ absPath := filepath.Join(root.Path, filepath.FromSlash(relPath))
+ if !strings.HasPrefix(absPath, root.Path) {
+ http.Error(w, "invalid path", http.StatusBadRequest)
+ return
+ }
+ http.ServeFile(w, r, absPath)
+}
+
func (s *Service) findSessionForRoot(rootName string) *sessionRunner {
for _, sr := range s.sessions {
if sr.session.LocalRoot == rootName {