// Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause //go:build !ts_omit_drive package main import ( "encoding/json" "errors" "fmt" "io" "net/http" "sync" "syscall/js" "tailscale.com/drive" "tailscale.com/tailcfg" "tailscale.com/tsd" ) // Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote. var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil) // jsFileSystemForRemote implements drive.FileSystemForRemote by bridging // incoming WebDAV requests to a JS handler function. Auth and permission // parsing are handled upstream by handleServeDrive before this is called. type jsFileSystemForRemote struct { mu sync.RWMutex fn js.Value } func (fs *jsFileSystemForRemote) setHandler(fn js.Value) { fs.mu.Lock() fs.fn = fn fs.mu.Unlock() } // SetFileServerAddr is a no-op: the JS handler owns its own storage. func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {} // SetShares is a no-op: the JS handler controls which shares it exposes. func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {} // Close is a no-op. func (fs *jsFileSystemForRemote) Close() error { return nil } // ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler. // It streams the request body to JS via readBodyChunk() and streams the // response body back via write()/end() callbacks, so no full-body buffering // occurs regardless of file size. // // The call blocks until JS calls end() (or a write error occurs). func (fs *jsFileSystemForRemote) ServeHTTPWithPerms( perms drive.Permissions, w http.ResponseWriter, r *http.Request, ) { fs.mu.RLock() fn := fs.fn fs.mu.RUnlock() if fn.IsUndefined() || fn.IsNull() { http.NotFound(w, r) return } // readBodyChunk is exposed to JS as req.readBodyChunk(). // Each call returns a Promise: null signals EOF. readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any { return makePromise(func() (any, error) { buf := make([]byte, 65536) n, err := r.Body.Read(buf) if n > 0 { arr := js.Global().Get("Uint8Array").New(n) js.CopyBytesToJS(arr, buf[:n]) return arr, nil } if errors.Is(err, io.EOF) { return js.Null(), nil } return nil, err }) }) // doneCh receives nil when JS calls end(), or a write error if Write fails. doneCh := make(chan error, 1) // writeHead sets response headers and status code. Must be called before write(). writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any { if len(args) < 1 { return nil } status := args[0].Int() if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() { for k, vs := range jsHeadersToGo(args[1]) { for _, v := range vs { w.Header().Add(k, v) } } } w.WriteHeader(status) return nil }) // write streams a single response body chunk to the client. write := js.FuncOf(func(_ js.Value, args []js.Value) any { if len(args) < 1 { return nil } data := args[0] buf := make([]byte, data.Get("length").Int()) js.CopyBytesToGo(buf, data) if _, werr := w.Write(buf); werr != nil { select { case doneCh <- werr: default: } return nil } if f, ok := w.(http.Flusher); ok { f.Flush() } return nil }) // end signals that the response is complete. end := js.FuncOf(func(_ js.Value, _ []js.Value) any { select { case doneCh <- nil: default: } return nil }) defer func() { readBodyChunk.Release() writeHead.Release() write.Release() end.Release() }() jsReq := map[string]any{ "method": r.Method, "path": r.URL.Path, "rawQuery": r.URL.RawQuery, "headers": goHeadersToJS(r.Header), "readBodyChunk": readBodyChunk, } jsRes := map[string]any{ "writeHead": writeHead, "write": write, "end": end, } fn.Invoke(jsReq, jsRes, drivePermsToJS(perms)) // Block this goroutine until JS calls end() or a write error occurs. // The Go WASM scheduler yields back to JS while we wait. <-doneCh } // drivePermsToJS converts drive.Permissions to a plain JS-friendly object. // Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write. // The wildcard share name "*" is included if present. func drivePermsToJS(p drive.Permissions) map[string]any { result := make(map[string]any, len(p)) for name, perm := range p { result[name] = int(perm) } return result } // goHeadersToJS converts an http.Header to a map[string]any suitable for JS. // Single-value headers become a string; multi-value headers become a []any. func goHeadersToJS(h http.Header) map[string]any { result := make(map[string]any, len(h)) for k, vs := range h { if len(vs) == 1 { result[k] = vs[0] } else { arr := make([]any, len(vs)) for i, v := range vs { arr[i] = v } result[k] = arr } } return result } // jsHeadersToGo parses a JS headers object into an http.Header map. // Values may be a string or an array of strings. func jsHeadersToGo(jsHeaders js.Value) http.Header { h := make(http.Header) keys := js.Global().Get("Object").Call("keys", jsHeaders) for i := 0; i < keys.Length(); i++ { key := keys.Index(i).String() val := jsHeaders.Get(key) switch val.Type() { case js.TypeString: h.Set(key, val.String()) case js.TypeObject: if val.InstanceOf(js.Global().Get("Array")) { for j := 0; j < val.Length(); j++ { h.Add(key, val.Index(j).String()) } } } } return h } // initDriveForRemote creates the JS-backed FileSystemForRemote and registers // it with sys. Must be called before NewLocalBackend (SubSystem is set-once). func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote { driveFS := &jsFileSystemForRemote{} sys.Set(driveFS) return driveFS } // wireDriveJS adds drive-related methods to the IPN JS methods map. // driveFS must be the value returned by initDriveForRemote. func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) { m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any { if len(args) < 1 { return nil } driveFS.setHandler(args[0]) return nil }) m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any { return i.listDrivePeers() }) } type jsDrivePeer struct { Name string `json:"name"` PeerAPIURL string `json:"peerAPIURL"` StableNodeID string `json:"stableNodeID"` Online *bool `json:"online,omitempty"` } // listDrivePeers returns a JSON array of peers that carry // PeerCapabilityTaildriveSharer. Returns an empty array if the local node // does not have drive:access in its ACL (DriveAccessEnabled). This mirrors // the filtering in LocalBackend.driveRemotesFromPeers. func (i *jsIPN) listDrivePeers() js.Value { return makePromise(func() (any, error) { if !i.lb.DriveAccessEnabled() { return "[]", nil } nm := i.lb.NetMap() if nm == nil { return nil, errors.New("listDrivePeers: no network map available") } var selfHave4, selfHave6 bool for _, a := range nm.GetAddresses().All() { if !a.IsSingleIP() { continue } if a.Addr().Is4() { selfHave4 = true } else if a.Addr().Is6() { selfHave6 = true } } peers := make([]jsDrivePeer, 0) for _, p := range nm.Peers { // Check PeerCapabilityTaildriveSharer via the live PeerCaps map // (derived from ACL rules), mirroring driveRemotesFromPeers. hasCap := false for _, a := range p.Addresses().All() { if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) { hasCap = true break } } if !hasCap { continue } peerURL := buildPeerAPIURL(p, selfHave4, selfHave6) online := p.Online().Clone() peers = append(peers, jsDrivePeer{ Name: p.DisplayName(false), PeerAPIURL: peerURL, StableNodeID: string(p.StableID()), Online: online, }) } b, err := json.Marshal(peers) if err != nil { return nil, fmt.Errorf("listDrivePeers: marshal: %w", err) } return string(b), nil }) }