openvibers/openvibe.link Commits

Link Send: resume a dropped transfer, hand over on the LAN, and make clipboard opt-in (#151)

OP OpenVibers committed 3 days ago · b865e25c9a · 15 files, +1009 −41

* Link Send: resume dropped transfers, direct on the LAN, and opt-in clipboard sharing

* The transfer-switch test skips the POSIX mode check on Windows

Changes

ddiff --git a/README.md b/README.md
iindex b879f79..55d6a9f 100644
---- a/README.md
++++ b/README.md
@@@ -113,8 +113,10 @@ turns on a feature you turned off here.
**Link's own window:** the tray's **Open Link** (or `openvibe-link panel`) opens a small page on the machine itself
at `127.0.0.1` that shows what runs here, what this computer allows, your shares, the coding-agent CLIs and the Codes
tasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,
--share a port, or send a file or a note to another of your machines (sealed on this computer with
--openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). It is served only on
++share a port, or send a file, a note or copied text to another of your machines (sealed on this computer with
++openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). A transfer is uploaded in
++parts, so a dropped connection continues where it stopped; on the same network it is handed over directly, and
++otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only. It is served only on
loopback, with a per-run token in the address fragment that every API call must send; see [docs/panel.md](docs/panel.md)
for the security rules.
ddiff --git a/docs/panel.md b/docs/panel.md
iindex 9a38645..6101849 100644
---- a/docs/panel.md
++++ b/docs/panel.md
@@@ -39,14 +39,21 @@ A restart makes a new port and a new token: an old link stops working.
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
| `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |
++| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json` |
++| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json` |
| `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines |
| `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept |
| `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |
--| `POST /panel/api/files` `{ "target_node": "lnk_…", "envelope": "…" }` | Relays an already encrypted item to one of your other machines | Real for Network-paired machines; 20 MB file limit |
++| `POST /panel/api/files` `{ "target_node": "lnk_…", "envelope": "…" }` | Sends an already encrypted item to one of your other machines | Direct on the same network when it can, otherwise uploaded in parts through the relay; 20 MB file limit |
| `POST /panel/api/files/:id/accept` | Gets the pending envelope after you choose Accept | Real; the window opens it locally and downloads it |
| `POST /panel/api/files/:id/decline` | Removes a pending item | Real |
| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |
++`state.clipboard` and `state.auto_accept` are the two per-machine switches; both are `false` until the person turns
++them on, and `panel.json` keeps them with the capability switches. A direct transfer on the same network is queued in
++memory by this machine's listener and appears in `send/inbox` beside the relay's items; the listener accepts only an
++HMAC from the owner's LAN secret, so another machine on the network cannot put anything there.
++
`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approval
waiting or answered, a pause, a share, a capability), newest first, at most 30.
ddiff --git a/internal/agent/agent.go b/internal/agent/agent.go
iindex 9ac06a9..941a4bd 100644
---- a/internal/agent/agent.go
++++ b/internal/agent/agent.go
@@@ -145,10 +145,12 @@ type Agent struct {
// panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it and
// panelFirst is its first-run answer, both kept in state/panel.json; activity is what "What happened here" lists,
// newest first.
-- panel *panel.Server
-- panelOff map[string]bool
-- panelFirst *panelFirstRun
-- activity []panel.Activity
++ panel *panel.Server
++ panelOff map[string]bool
++ panelFirst *panelFirstRun
++ panelClipboard bool
++ panelAutoAccept bool
++ activity []panel.Activity
// codes is what the Codes worker ran here today; codesAgents caches the coding-agent CLIs the window shows.
codes *codesTaskState
codesAgents codesAgentsCache
@@@ -194,9 +196,10 @@ func New(opt Options) (*Agent, error) {
opt.Log.Error("latch", "err", err)
}
panelOff, panelFirst := loadPanel(opt.Paths.StateDir, opt.Creds != nil)
++ panelClipboard, panelAutoAccept := loadPanelSettings(opt.Paths.StateDir)
n := &Agent{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{},
-- panelOff: panelOff, panelFirst: panelFirst}
++ panelOff: panelOff, panelFirst: panelFirst, panelClipboard: panelClipboard, panelAutoAccept: panelAutoAccept}
home, _ := os.UserHomeDir()
n.codes = loadCodesTasks(opt.Paths.StateDir, home, opt.Log)
n.codesAgents.fn = opt.CodesAgents
ddiff --git a/internal/agent/panel.go b/internal/agent/panel.go
iindex 8bebb63..e69de46 100644
---- a/internal/agent/panel.go
++++ b/internal/agent/panel.go
@@@ -93,12 +93,29 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
}
client := &send.Client{BaseURL: n.opt.Creds.Network,
Tokens: link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-link/"+n.opt.Version, "openvibe.network"),
-- Agent: "openvibe-link/" + n.opt.Version}
++ Agent: "openvibe-link/" + n.opt.Version, Self: n.opt.Creds.Principal}
++ // A random local port accepts a sealed envelope from another of the owner's machines on this network. The relay
++ // stays the fallback, and nothing unauthenticated can reach the inbox.
++ lan := send.NewLAN(n.opt.Creds.Principal)
++ if err := lan.Listen(""); err != nil {
++ n.log.Warn("Link Send stays on the relay: no local listening port", "err", err)
++ } else {
++ go func() {
++ <-ctx.Done()
++ _ = lan.Close()
++ }()
++ }
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
-- if err := client.Register(ctx, key); err == nil {
++ secretOK := false
++ if secret, err := client.LANKey(ctx); err == nil {
++ if err := lan.SetSecret(secret); err == nil {
++ secretOK = true
++ }
++ }
++ if err := client.Register(ctx, key, lan.URLs()...); err == nil && secretOK {
return
}
select {
@@@ -129,15 +146,33 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
if err != nil {
return nil, err
}
-- out := make([]panel.IncomingSend, 0, len(items))
++ out := make([]panel.IncomingSend, 0, len(items)+len(lan.Items()))
for _, item := range items {
out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: item.SenderName, Bytes: item.Bytes, CreatedAt: item.CreatedAt})
}
++ for _, item := range lan.Items() {
++ out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: "one of your machines", Bytes: item.Bytes, CreatedAt: item.CreatedAt})
++ }
return out, nil
},
-- accept: func(id string) (string, error) { return client.Accept(ctx, id) },
-- decline: func(id string) error { return client.Decline(ctx, id) },
-- complete: func(id string) error { return client.Complete(ctx, id) },
++ accept: func(id string) (string, error) {
++ if strings.HasPrefix(id, "lan_") {
++ return lan.Accept(id)
++ }
++ return client.Accept(ctx, id)
++ },
++ decline: func(id string) error {
++ if strings.HasPrefix(id, "lan_") {
++ return lan.Decline(id)
++ }
++ return client.Decline(ctx, id)
++ },
++ complete: func(id string) error {
++ if strings.HasPrefix(id, "lan_") {
++ return lan.Complete(id)
++ }
++ return client.Complete(ctx, id)
++ },
}
}
@@@ -145,19 +180,31 @@ type panelSaved struct {
Off []string `json:"off"`
// FirstRun is absent in a file written before the question existed.
FirstRun *panelFirstRun `json:"first_run,omitempty"`
++ // Clipboard sync is off until the person turns it on, on this machine. It is text only.
++ Clipboard bool `json:"clipboard,omitempty"`
++ // AutoAccept saves incoming items from the person's own machines without asking, per machine.
++ AutoAccept bool `json:"auto_accept,omitempty"`
}
--// loadPanel reads the switches turned off and the first-run answer. A file written before the question existed, on a
--// machine that is paired, is a Agent that was set up before this change (paired, its capabilities already saved): it
--// is not asked. A machine that never answered and has no such file is asked once.
--func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
-- off := map[string]bool{}
++func readPanel(dir string) (panelSaved, bool) {
b, err := os.ReadFile(filepath.Join(dir, panelFile))
if err != nil {
-- return off, nil
++ return panelSaved{}, false
}
var s panelSaved
if json.Unmarshal(b, &s) != nil {
++ return panelSaved{}, false
++ }
++ return s, true
++}
++
++// loadPanel reads the switches turned off and the first-run answer. A file written before the question existed, on a
++// machine that is paired, is a Agent that was set up before this change (paired, its capabilities already saved): it
++// is not asked. A machine that never answered and has no such file is asked once.
++func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
++ off := map[string]bool{}
++ s, ok := readPanel(dir)
++ if !ok {
return off, nil
}
for _, id := range s.Off {
@@@ -172,11 +219,17 @@ func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
return off, nil
}
--func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
-- s := panelSaved{Off: []string{}, FirstRun: first}
-- for id := range off {
-- s.Off = append(s.Off, id)
++// loadPanelSettings reads the two transfer switches. Both are false when the file has neither: clipboard sync and
++// auto-accept are off by default and only this machine's person turns them on.
++func loadPanelSettings(dir string) (clipboard, autoAccept bool) {
++ s, ok := readPanel(dir)
++ if !ok {
++ return false, false
}
++ return s.Clipboard, s.AutoAccept
++}
++
++func writePanel(dir string, s panelSaved) error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
@@@ -188,6 +241,22 @@ func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
return os.Rename(tmp, filepath.Join(dir, panelFile))
}
++func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
++ // Keep the transfer switches, which this call does not name.
++ old, _ := readPanel(dir)
++ s := panelSaved{Off: []string{}, FirstRun: first, Clipboard: old.Clipboard, AutoAccept: old.AutoAccept}
++ for id := range off {
++ s.Off = append(s.Off, id)
++ }
++ return writePanel(dir, s)
++}
++
++func savePanelSettings(dir string, clipboard, autoAccept bool) error {
++ old, _ := readPanel(dir)
++ s := panelSaved{Off: old.Off, FirstRun: old.FirstRun, Clipboard: clipboard, AutoAccept: autoAccept}
++ return writePanel(dir, s)
++}
++
// startPanel serves Link's window until ctx ends. A failure leaves the Agent running without it.
func (n *Agent) startPanel(ctx context.Context) {
if !n.opt.NoShares {
@@@ -208,6 +277,8 @@ func (n *Agent) startPanel(ctx context.Context) {
Share: n.shareAdd,
Unshare: n.shareRemove,
FirstRun: n.applyFirstRun,
++ SetClipboard: n.setClipboard,
++ SetAutoAccept: n.setAutoAccept,
CodesStop: n.panelStopCodesTask,
SendKey: transfer.key,
SendTargets: transfer.targets,
@@@ -268,6 +339,8 @@ func (n *Agent) panelState() panel.State {
n.mu.Lock()
out.Activity = append([]panel.Activity(nil), n.activity...)
out.FirstRun = n.panelFirst == nil || !n.panelFirst.Answered
++ out.Clipboard = n.panelClipboard
++ out.AutoAccept = n.panelAutoAccept
n.mu.Unlock()
return out
}
@@@ -400,6 +473,45 @@ func (n *Agent) setCapability(id string, on bool) error {
return nil
}
++// setClipboard keeps the clipboard-sync switch. It starts off, and turning it on is this machine's consent: text
++// copied with the window's Send clipboard action is sealed for one of the person's own machines, never sent in clear.
++func (n *Agent) setClipboard(on bool) error {
++ n.mu.Lock()
++ err := savePanelSettings(n.opt.Paths.StateDir, on, n.panelAutoAccept)
++ if err == nil {
++ n.panelClipboard = on
++ }
++ n.mu.Unlock()
++ if err != nil {
++ return err
++ }
++ if on {
++ n.addActivity("Turned on clipboard sharing with your other machines")
++ } else {
++ n.addActivity("Turned off clipboard sharing")
++ }
++ return nil
++}
++
++// setAutoAccept keeps the per-machine choice to save incoming items from the person's own machines without asking.
++func (n *Agent) setAutoAccept(on bool) error {
++ n.mu.Lock()
++ err := savePanelSettings(n.opt.Paths.StateDir, n.panelClipboard, on)
++ if err == nil {
++ n.panelAutoAccept = on
++ }
++ n.mu.Unlock()
++ if err != nil {
++ return err
++ }
++ if on {
++ n.addActivity("Turned on saving what your own machines send")
++ } else {
++ n.addActivity("Turned off saving what your own machines send without asking")
++ }
++ return nil
++}
++
// applyFirstRun records the answer to "What's this computer for?" and turns on what each use says: a browser for the
// agents, or the AI plugin. Codes has no module switch on the Agent yet and earn only records the wish (Exchange
// confirms it); Terminal and Desktop are never turned on here, only by their own switch in the window. A skip only
ddiff --git a/internal/agent/transfer_settings_test.go b/internal/agent/transfer_settings_test.go
nnew file mode 100644
iindex 0000000..4c2ab35
---- /dev/null
++++ b/internal/agent/transfer_settings_test.go
@@@ -0,0 +1,46 @@
++package agent
++
++import (
++ "encoding/json"
++ "os"
++ "path/filepath"
++ "runtime"
++ "testing"
++)
++
++// Clipboard sync and auto-accept are off on a machine that never turned them on, and a capability or first-run
++// change never switches them on by accident.
++func TestClipboardAndAutoAcceptStartOffAndPersist(t *testing.T) {
++ dir := t.TempDir()
++ if clipboard, autoAccept := loadPanelSettings(dir); clipboard || autoAccept {
++ t.Fatalf("a fresh machine has clipboard=%v auto_accept=%v, want both off", clipboard, autoAccept)
++ }
++ if err := savePanelSettings(dir, true, true); err != nil {
++ t.Fatal(err)
++ }
++ if clipboard, autoAccept := loadPanelSettings(dir); !clipboard || !autoAccept {
++ t.Fatalf("the person's switches were not kept: clipboard=%v auto_accept=%v", clipboard, autoAccept)
++ }
++ // A capability or first-run save rewrites the same file and must keep the transfer switches.
++ if err := savePanel(dir, map[string]bool{"terminal": true}, &panelFirstRun{Answered: true}); err != nil {
++ t.Fatal(err)
++ }
++ if clipboard, autoAccept := loadPanelSettings(dir); !clipboard || !autoAccept {
++ t.Fatalf("a capability save lost the transfer switches: clipboard=%v auto_accept=%v", clipboard, autoAccept)
++ }
++ info, err := os.Stat(filepath.Join(dir, panelFile))
++ if err != nil {
++ t.Fatal(err)
++ }
++ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
++ t.Fatalf("panel.json mode = %o, want 600", info.Mode().Perm())
++ }
++ raw, err := os.ReadFile(filepath.Join(dir, panelFile))
++ if err != nil {
++ t.Fatal(err)
++ }
++ var saved panelSaved
++ if err := json.Unmarshal(raw, &saved); err != nil || !saved.Clipboard || !saved.AutoAccept {
++ t.Fatalf("saved switches: %+v (%v)", saved, err)
++ }
++}
ddiff --git a/internal/panel/panel.go b/internal/panel/panel.go
iindex d913fbe..133b7d4 100644
---- a/internal/panel/panel.go
++++ b/internal/panel/panel.go
@@@ -145,6 +145,10 @@ type State struct {
Codes *Codes `json:"codes,omitempty"`
// FirstRun is true until the person has answered "What's this computer for?" or skipped it.
FirstRun bool `json:"first_run,omitempty"`
++ // Clipboard is the person's switch for sharing copied text between their own machines. Off by default.
++ Clipboard bool `json:"clipboard"`
++ // AutoAccept saves incoming items from the person's own machines without asking. Off by default.
++ AutoAccept bool `json:"auto_accept"`
}
// FirstRunAnswer is the body of POST /panel/api/first-run: the uses the person picked, or a skip that only stops the
@@@ -188,6 +192,9 @@ type Options struct {
// FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops
// the question.
FirstRun func(uses []string, skip bool) error
++ // SetClipboard and SetAutoAccept keep the two transfer switches. Both start off.
++ SetClipboard func(on bool) error
++ SetAutoAccept func(on bool) error
// CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the
// task is not running on this computer.
CodesStop func(id string) error
@@@ -439,6 +446,40 @@ func (s *Server) api() http.Handler {
return
}
w.WriteHeader(http.StatusNoContent)
++ case path == "clipboard" && r.Method == http.MethodPost:
++ var body struct {
++ On *bool `json:"on"`
++ }
++ if !decode(w, r, &body) || body.On == nil {
++ writeJSON(w, http.StatusBadRequest, errBody("on is true or false"))
++ return
++ }
++ if s.opt.SetClipboard == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot change this switch"))
++ return
++ }
++ if err := s.opt.SetClipboard(*body.On); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]bool{"on": *body.On})
++ case path == "send/auto-accept" && r.Method == http.MethodPost:
++ var body struct {
++ On *bool `json:"on"`
++ }
++ if !decode(w, r, &body) || body.On == nil {
++ writeJSON(w, http.StatusBadRequest, errBody("on is true or false"))
++ return
++ }
++ if s.opt.SetAutoAccept == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot change this switch"))
++ return
++ }
++ if err := s.opt.SetAutoAccept(*body.On); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]bool{"on": *body.On})
case path == "first-run" && r.Method == http.MethodPost:
var body FirstRunAnswer
if !decode(w, r, &body) {
ddiff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go
iindex 351d5c4..244d89e 100644
---- a/internal/panel/panel_test.go
++++ b/internal/panel/panel_test.go
@@@ -372,3 +372,36 @@ func TestAPIUnknownPathIs404(t *testing.T) {
t.Fatalf("unknown: %d", w.Code)
}
}
++
++// Clipboard sharing and auto-accept are per-machine switches that start off; the window can turn each one on and
++// the state it polls says which way it is.
++func TestTransferSwitchesStartOffAndCanBeTurnedOn(t *testing.T) {
++ clipboard, autoAccept := false, false
++ srv := testServer(t, Options{
++ State: func() State { return State{Clipboard: clipboard, AutoAccept: autoAccept} },
++ SetClipboard: func(on bool) error { clipboard = on; return nil },
++ SetAutoAccept: func(on bool) error { autoAccept = on; return nil },
++ })
++ state := func() State {
++ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "", "")
++ if w.Code != http.StatusOK {
++ t.Fatalf("state: %d %s", w.Code, w.Body)
++ }
++ var st State
++ if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil {
++ t.Fatal(err)
++ }
++ return st
++ }
++ if st := state(); st.Clipboard || st.AutoAccept {
++ t.Fatalf("a fresh machine reports clipboard=%v auto_accept=%v, want both off", st.Clipboard, st.AutoAccept)
++ }
++ for _, call := range []struct{ path string }{{"clipboard"}, {"send/auto-accept"}} {
++ if w := do(t, srv, http.MethodPost, "/panel/api/"+call.path, testToken, "", `{"on":true}`); w.Code != http.StatusOK {
++ t.Fatalf("%s: %d %s", call.path, w.Code, w.Body)
++ }
++ }
++ if st := state(); !st.Clipboard || !st.AutoAccept {
++ t.Fatalf("the switches did not turn on: clipboard=%v auto_accept=%v", st.Clipboard, st.AutoAccept)
++ }
++}
ddiff --git a/internal/panel/ui/README.md b/internal/panel/ui/README.md
iindex 9808d69..52ecf26 100644
---- a/internal/panel/ui/README.md
++++ b/internal/panel/ui/README.md
@@@ -29,6 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. |
| `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. |
| `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |
++| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only, sealed like every other item. |
++| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default. |
## `GET /panel/api/state`
@@@ -47,7 +49,9 @@ Every key is optional. A section with nothing in it is left out; the window neve
"agents": [{ "id": "claude-code", "name": "Claude Code", "state": "ready", "version": "2.1.3" }, { "id": "codex", "name": "Codex", "state": "signin", "hint": "codex login" }],
"tasks": [{ "id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "title": "Make every page work at 320 px", "folder": "~/code/openvibe-site", "agent": "Claude Code", "state": "running", "started_at": "2026-11-07T09:52:00Z", "url": "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" }]
},
-- "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]
++ "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }],
++ "clipboard": false,
++ "auto_accept": false
}
```
ddiff --git a/internal/panel/ui/index.html b/internal/panel/ui/index.html
iindex d816b17..6b7a961 100644
---- a/internal/panel/ui/index.html
++++ b/internal/panel/ui/index.html
@@@ -68,6 +68,9 @@
<textarea id="lk-send-note" data-lk-send-note rows="3" maxlength="10000" placeholder="Write a short note"></textarea>
<button type="button" data-lk-send-note-button>Send note</button>
<p class="lk-fine" data-lk-drop-say role="status" aria-live="polite"></p>
++ <label class="lk-fine"><input type="checkbox" data-lk-clipboard> Share copied text with my other machines <small>(off by default, text only, encrypted the same way)</small></label>
++ <p class="lk-fine"><button type="button" data-lk-clipboard-button>Send what I copied</button></p>
++ <label class="lk-fine"><input type="checkbox" data-lk-auto-accept> Save what my own machines send without asking <small>(per machine)</small></label>
<h3>Waiting for you</h3><ul class="lk-send-inbox" data-lk-send-inbox></ul>
</section>
</div>
ddiff --git a/internal/panel/ui/panel.js b/internal/panel/ui/panel.js
iindex 4f67abf..14f021f 100644
---- a/internal/panel/ui/panel.js
++++ b/internal/panel/ui/panel.js
@@@ -78,6 +78,10 @@
root.setAttribute('data-needs', m.needs_you ? '1' : '0');
root.style.setProperty('--fill', String(Math.max(10, 100 - Math.min(running.length, 4) * 22)));
$('[data-lk-down]').hidden = true;
++ settings.clipboard = !!s.clipboard;
++ settings.auto_accept = !!s.auto_accept;
++ if ($('[data-lk-clipboard]')) $('[data-lk-clipboard]').checked = settings.clipboard;
++ if ($('[data-lk-auto-accept]')) $('[data-lk-auto-accept]').checked = settings.auto_accept;
$('[data-lk-first]').hidden = !s.first_run;
$('[data-lk-name]').textContent = m.name || 'This computer';
drawCodes(s.codes, m);
@@@ -175,6 +179,8 @@
});
var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
var sendKey = null, lockerModule = null;
++ var settings = { clipboard: false, auto_accept: false };
++ var seenInbox = {};
function panelApi(path, method, body) {
return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),
body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {
@@@ -184,8 +190,10 @@
}
function base64(bytes) { var s = ''; for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); }
function unbase64(s) { var raw = atob(s), out = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; }
-- function packed(name, mime, data) {
-- var meta = new TextEncoder().encode(JSON.stringify({ name: name, mime: mime || 'application/octet-stream' }));
++ function packed(name, mime, data, kind) {
++ var info = { name: name, mime: mime || 'application/octet-stream' };
++ if (kind) info.kind = kind;
++ var meta = new TextEncoder().encode(JSON.stringify(info));
var out = new Uint8Array(4 + meta.length + data.length); new DataView(out.buffer).setUint32(0, meta.length); out.set(meta, 4); out.set(data, 4 + meta.length); return out;
}
function moduleLocker() { return lockerModule || (lockerModule = import('https://openvibe.link/locker.mjs').then(function (m) { return m.locker; })); }
@@@ -197,14 +205,14 @@
if (targetSelect.options.length === 1) say('Open Link on another paired machine to turn on receiving.');
}).catch(function (e) { if (targetSelect.options.length === 1) { targetSelect.replaceChildren(new Option('No receiving machine is ready', '')); say(e.message); } });
}
-- function sendPayload(name, mime, data) {
++ function sendPayload(name, mime, data, kind) {
var opt = targetSelect.options[targetSelect.selectedIndex];
if (!opt || !opt.value) { say('Choose one of your machines first.'); return Promise.resolve(); }
if (data.length > 20 * 1024 * 1024) { say('Choose a file smaller than 20 MB for now.'); return Promise.resolve(); }
say('Encrypting on this computer…');
return moduleLocker().then(function (locker) {
var publicKey = JSON.parse(opt.dataset.publicKey || '{}');
-- var content = packed(name, mime, data);
++ var content = packed(name, mime, data, kind);
return locker.seal(content, { lock: 'people', keys: [{ subject: { type: 'link', id: opt.value }, publicKey: publicKey }] }).then(function (sealed) {
say('Sending encrypted data…');
return panelApi('files', 'POST', { target_node: opt.value, envelope: base64(sealed.bytes) });
@@@ -214,6 +222,12 @@
}
function sendFile(file) { return file.arrayBuffer().then(function (b) { return sendPayload(file.name, file.type, new Uint8Array(b)); }); }
function sendNote() { var text = $('[data-lk-send-note]').value; if (!text.trim()) { say('Write a note first.'); return; } sendPayload('note.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text)).then(function () { $('[data-lk-send-note]').value = ''; }); }
++ function saveFile(info, fileBytes, id) {
++ var safeName = String(info.name || 'received-file').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').slice(0, 180) || 'received-file';
++ var url = URL.createObjectURL(new Blob([fileBytes], { type: info.mime || 'application/octet-stream' }));
++ var a = document.createElement('a'); a.href = url; a.download = safeName; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
++ return panelApi('files/' + encodeURIComponent(id) + '/complete', 'POST', {}).then(function () { say('Accepted. Your browser saved ' + safeName + ' to Downloads.'); });
++ }
function downloadAccepted(item) {
var path = 'files/' + encodeURIComponent(item.id);
return Promise.all([moduleLocker(), panelApi('send/key'), panelApi(path + '/accept', 'POST', {})]).then(function (parts) {
@@@ -224,10 +238,15 @@
var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);
if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');
var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);
-- var safeName = String(info.name || 'received-file').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').slice(0, 180) || 'received-file';
-- var url = URL.createObjectURL(new Blob([fileBytes], { type: info.mime || 'application/octet-stream' }));
-- var a = document.createElement('a'); a.href = url; a.download = safeName; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
-- return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () { say('Accepted. Your browser saved ' + safeName + ' to Downloads.'); });
++ if (info.kind === 'clipboard' && navigator.clipboard && navigator.clipboard.writeText) {
++ var text = new TextDecoder().decode(fileBytes);
++ return navigator.clipboard.writeText(text).then(function () {
++ return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () {
++ say('Copied text from ' + (item.sender_name || 'your machine') + ' to this computer\'s clipboard.');
++ });
++ }, function () { return saveFile(info, fileBytes, item.id); });
++ }
++ return saveFile(info, fileBytes, item.id);
});
}
function size(bytes) {
@@@ -237,6 +256,11 @@
function loadInbox() {
panelApi('send/inbox').then(function (body) {
var list = $('[data-lk-send-inbox]');
++ (body.items || []).forEach(function (item) {
++ if (!settings.auto_accept || seenInbox[item.id]) return;
++ seenInbox[item.id] = true;
++ downloadAccepted(item).catch(function (e) { say(e.message || 'An item from one of your machines could not be opened.'); });
++ });
list.replaceChildren.apply(list, (body.items || []).map(function (item) {
var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));
var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');
@@@ -253,6 +277,16 @@
dropTarget.addEventListener('drop', function (e) { e.preventDefault(); if (e.dataTransfer.files[0]) sendFile(e.dataTransfer.files[0]); });
$('[data-lk-files]').addEventListener('change', function () { if (this.files[0]) sendFile(this.files[0]); this.value = ''; });
$('[data-lk-send-note-button]').addEventListener('click', sendNote);
++ if ($('[data-lk-clipboard]')) $('[data-lk-clipboard]').addEventListener('change', function () { send('clipboard', 'POST', { on: this.checked }); });
++ if ($('[data-lk-auto-accept]')) $('[data-lk-auto-accept]').addEventListener('change', function () { send('send/auto-accept', 'POST', { on: this.checked }); });
++ if ($('[data-lk-clipboard-button]')) $('[data-lk-clipboard-button]').addEventListener('click', function () {
++ if (!settings.clipboard) { say('Turn on sharing copied text first.'); return; }
++ if (!navigator.clipboard || !navigator.clipboard.readText) { say('This browser does not let Link read the clipboard.'); return; }
++ navigator.clipboard.readText().then(function (text) {
++ if (!text.trim()) { say('There is no text on this computer\'s clipboard.'); return; }
++ return sendPayload('clipboard.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text), 'clipboard');
++ }).catch(function () { say('Link could not read the clipboard. Click the page and try again.'); });
++ });
loadTargets(); loadInbox(); setInterval(function () { if (document.visibilityState === 'visible') { loadTargets(); loadInbox(); } }, 3000);
function tick() { if (document.visibilityState === 'visible') load(); }
load();
ddiff --git a/internal/panel/ui/testdata/state.json b/internal/panel/ui/testdata/state.json
iindex ed1b818..052a4fc 100644
---- a/internal/panel/ui/testdata/state.json
++++ b/internal/panel/ui/testdata/state.json
@@@ -161,5 +161,7 @@
"url": "https://openvibe.codes/tasks/tsk_01M6N2"
}
]
-- }
++ },
++ "clipboard": false,
++ "auto_accept": false
}
ddiff --git a/internal/send/client.go b/internal/send/client.go
iindex 73bf5ba..3a149ca 100644
---- a/internal/send/client.go
++++ b/internal/send/client.go
@@@ -20,19 +20,27 @@ type Tokens interface {
}
var itemID = regexp.MustCompile(`^snd_[a-f0-9]{32}$`)
++var uploadID = regexp.MustCompile(`^upl_[a-f0-9]{32}$`)
++
++// uploadChunk is how much of the base64 envelope Link sends in one part. The relay accepts up to 2 MiB; 1 MiB keeps
++// one request small enough to retry quickly on a home connection.
++const uploadChunk = 1024 * 1024
type Client struct {
BaseURL string
Tokens Tokens
HTTP *http.Client
Agent string
++ // Self is this machine's principal (lnk_…), so a direct transfer can name its sender.
++ Self string
}
type Machine struct {
-- ID string `json:"id"`
-- Name string `json:"name"`
-- Online bool `json:"online"`
-- PublicKey JWK `json:"public_key"`
++ ID string `json:"id"`
++ Name string `json:"name"`
++ Online bool `json:"online"`
++ PublicKey JWK `json:"public_key"`
++ LAN []string `json:"lan"`
}
// Item is one waiting transfer as the inbox lists it: who sent it and how big it is. The ciphertext is not in the
@@@ -46,8 +54,21 @@ type Item struct {
ExpiresAt time.Time `json:"expires_at"`
}
--func (c *Client) Register(ctx context.Context, key JWK) error {
-- return c.do(ctx, http.MethodPut, "/api/v1/link/self/send/key", map[string]any{"public_key": Public(key)}, nil)
++func (c *Client) Register(ctx context.Context, key JWK, lan ...string) error {
++ body := map[string]any{"public_key": Public(key)}
++ if len(lan) > 0 {
++ body["lan"] = lan
++ }
++ return c.do(ctx, http.MethodPut, "/api/v1/link/self/send/key", body, nil)
++}
++
++// LANKey is the owner's shared secret for direct transfers; every one of the owner's machines reads the same one.
++func (c *Client) LANKey(ctx context.Context) (string, error) {
++ var out struct {
++ Key string `json:"key"`
++ }
++ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/lan-key", nil, &out)
++ return out.Key, err
}
func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
var out struct {
@@@ -56,8 +77,140 @@ func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/targets", nil, &out)
return out.Machines, err
}
++
++// Upload is the relay's view of a transfer that is arriving in parts: the part after the last one it stored, and how
++// many bytes it holds. Resuming asks the relay for this instead of guessing from the failed request.
++type Upload struct {
++ ID string `json:"id"`
++ Bytes int64 `json:"bytes"`
++ Received int64 `json:"received"`
++ Seq int `json:"seq"`
++ ItemID string `json:"item_id"`
++}
++
++// Send uploads the Locker envelope in parts and resumes from the relay's acknowledged part after a dropped
++// connection. Finishing is idempotent on the relay, so a lost finish answer does not make a second item.
func (c *Client) Send(ctx context.Context, target, envelope string) error {
-- return c.do(ctx, http.MethodPost, "/api/v1/link/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil)
++ if envelope == "" {
++ return fmt.Errorf("the encrypted item is empty")
++ }
++ // Same network: hand it over directly, sealed as before. Any problem (off the LAN, a timeout, no shared secret)
++ // falls through to the relay, which keeps the item until the receiver accepts it.
++ if err := c.sendLAN(ctx, target, envelope); err == nil {
++ return nil
++ }
++ upload, err := c.StartUpload(ctx, target, int64(len(envelope)))
++ if err != nil {
++ // A Link newer than the Network it talks to still works: the one-request form is the v1 relay.
++ if single := c.do(ctx, http.MethodPost, "/api/v1/link/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil); single == nil {
++ return nil
++ }
++ return err
++ }
++ failures := 0
++ for {
++ for upload.Seq*uploadChunk < len(envelope) {
++ from := upload.Seq * uploadChunk
++ to := from + uploadChunk
++ if to > len(envelope) {
++ to = len(envelope)
++ }
++ next, err := c.UploadPart(ctx, upload.ID, upload.Seq, envelope[from:to])
++ if err == nil {
++ upload = next
++ failures = 0
++ continue
++ }
++ if ctx.Err() != nil {
++ return fmt.Errorf("sending was stopped")
++ }
++ failures++
++ if failures > 5 {
++ return fmt.Errorf("the connection kept dropping; the parts already sent are kept for an hour, try again")
++ }
++ time.Sleep(time.Duration(failures) * 250 * time.Millisecond)
++ status, statusErr := c.UploadStatus(ctx, upload.ID)
++ if statusErr != nil {
++ continue
++ }
++ upload = status
++ }
++ item, err := c.FinishUpload(ctx, upload.ID)
++ if err == nil {
++ upload.ItemID = item
++ return nil
++ }
++ if ctx.Err() != nil {
++ return fmt.Errorf("sending was stopped")
++ }
++ failures++
++ if failures > 5 {
++ return fmt.Errorf("the relay did not confirm the item; the parts are kept for an hour, try again")
++ }
++ status, statusErr := c.UploadStatus(ctx, upload.ID)
++ if statusErr == nil {
++ if status.ItemID != "" {
++ return nil
++ }
++ upload = status
++ }
++ time.Sleep(time.Duration(failures) * 250 * time.Millisecond)
++ }
++}
++
++func (c *Client) StartUpload(ctx context.Context, target string, bytes int64) (Upload, error) {
++ var out Upload
++ err := c.do(ctx, http.MethodPost, "/api/v1/link/self/send/upload", map[string]any{"target_node": target, "bytes": bytes}, &out)
++ return out, err
++}
++
++func (c *Client) UploadPart(ctx context.Context, id string, seq int, chunk string) (Upload, error) {
++ if !uploadID.MatchString(id) {
++ return Upload{}, fmt.Errorf("this transfer is no longer available")
++ }
++ var out Upload
++ err := c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/link/self/send/upload/%s/%d", id, seq), map[string]string{"chunk": chunk}, &out)
++ return out, err
++}
++
++func (c *Client) UploadStatus(ctx context.Context, id string) (Upload, error) {
++ if !uploadID.MatchString(id) {
++ return Upload{}, fmt.Errorf("this transfer is no longer available")
++ }
++ var out Upload
++ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/upload/"+id, nil, &out)
++ return out, err
++}
++
++func (c *Client) FinishUpload(ctx context.Context, id string) (string, error) {
++ if !uploadID.MatchString(id) {
++ return "", fmt.Errorf("this transfer is no longer available")
++ }
++ var out struct {
++ ID string `json:"id"`
++ }
++ err := c.do(ctx, http.MethodPost, "/api/v1/link/self/send/upload/"+id+"/finish", map[string]string{}, &out)
++ return out.ID, err
++}
++
++// Outbox is the sender's list: what this machine sent and whether the receiving machine accepted or saved it.
++type OutboxItem struct {
++ ID string `json:"id"`
++ Target string `json:"target_node"`
++ TargetName string `json:"target_name"`
++ Bytes int64 `json:"bytes"`
++ State string `json:"state"`
++ CreatedAt time.Time `json:"created_at"`
++ AcceptedAt time.Time `json:"accepted_at"`
++ ExpiresAt time.Time `json:"expires_at"`
++}
++
++func (c *Client) Outbox(ctx context.Context) ([]OutboxItem, error) {
++ var out struct {
++ Items []OutboxItem `json:"items"`
++ }
++ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/outbox", nil, &out)
++ return out.Items, err
}
func (c *Client) Inbox(ctx context.Context) ([]Item, error) {
var out struct {
ddiff --git a/internal/send/lan.go b/internal/send/lan.go
nnew file mode 100644
iindex 0000000..4a63b0d
---- /dev/null
++++ b/internal/send/lan.go
@@@ -0,0 +1,329 @@
++package send
++
++// Direct delivery on the same network: a Link listens on a random local port and accepts a sealed Locker envelope
++// only from another of the owner's machines. The sender proves it with an HMAC from the owner's LAN secret, which
++// both machines read from Network with their own node tokens; no relay round trip is needed, and an unauthenticated
++// neighbour cannot put anything in the inbox. The payload is always ciphertext: the listener never has a key.
++
++import (
++ "context"
++ "crypto/hmac"
++ "crypto/rand"
++ "crypto/sha256"
++ "crypto/subtle"
++ "encoding/base64"
++ "encoding/hex"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "io"
++ "net"
++ "net/http"
++ "strconv"
++ "strings"
++ "sync"
++ "time"
++)
++
++const (
++ lanPath = "/api/link-send/v1"
++ lanMaxBody = 40 << 20
++ lanWindow = 5 * time.Minute
++ lanMaxItems = 100
++ lanMaxNonces = 4096
++)
++
++// Incoming is one sealed item that arrived directly. It is held in memory until the person accepts, declines or Link
++// exits; the relay copy is still there if they restart, because the sender only takes the direct path when accepted.
++type Incoming struct {
++ ID string
++ Sender string
++ SenderName string
++ Envelope string
++ Bytes int64
++ CreatedAt time.Time
++}
++
++// LAN is one Link's direct listener and its small queue of received items.
++type LAN struct {
++ target string
++ mu sync.Mutex
++ secret []byte
++ items map[string]Incoming
++ nonces map[string]time.Time
++ ln net.Listener
++ server *http.Server
++}
++
++func NewLAN(target string) *LAN {
++ return &LAN{target: target, items: map[string]Incoming{}, nonces: map[string]time.Time{}}
++}
++
++// Listen opens a random port. It is loopback-only in tests; on a real machine it is reachable on the local network,
++// which is the point, and every request still has to carry the owner's HMAC.
++func (l *LAN) Listen(addr string) error {
++ if addr == "" {
++ addr = "0.0.0.0:0"
++ }
++ ln, err := net.Listen("tcp", addr)
++ if err != nil {
++ return err
++ }
++ l.ln = ln
++ l.server = &http.Server{Handler: l.handler(), ReadHeaderTimeout: 5 * time.Second}
++ go func() { _ = l.server.Serve(ln) }()
++ return nil
++}
++
++func (l *LAN) Close() error {
++ if l.server != nil {
++ return l.server.Close()
++ }
++ return nil
++}
++
++// Port is the listening port, or 0 when Listen did not succeed.
++func (l *LAN) Port() int {
++ if l.ln == nil {
++ return 0
++ }
++ if a, ok := l.ln.Addr().(*net.TCPAddr); ok {
++ return a.Port
++ }
++ return 0
++}
++
++// URLs are the addresses to advertise to Network: every non-loopback IPv4 address of this machine, plus loopback for
++// two Links on one computer (a test, or two profiles).
++func (l *LAN) URLs() []string {
++ port := l.Port()
++ if port == 0 {
++ return nil
++ }
++ seen := map[string]bool{}
++ var out []string
++ add := func(host string) {
++ if host == "" || seen[host] {
++ return
++ }
++ seen[host] = true
++ out = append(out, "http://"+net.JoinHostPort(host, strconv.Itoa(port)))
++ }
++ add("127.0.0.1")
++ ifaces, err := net.Interfaces()
++ if err != nil {
++ return out
++ }
++ for _, iface := range ifaces {
++ if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
++ continue
++ }
++ addrs, _ := iface.Addrs()
++ for _, a := range addrs {
++ ip, _, err := net.ParseCIDR(a.String())
++ if err != nil || ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.To4() == nil {
++ continue
++ }
++ add(ip.String())
++ }
++ }
++ return out
++}
++
++func (l *LAN) SetSecret(encoded string) error {
++ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
++ if err != nil || len(raw) < 16 {
++ return errors.New("the LAN sender secret is not valid")
++ }
++ l.mu.Lock()
++ l.secret = append([]byte(nil), raw...)
++ l.mu.Unlock()
++ return nil
++}
++
++func (l *LAN) Items() []Incoming {
++ l.mu.Lock()
++ defer l.mu.Unlock()
++ out := make([]Incoming, 0, len(l.items))
++ for _, item := range l.items {
++ out = append(out, item)
++ }
++ return out
++}
++
++func (l *LAN) Accept(id string) (string, error) {
++ l.mu.Lock()
++ defer l.mu.Unlock()
++ item, ok := l.items[id]
++ if !ok {
++ return "", errors.New("that item is no longer waiting")
++ }
++ return item.Envelope, nil
++}
++
++func (l *LAN) Decline(id string) error {
++ l.mu.Lock()
++ defer l.mu.Unlock()
++ if _, ok := l.items[id]; !ok {
++ return errors.New("that item is no longer waiting")
++ }
++ delete(l.items, id)
++ return nil
++}
++
++func (l *LAN) Complete(id string) error {
++ l.mu.Lock()
++ defer l.mu.Unlock()
++ if _, ok := l.items[id]; !ok {
++ return errors.New("that item is no longer waiting")
++ }
++ delete(l.items, id)
++ return nil
++}
++
++type lanRequest struct {
++ Sender string `json:"sender_node"`
++ Target string `json:"target_node"`
++ Envelope string `json:"envelope"`
++ At int64 `json:"at"`
++ Nonce string `json:"nonce"`
++ Mac string `json:"mac"`
++}
++
++func (l *LAN) handler() http.Handler {
++ mux := http.NewServeMux()
++ mux.HandleFunc(lanPath, func(w http.ResponseWriter, r *http.Request) {
++ if r.Method != http.MethodPost {
++ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
++ return
++ }
++ var req lanRequest
++ if err := json.NewDecoder(io.LimitReader(r.Body, lanMaxBody)).Decode(&req); err != nil {
++ http.Error(w, "unreadable transfer", http.StatusBadRequest)
++ return
++ }
++ l.mu.Lock()
++ secret := append([]byte(nil), l.secret...)
++ l.mu.Unlock()
++ if len(secret) == 0 {
++ http.Error(w, "this machine has not turned on receiving", http.StatusForbidden)
++ return
++ }
++ if req.Target != l.target {
++ http.Error(w, "that item is for another machine", http.StatusNotFound)
++ return
++ }
++ if req.Sender == "" || req.Sender == l.target || req.Nonce == "" || len(req.Nonce) > 128 {
++ http.Error(w, "the sender is not one of this machine's owner's machines", http.StatusForbidden)
++ return
++ }
++ if len(req.Envelope) == 0 || len(req.Envelope) > 32*1024*1024 {
++ http.Error(w, "the encrypted item is missing or too large", http.StatusBadRequest)
++ return
++ }
++ now := time.Now()
++ at := time.Unix(req.At, 0)
++ if req.At == 0 || now.Sub(at) > lanWindow || at.Sub(now) > lanWindow {
++ http.Error(w, "the transfer is too old", http.StatusForbidden)
++ return
++ }
++ want := lanMAC(secret, req.Sender, req.Target, req.At, req.Nonce, req.Envelope)
++ if subtle.ConstantTimeCompare([]byte(req.Mac), []byte(want)) != 1 {
++ http.Error(w, "the sender is not one of this machine's owner's machines", http.StatusForbidden)
++ return
++ }
++ l.mu.Lock()
++ defer l.mu.Unlock()
++ if _, seen := l.nonces[req.Nonce]; seen {
++ http.Error(w, "that transfer was already received", http.StatusConflict)
++ return
++ }
++ if len(l.nonces) > lanMaxNonces {
++ for nonce, when := range l.nonces {
++ if now.Sub(when) > lanWindow {
++ delete(l.nonces, nonce)
++ }
++ }
++ }
++ if len(l.items) >= lanMaxItems {
++ http.Error(w, "this machine's transfer list is full; accept or decline an item first", http.StatusTooManyRequests)
++ return
++ }
++ l.nonces[req.Nonce] = now
++ id := "lan_" + hex.EncodeToString(random(16))
++ l.items[id] = Incoming{ID: id, Sender: req.Sender, Envelope: req.Envelope, Bytes: int64(len(req.Envelope)), CreatedAt: now}
++ w.Header().Set("Content-Type", "application/json")
++ w.WriteHeader(http.StatusCreated)
++ _, _ = w.Write([]byte(`{"id":"` + id + `"}`))
++ })
++ return mux
++}
++
++func lanMAC(secret []byte, sender, target string, at int64, nonce, envelope string) string {
++ sum := sha256.Sum256([]byte(envelope))
++ h := hmac.New(sha256.New, secret)
++ fmt.Fprintf(h, "openvibe-link-send-lan-v1\n%s\n%s\n%d\n%s\n%s\n", sender, target, at, nonce, base64.RawURLEncoding.EncodeToString(sum[:]))
++ return hex.EncodeToString(h.Sum(nil))
++}
++
++func random(n int) []byte {
++ b := make([]byte, n)
++ if _, err := rand.Read(b); err != nil {
++ panic(err)
++ }
++ return b
++}
++
++// sendLAN tries the target's advertised local endpoints. Any failure (not on the network, a timeout, an old secret)
++// falls back to the relay by returning an error.
++func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
++ if c.Self == "" {
++ return errors.New("this machine has no id for a direct transfer")
++ }
++ machines, err := c.Targets(ctx)
++ if err != nil {
++ return err
++ }
++ var endpoints []string
++ for _, m := range machines {
++ if m.ID == target {
++ endpoints = m.LAN
++ break
++ }
++ }
++ if len(endpoints) == 0 {
++ return errors.New("the receiving machine is not on this network")
++ }
++ encoded, err := c.LANKey(ctx)
++ if err != nil {
++ return err
++ }
++ secret, err := base64.RawURLEncoding.DecodeString(encoded)
++ if err != nil || len(secret) < 16 {
++ return errors.New("the LAN sender secret is not valid")
++ }
++ for _, base := range endpoints {
++ at := time.Now().Unix()
++ nonce := hex.EncodeToString(random(16))
++ body, _ := json.Marshal(lanRequest{
++ Sender: c.Self, Target: target, Envelope: envelope, At: at, Nonce: nonce,
++ Mac: lanMAC(secret, c.Self, target, at, nonce, envelope),
++ })
++ req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+lanPath, strings.NewReader(string(body)))
++ if err != nil {
++ continue
++ }
++ req.Header.Set("Content-Type", "application/json")
++ client := &http.Client{Timeout: 2 * time.Second}
++ resp, err := client.Do(req)
++ if err != nil {
++ continue
++ }
++ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
++ resp.Body.Close()
++ if resp.StatusCode == http.StatusCreated {
++ return nil
++ }
++ }
++ return errors.New("the direct transfer did not arrive")
++}
ddiff --git a/internal/send/lan_test.go b/internal/send/lan_test.go
nnew file mode 100644
iindex 0000000..b734650
---- /dev/null
++++ b/internal/send/lan_test.go
@@@ -0,0 +1,113 @@
++package send
++
++import (
++ "bytes"
++ "context"
++ "encoding/base64"
++ "encoding/hex"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "strings"
++ "testing"
++ "time"
++)
++
++const lanTarget = "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++
++func startTestLAN(t *testing.T) (*LAN, string) {
++ t.Helper()
++ lan := NewLAN(lanTarget)
++ if err := lan.Listen("127.0.0.1:0"); err != nil {
++ t.Fatal(err)
++ }
++ t.Cleanup(func() { _ = lan.Close() })
++ secret := strings.Repeat("s", 32)
++ if err := lan.SetSecret(base64.RawURLEncoding.EncodeToString([]byte(secret))); err != nil {
++ t.Fatal(err)
++ }
++ return lan, secret
++}
++
++func lanBody(sender, secret, envelope string) []byte {
++ at := time.Now().Unix()
++ nonce := hex.EncodeToString(random(16))
++ b, _ := json.Marshal(lanRequest{Sender: sender, Target: lanTarget, Envelope: envelope, At: at, Nonce: nonce,
++ Mac: lanMAC([]byte(secret), sender, lanTarget, at, nonce, envelope)})
++ return b
++}
++
++func postLAN(t *testing.T, url string, body []byte) int {
++ t.Helper()
++ resp, err := http.Post(url+lanPath, "application/json", bytes.NewReader(body))
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer resp.Body.Close()
++ return resp.StatusCode
++}
++
++// A machine refuses a sender that does not hold the owner's LAN secret, and it refuses a replayed transfer even when
++// it does. The accepted envelope is the same ciphertext the sender made: the listener never sees a key.
++func TestLANRefusesANonOwnerAndReplayedTransfer(t *testing.T) {
++ lan, secret := startTestLAN(t)
++ url := "http://" + lan.ln.Addr().String()
++ envelope := base64.RawStdEncoding.EncodeToString([]byte("sealed bytes"))
++ if code := postLAN(t, url, lanBody("lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ", "not-the-owner-secret", envelope)); code != http.StatusForbidden {
++ t.Fatalf("a sender without the owner secret got %d, want 403", code)
++ }
++ if got := len(lan.Items()); got != 0 {
++ t.Fatalf("a refused sender put %d items in the inbox", got)
++ }
++ body := lanBody("lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ", secret, envelope)
++ if code := postLAN(t, url, body); code != http.StatusCreated {
++ t.Fatalf("the owner's machine got %d, want 201", code)
++ }
++ if code := postLAN(t, url, body); code != http.StatusConflict {
++ t.Fatalf("a replayed transfer got %d, want 409", code)
++ }
++ items := lan.Items()
++ if len(items) != 1 || items[0].Envelope != envelope {
++ t.Fatalf("stored items = %+v", items)
++ }
++ if got, err := lan.Accept(items[0].ID); err != nil || got != envelope {
++ t.Fatalf("accept = %q, %v", got, err)
++ }
++}
++
++// A sender on the same network hands the sealed item straight to the target: the relay is not called for the upload.
++func TestSendUsesLANBeforeTheRelay(t *testing.T) {
++ lan, secret := startTestLAN(t)
++ relayCalls := 0
++ network := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ switch r.URL.Path {
++ case "/api/v1/link/self/send/targets":
++ relayCalls++
++ _ = json.NewEncoder(w).Encode(map[string]any{"machines": []map[string]any{{
++ "id": lanTarget, "name": "Workshop", "online": true,
++ "public_key": map[string]string{"kty": "EC", "crv": "P-256", "x": "x", "y": "y"},
++ "lan": []string{"http://" + lan.ln.Addr().String()},
++ }}})
++ case "/api/v1/link/self/send/lan-key":
++ relayCalls++
++ _ = json.NewEncoder(w).Encode(map[string]string{"key": base64.RawURLEncoding.EncodeToString([]byte(secret))})
++ default:
++ relayCalls += 100
++ t.Errorf("the relay was asked to upload over %s %s", r.Method, r.URL.Path)
++ }
++ }))
++ defer network.Close()
++ c := &Client{BaseURL: network.URL, Tokens: staticToken("node-token"), HTTP: network.Client(),
++ Self: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ"}
++ envelope := base64.RawStdEncoding.EncodeToString([]byte(strings.Repeat("sealed", 100)))
++ if err := c.Send(context.Background(), lanTarget, envelope); err != nil {
++ t.Fatal(err)
++ }
++ if relayCalls != 2 {
++ t.Fatalf("relay calls = %d, want only the target list and the LAN secret", relayCalls)
++ }
++ items := lan.Items()
++ if len(items) != 1 || items[0].Envelope != envelope {
++ t.Fatalf("the direct transfer stored %+v", items)
++ }
++}
ddiff --git a/internal/send/upload_test.go b/internal/send/upload_test.go
nnew file mode 100644
iindex 0000000..44068ce
---- /dev/null
++++ b/internal/send/upload_test.go
@@@ -0,0 +1,86 @@
++package send
++
++import (
++ "context"
++ "encoding/json"
++ "fmt"
++ "net/http"
++ "net/http/httptest"
++ "strings"
++ "sync"
++ "testing"
++)
++
++// A dropped part must not restart the transfer: Link asks the relay which part it holds and continues there. The
++// finished item is the exact ciphertext the sender began with.
++func TestSendResumesAfterDroppedPart(t *testing.T) {
++ const id = "upl_0123456789abcdef0123456789abcdef"
++ envelope := strings.Repeat("QUJD", 300000) // ~1.2 MiB: two parts
++ var mu sync.Mutex
++ parts := map[int]string{}
++ next := 0
++ dropped := false
++ statusReads := 0
++ var finished string
++
++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ mu.Lock()
++ defer mu.Unlock()
++ switch {
++ case r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/send/upload":
++ w.Header().Set("Content-Type", "application/json")
++ w.WriteHeader(http.StatusCreated)
++ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 0, "seq": 0})
++ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/link/self/send/upload/"+id+"/"):
++ var seq int
++ if _, err := fmt.Sscanf(strings.TrimPrefix(r.URL.Path, "/api/v1/link/self/send/upload/"+id+"/"), "%d", &seq); err != nil {
++ t.Fatalf("bad part path %s", r.URL.Path)
++ }
++ var body struct {
++ Chunk string `json:"chunk"`
++ }
++ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
++ t.Fatal(err)
++ }
++ if seq == 1 && !dropped {
++ dropped = true
++ w.WriteHeader(http.StatusBadGateway)
++ return
++ }
++ if seq != next {
++ t.Fatalf("part %d arrived while the relay held %d", seq, next)
++ }
++ parts[seq] = body.Chunk
++ next++
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 1, "seq": next})
++ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/link/self/send/upload/"+id:
++ statusReads++
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 1, "seq": next})
++ case r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/send/upload/"+id+"/finish":
++ var b strings.Builder
++ for i := 0; i < next; i++ {
++ b.WriteString(parts[i])
++ }
++ finished = b.String()
++ w.Header().Set("Content-Type", "application/json")
++ w.WriteHeader(http.StatusCreated)
++ _ = json.NewEncoder(w).Encode(map[string]any{"id": "snd_0123456789abcdef0123456789abcdef"})
++ default:
++ t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
++ }
++ }))
++ defer server.Close()
++
++ c := &Client{BaseURL: server.URL, Tokens: staticToken("node-token"), HTTP: server.Client()}
++ if err := c.Send(context.Background(), "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ", envelope); err != nil {
++ t.Fatal(err)
++ }
++ if !dropped || statusReads == 0 {
++ t.Fatalf("the test did not drop a part (dropped=%v statusReads=%d)", dropped, statusReads)
++ }
++ if finished != envelope {
++ t.Fatalf("the relay assembled %d bytes, want %d", len(finished), len(envelope))
++ }
++}