openvibers/openvibe.link Commits
Add encrypted Link Send to the local window (#141)
Changes
@@@ -114,8 +114,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 itselfat `127.0.0.1` that shows what runs here, what this computer allows, your shares, the coding-agent CLIs and the Codestasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,--share a port, or send files (not in this version). 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.++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++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.**Your machines:** [openvibe.link/machines](https://openvibe.link/machines) opens the signed-in machines hub: eachpaired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a@@@ -5,8 +5,8 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.49.0",-- "release": "v0.49.0: Link can host a shared party desktop in a temporary neko Google Chrome container, with a bridge-bound public-web egress proxy and managed Chrome policy. The party owner can stop it from the Link window.",++ "version": "0.50.0",++ "release": "v0.50.0: Send to my machine. Link's window sends a file or a note, encrypted on this computer with openvibe-sdk/locker, to another of your paired machines (or from openvibe.link in a browser); the receiving window lists the sender and size and asks before saving.","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,@@@ -39,7 +39,13 @@ 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/files` | Sends files to your other devices | `501` with a reason: not in the daemon yet |++| `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": "nod_…", "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/: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` omits what the Node cannot fill truthfully: `devices` (the paired account's machine list is not read here),`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approval@@@ -17,9 +17,11 @@ import ("time""github.com/OpenVibers/OpenVibe.Node/internal/firstrun"++ "github.com/OpenVibers/OpenVibe.Node/internal/link""github.com/OpenVibers/OpenVibe.Node/internal/panel""github.com/OpenVibers/OpenVibe.Node/internal/plugins""github.com/OpenVibers/OpenVibe.Node/internal/protocol"++ "github.com/OpenVibers/OpenVibe.Node/internal/send")// panelCapabilities are the switches the window shows, in its order. Every one is a switch the daemon already has:@@@ -69,6 +71,75 @@ type panelFirstRun struct {Uses []string `json:"uses,omitempty"`}++type panelSend struct {++ key func() any++ targets func() ([]panel.SendMachine, error)++ send func(string, string) error++ inbox func() ([]panel.IncomingSend, error)++ accept func(string) (string, error)++ decline func(string) error++ complete func(string) error++}++++func (n *Node) panelSendOptions(ctx context.Context) panelSend {++ if n.opt.Creds == nil || !n.opt.Creds.NetworkPaired() {++ return panelSend{}++ }++ key, err := send.LoadOrCreate(n.opt.Paths.ConfigDir)++ if err != nil {++ n.log.Warn("Link Send is unavailable: the receive key could not be opened", "err", err)++ return panelSend{}++ }++ client := &send.Client{BaseURL: n.opt.Creds.Network,++ Tokens: link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, "openvibe.network"),++ Agent: "openvibe-node/" + n.opt.Version}++ go func() {++ t := time.NewTicker(time.Minute)++ defer t.Stop()++ for {++ if err := client.Register(ctx, key); err == nil {++ return++ }++ select {++ case <-ctx.Done():++ return++ case <-t.C:++ }++ }++ }()++ return panelSend{++ key: func() any {++ return map[string]any{"private_key": key, "subject": map[string]string{"type": "node", "id": n.opt.Creds.Principal}}++ },++ targets: func() ([]panel.SendMachine, error) {++ machines, err := client.Targets(ctx)++ if err != nil {++ return nil, err++ }++ out := make([]panel.SendMachine, 0, len(machines))++ for _, m := range machines {++ out = append(out, panel.SendMachine{ID: m.ID, Name: m.Name, Online: m.Online, PublicKey: m.PublicKey})++ }++ return out, nil++ },++ send: func(target, envelope string) error { return client.Send(ctx, target, envelope) },++ inbox: func() ([]panel.IncomingSend, error) {++ items, err := client.Inbox(ctx)++ if err != nil {++ return nil, err++ }++ out := make([]panel.IncomingSend, 0, len(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})++ }++ 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) },++ }++}++type panelSaved struct {Off []string `json:"off"`// FirstRun is absent in a file written before the question existed.@@@ -123,6 +194,7 @@ func (n *Node) startPanel(ctx context.Context) {n.log.Warn("Link's window off: no token", "err", err)return}++ transfer := n.panelSendOptions(ctx)srv, err := panel.Listen(panel.Options{Token: token,State: n.panelState,@@@ -133,6 +205,13 @@ func (n *Node) startPanel(ctx context.Context) {Unshare: n.shareRemove,FirstRun: n.applyFirstRun,CodesStop: n.panelStopCodesTask,++ SendKey: transfer.key,++ SendTargets: transfer.targets,++ SendFile: transfer.send,++ SendInbox: transfer.inbox,++ SendAccept: transfer.accept,++ SendDecline: transfer.decline,++ SendComplete: transfer.complete,Log: n.log,})if err != nil {@@@ -154,6 +154,22 @@ type FirstRunAnswer struct {Skip bool `json:"skip"`}++// SendMachine and IncomingSend are the Link Send payloads shown in the window. An IncomingSend carries no ciphertext:++// the window asks for it through accept when the person chooses to receive the item.++type SendMachine struct {++ ID string `json:"id"`++ Name string `json:"name"`++ Online bool `json:"online"`++ PublicKey any `json:"public_key"`++}++type IncomingSend struct {++ ID string `json:"id"`++ Sender string `json:"sender_node,omitempty"`++ SenderName string `json:"sender_name,omitempty"`++ Bytes int64 `json:"bytes"`++ CreatedAt time.Time `json:"created_at"`++}++// Options are what the daemon gives the panel: its state, and the actions the window can ask for.type Options struct {// Token is the per-run secret the page sends as X-Link-Panel. Required.@@@ -174,8 +190,15 @@ type Options struct {FirstRun func(uses []string, skip 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-- Log *slog.Logger++ CodesStop func(id string) error++ SendKey func() any++ SendTargets func() ([]SendMachine, error)++ SendFile func(target, envelope string) error++ SendInbox func() ([]IncomingSend, error)++ SendAccept func(id string) (string, error)++ SendDecline func(id string) error++ SendComplete func(id string) error++ Log *slog.Logger}// Server is one running panel server.@@@ -277,7 +300,7 @@ func (s *Server) api() http.Handler {writeJSON(w, http.StatusForbidden, errBody("forbidden"))return}-- r.Body = http.MaxBytesReader(w, r.Body, 1<<20)++ r.Body = http.MaxBytesReader(w, r.Body, 40<<20)path := strings.TrimPrefix(r.URL.Path, "/panel/api/")switch {case path == "state" && r.Method == http.MethodGet:@@@ -286,6 +309,34 @@ func (s *Server) api() http.Handler {st = s.opt.State()}writeJSON(w, http.StatusOK, st)++ case path == "send/key" && r.Method == http.MethodGet:++ if s.opt.SendKey == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))++ return++ }++ writeJSON(w, http.StatusOK, map[string]any{"private_key": s.opt.SendKey()})++ case path == "send/targets" && r.Method == http.MethodGet:++ if s.opt.SendTargets == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("sending is not available on this machine"))++ return++ }++ items, err := s.opt.SendTargets()++ if err != nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody(err.Error()))++ return++ }++ writeJSON(w, http.StatusOK, map[string]any{"machines": items})++ case path == "send/inbox" && r.Method == http.MethodGet:++ if s.opt.SendInbox == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))++ return++ }++ items, err := s.opt.SendInbox()++ if err != nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody(err.Error()))++ return++ }++ writeJSON(w, http.StatusOK, map[string]any{"items": items})case path == "pause" && r.Method == http.MethodPost:var body struct {Paused *bool `json:"paused"`@@@ -412,8 +463,57 @@ func (s *Server) api() http.Handler {}writeJSON(w, http.StatusOK, map[string]bool{"first_run": false})case path == "files" && r.Method == http.MethodPost:-- // Sending files between devices is not in the daemon yet; the window already says it could not send them.-- writeJSON(w, http.StatusNotImplemented, errBody("sending files between devices is not available in this version of Link"))++ var body struct {++ Target string `json:"target_node"`++ Envelope string `json:"envelope"`++ }++ if !decode(w, r, &body) || body.Target == "" || body.Envelope == "" {++ writeJSON(w, http.StatusBadRequest, errBody("choose a machine and provide an encrypted envelope"))++ return++ }++ if s.opt.SendFile == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("sending is not available on this machine"))++ return++ }++ if err := s.opt.SendFile(body.Target, body.Envelope); err != nil {++ writeJSON(w, http.StatusConflict, errBody(err.Error()))++ return++ }++ writeJSON(w, http.StatusCreated, map[string]bool{"sent": true})++ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/accept") && r.Method == http.MethodPost:++ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/accept")++ if s.opt.SendAccept == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))++ return++ }++ envelope, err := s.opt.SendAccept(id)++ if err != nil {++ writeJSON(w, http.StatusConflict, errBody(err.Error()))++ return++ }++ writeJSON(w, http.StatusOK, map[string]string{"envelope": envelope})++ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/decline") && r.Method == http.MethodPost:++ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/decline")++ if s.opt.SendDecline == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))++ return++ }++ if err := s.opt.SendDecline(id); err != nil {++ writeJSON(w, http.StatusConflict, errBody(err.Error()))++ return++ }++ w.WriteHeader(http.StatusNoContent)++ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/complete") && r.Method == http.MethodPost:++ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/complete")++ if s.opt.SendComplete == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))++ return++ }++ if err := s.opt.SendComplete(id); err != nil {++ writeJSON(w, http.StatusConflict, errBody(err.Error()))++ return++ }++ w.WriteHeader(http.StatusNoContent)default:http.NotFound(w, r)}@@@ -433,7 +533,7 @@ func (s *Server) authorized(r *http.Request) bool {}func decode(w http.ResponseWriter, r *http.Request, out any) bool {-- dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))++ dec := json.NewDecoder(io.LimitReader(r.Body, 40<<20))dec.DisallowUnknownFields()return dec.Decode(out) == nil}@@@ -333,17 +333,36 @@ func TestFirstRunWithoutAWayToRecordItSaysSo(t *testing.T) {}}--func TestFilesAnswer501WithAReason(t *testing.T) {-- srv := testServer(t, Options{})-- w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", "")-- if w.Code != http.StatusNotImplemented {-- t.Fatalf("files: %d", w.Code)++func TestFilesSendEncryptedEnvelope(t *testing.T) {++ var gotTarget, gotEnvelope string++ srv := testServer(t, Options{SendFile: func(target, envelope string) error { gotTarget, gotEnvelope = target, envelope; return nil }})++ w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", `{"target_node":"nod_example","envelope":"Y2lwaGVydGV4dA=="}`)++ if w.Code != http.StatusCreated || gotTarget != "nod_example" || gotEnvelope != "Y2lwaGVydGV4dA==" {++ t.Fatalf("send: status %d target %q envelope %q body %s", w.Code, gotTarget, gotEnvelope, w.Body)++ }++}++++func TestIncomingItemsNeedAnExplicitAcceptOrDecline(t *testing.T) {++ accepted, declined, completed := false, false, false++ srv := testServer(t, Options{++ SendInbox: func() ([]IncomingSend, error) {++ return []IncomingSend{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Desk PC", Bytes: 2048}}, nil++ },++ SendAccept: func(id string) (string, error) { accepted = id != ""; return "ciphertext", nil },++ SendDecline: func(id string) error { declined = id != ""; return nil },++ SendComplete: func(id string) error { completed = id != ""; return nil },++ })++ if w := do(t, srv, http.MethodGet, "/panel/api/send/inbox", testToken, "", ""); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Desk PC") || strings.Contains(w.Body.String(), "envelope") {++ t.Fatalf("inbox: %d %s", w.Code, w.Body)++ }++ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/accept", testToken, "", "{}"); w.Code != http.StatusOK || !accepted || !strings.Contains(w.Body.String(), "ciphertext") {++ t.Fatalf("accept: %d %s", w.Code, w.Body)}-- var body struct {-- Error string `json:"error"`++ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/complete", testToken, "", "{}"); w.Code != http.StatusNoContent || !completed {++ t.Fatalf("complete: %d %s", w.Code, w.Body)}-- if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || !strings.Contains(body.Error, "files") {-- t.Fatalf("reason: %q %v", w.Body, err)++ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/decline", testToken, "", "{}"); w.Code != http.StatusNoContent || !declined {++ t.Fatalf("decline: %d %s", w.Code, w.Body)}}@@@ -21,7 +21,13 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. || `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. || `DELETE /panel/api/shares/:port` | — | Ends that share. |--| `POST /panel/api/files` | multipart `file`… | Sends the files to the person's other devices. |++| `GET /panel/api/send/targets` | — | The person's other paired machines with receive keys. |++| `GET /panel/api/send/inbox` | — | Pending encrypted items, shown with Accept and Decline. |++| `GET /panel/api/send/key` | — | This machine's Locker private key, returned only to the token-protected loopback window. |++| `POST /panel/api/files` | `{ "target_node": "nod_…", "envelope": "base64" }` | Relays Locker ciphertext to one of the person's other machines. |++| `POST /panel/api/files/:id/accept` | — | Returns ciphertext for local Locker opening after the person chooses Accept. |++| `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. |## `GET /panel/api/state`@@@ -60,9 +60,15 @@<section class="lk-sec" data-lk-sec="drop" aria-labelledby="lk-drop-h"><h2 id="lk-drop-h" class="lk-sr">Send files</h2>-- <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send files to your other devices</b><small>Drop them here, or choose</small></label>-- <input id="lk-files" class="lk-sr" type="file" multiple data-lk-files>-- <p class="lk-fine" data-lk-drop-say role="status"></p>++ <label class="lk-fine" for="lk-send-target">Send to</label>++ <select id="lk-send-target" data-lk-send-target><option value="">Loading your machines…</option></select>++ <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send a file</b><small>Drop it here, or choose</small></label>++ <input id="lk-files" class="lk-sr" type="file" data-lk-files>++ <label class="lk-fine" for="lk-send-note">Or send a text note</label>++ <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>++ <h3>Waiting for you</h3><ul class="lk-send-inbox" data-lk-send-inbox></ul></section></div>@@@ -113,6 +113,12 @@ a.lk-btn.is-primary { color: var(--on-accent); }.lk-drop-ic { width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 18%, transparent); position: relative; }.lk-drop-ic::after { content: "↑"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent-2); font-weight: 900; }.lk-drop small { color: var(--muted); }++.lk-sec[data-lk-sec="drop"] select,.lk-sec[data-lk-sec="drop"] textarea { width:100%;box-sizing:border-box;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text);font:inherit }++.lk-sec[data-lk-sec="drop"]>button,.lk-send-item button { padding:8px 11px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text);font:inherit;font-weight:650;cursor:pointer }++.lk-send-inbox { display:grid;gap:8px;list-style:none;margin:8px 0 0;padding:0 }++.lk-send-item { display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border:1px solid var(--line);border-radius:12px }++.lk-send-item span { color:var(--muted);font-size:12px }++.lk-send-item div { display:flex;gap:6px }.lk-gpu-name { margin: 0 0 8px; font-weight: 700; }.lk-models { display: grid; gap: 6px; }@@@ -173,18 +173,87 @@if (!(port >= 1 && port <= 65535)) return;var f = this; send('shares', 'POST', { port: port }).then(function () { f.reset(); });});-- var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]');-- function sendFiles(files) {-- if (!files || !files.length) return;-- var fd = new FormData(); for (var i = 0; i < files.length; i++) fd.append('file', files[i]);-- say.textContent = 'Sending ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…';-- fetch('/panel/api/files', { method: 'POST', body: fd, headers: { 'X-Link-Panel': token }, credentials: 'same-origin' })-- .then(function (r) { say.textContent = r.ok ? 'Sent to your other devices.' : 'Link could not send them (' + r.status + ').'; }, function () { say.textContent = 'Link is not answering.'; });++ var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');++ var sendKey = null, lockerModule = null;++ 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) {++ if (!r.ok) return r.json().catch(function () { return {}; }).then(function (x) { throw new Error(x.error || 'Link said ' + r.status); });++ return r.status === 204 ? null : r.json();++ });}++ 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' }));++ 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; })); }++ function loadTargets() {++ panelApi('send/targets').then(function (body) {++ var old = targetSelect.value; targetSelect.replaceChildren(new Option('Choose one of your machines', ''));++ (body.machines || []).forEach(function (m) { var opt = new Option(m.name + (m.online ? '' : ' (offline)'), m.id); opt.dataset.publicKey = JSON.stringify(m.public_key); targetSelect.add(opt); });++ if (old) targetSelect.value = old;++ 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) {++ 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);++ return locker.seal(content, { lock: 'people', keys: [{ subject: { type: 'node', id: opt.value }, publicKey: publicKey }] }).then(function (sealed) {++ say('Sending encrypted data…');++ return panelApi('files', 'POST', { target_node: opt.value, envelope: base64(sealed.bytes) });++ });++ }).then(function () { say('Sent. The receiving machine will ask before saving it.'); loadTargets(); })++ .catch(function (e) { say(e.message || 'Link could not send this item.'); });++ }++ 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 downloadAccepted(item) {++ var path = 'files/' + encodeURIComponent(item.id);++ return Promise.all([moduleLocker(), panelApi('send/key'), panelApi(path + '/accept', 'POST', {})]).then(function (parts) {++ var locker = parts[0], own = parts[1], envelope = unbase64(parts[2].envelope || '');++ if (!envelope.length) throw new Error('The encrypted item is no longer waiting.');++ return locker.open(envelope, { privateKey: own.private_key.privateKey || own.private_key, subject: own.subject });++ }).then(function (bytes) {++ 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.'); });++ });++ }++ function size(bytes) {++ var n = Number(bytes) || 0;++ return n < 1024 ? n + ' B' : n < 1048576 ? Math.round(n / 1024) + ' KB' : (n / 1048576).toFixed(1) + ' MB';++ }++ function loadInbox() {++ panelApi('send/inbox').then(function (body) {++ var list = $('[data-lk-send-inbox]');++ 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');++ accept.type = decline.type = 'button';++ accept.addEventListener('click', function () { accept.disabled = true; say('Opening the encrypted item…'); downloadAccepted(item).catch(function (e) { accept.disabled = false; say(e.message || 'The item could not be opened. It is still waiting.'); }); });++ decline.addEventListener('click', function () { decline.disabled = true; panelApi('files/' + encodeURIComponent(item.id) + '/decline', 'POST', {}).then(loadInbox).catch(function (e) { decline.disabled = false; say(e.message); }); });++ actions.append(accept, decline); li.append(label, actions); return li;++ }));++ }).catch(function () {});++ }++ var dropTarget = $('[data-lk-drop]');['dragenter', 'dragover'].forEach(function (t) { drop.addEventListener(t, function (e) { e.preventDefault(); drop.classList.add('is-over'); }); });['dragleave', 'drop'].forEach(function (t) { drop.addEventListener(t, function () { drop.classList.remove('is-over'); }); });-- drop.addEventListener('drop', function (e) { e.preventDefault(); sendFiles(e.dataTransfer.files); });-- $('[data-lk-files]').addEventListener('change', function () { sendFiles(this.files); this.value = ''; });++ 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);++ loadTargets(); loadInbox(); setInterval(function () { if (document.visibilityState === 'visible') { loadTargets(); loadInbox(); } }, 3000);function tick() { if (document.visibilityState === 'visible') load(); }load();timer = setInterval(tick, 3000);nnew file mode 100644@@@ -0,0 +1,148 @@++package send++++import (++ "bytes"++ "context"++ "encoding/json"++ "fmt"++ "io"++ "net/http"++ "regexp"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"++)++++type Tokens interface {++ Token(context.Context) (credentials.Secret, error)++ Invalidate()++}++++var itemID = regexp.MustCompile(`^snd_[a-f0-9]{32}$`)++++type Client struct {++ BaseURL string++ Tokens Tokens++ HTTP *http.Client++ Agent string++}++++type Machine struct {++ ID string `json:"id"`++ Name string `json:"name"`++ Online bool `json:"online"`++ PublicKey JWK `json:"public_key"`++}++++// Item is one waiting transfer as the inbox lists it: who sent it and how big it is. The ciphertext is not in the++// list (it is polled every few seconds); Accept fetches it once.++type Item struct {++ ID string `json:"id"`++ Sender string `json:"sender_node"`++ SenderName string `json:"sender_name"`++ Bytes int64 `json:"bytes"`++ CreatedAt time.Time `json:"created_at"`++ ExpiresAt time.Time `json:"expires_at"`++}++++func (c *Client) Register(ctx context.Context, key JWK) error {++ return c.do(ctx, http.MethodPut, "/api/v1/node/self/send/key", map[string]any{"public_key": Public(key)}, nil)++}++func (c *Client) Targets(ctx context.Context) ([]Machine, error) {++ var out struct {++ Machines []Machine `json:"machines"`++ }++ err := c.do(ctx, http.MethodGet, "/api/v1/node/self/send/targets", nil, &out)++ return out.Machines, err++}++func (c *Client) Send(ctx context.Context, target, envelope string) error {++ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil)++}++func (c *Client) Inbox(ctx context.Context) ([]Item, error) {++ var out struct {++ Items []Item `json:"items"`++ }++ err := c.do(ctx, http.MethodGet, "/api/v1/node/self/send/inbox", nil, &out)++ return out.Items, err++}++func (c *Client) Accept(ctx context.Context, id string) (string, error) {++ if !itemID.MatchString(id) {++ return "", fmt.Errorf("this transfer is no longer available")++ }++ var out struct {++ Envelope string `json:"envelope"`++ }++ err := c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/accept", map[string]string{}, &out)++ return out.Envelope, err++}++func (c *Client) Decline(ctx context.Context, id string) error {++ if !itemID.MatchString(id) {++ return fmt.Errorf("this transfer is no longer available")++ }++ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/decline", map[string]string{}, nil)++}++func (c *Client) Complete(ctx context.Context, id string) error {++ if !itemID.MatchString(id) {++ return fmt.Errorf("this transfer is no longer available")++ }++ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/complete", map[string]string{}, nil)++}++++func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {++ if c == nil || c.Tokens == nil || c.BaseURL == "" {++ return fmt.Errorf("this machine is not paired for Link Send")++ }++ tok, err := c.Tokens.Token(ctx)++ if err != nil {++ return err++ }++ var data io.Reader++ if body != nil {++ b, e := json.Marshal(body)++ if e != nil {++ return e++ }++ data = bytes.NewReader(b)++ }++ req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, data)++ if err != nil {++ return err++ }++ req.Header.Set("Authorization", "Bearer "+tok.Reveal())++ req.Header.Set("Accept", "application/json")++ if body != nil {++ req.Header.Set("Content-Type", "application/json")++ }++ if c.Agent != "" {++ req.Header.Set("User-Agent", c.Agent)++ }++ client := c.HTTP++ if client == nil {++ client = &http.Client{Timeout: 45 * time.Second}++ }++ resp, err := client.Do(req)++ if err != nil {++ return fmt.Errorf("OpenVibe could not be reached")++ }++ defer resp.Body.Close()++ if resp.StatusCode < 200 || resp.StatusCode >= 300 {++ b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))++ var problem struct {++ Detail string `json:"detail"`++ }++ _ = json.Unmarshal(b, &problem)++ if problem.Detail == "" {++ problem.Detail = "the transfer was refused"++ }++ return fmt.Errorf("%s", problem.Detail)++ }++ if out == nil {++ _, err = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))++ return err++ }++ if err := json.NewDecoder(io.LimitReader(resp.Body, 40<<20)).Decode(out); err != nil {++ return fmt.Errorf("OpenVibe returned an unreadable transfer")++ }++ return nil++}nnew file mode 100644@@@ -0,0 +1,54 @@++package send++++import (++ "context"++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "testing"++++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"++)++++type staticToken string++++func (s staticToken) Token(context.Context) (credentials.Secret, error) {++ return credentials.NewSecret(string(s)), nil++}++func (staticToken) Invalidate() {}++++func TestClientUsesNodeTokenAndOnlyPublishesPublicReceiveKey(t *testing.T) {++ var calls int++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ calls++++ if r.Header.Get("Authorization") != "Bearer node-token" {++ t.Errorf("authorization = %q", r.Header.Get("Authorization"))++ }++ if r.URL.Path != "/api/v1/node/self/send/key" || r.Method != http.MethodPut {++ t.Errorf("request = %s %s", r.Method, r.URL.Path)++ }++ var body struct {++ PublicKey JWK `json:"public_key"`++ }++ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {++ t.Fatal(err)++ }++ if body.PublicKey.D != "" || body.PublicKey.X == "" {++ t.Errorf("registered key includes private data or is empty: %#v", body.PublicKey)++ }++ w.Header().Set("Content-Type", "application/json")++ _, _ = w.Write([]byte(`{"node_id":"nod_test","key_thumbprint":"thumb"}`))++ }))++ defer server.Close()++ key, err := LoadOrCreate(t.TempDir())++ if err != nil {++ t.Fatal(err)++ }++ c := &Client{BaseURL: server.URL, Tokens: staticToken("node-token"), HTTP: server.Client()}++ if err := c.Register(context.Background(), key); err != nil {++ t.Fatal(err)++ }++ if calls != 1 {++ t.Fatalf("made %d calls", calls)++ }++}nnew file mode 100644@@@ -0,0 +1,91 @@++package send++++import (++ "crypto/ecdsa"++ "crypto/elliptic"++ "crypto/rand"++ "encoding/base64"++ "encoding/json"++ "errors"++ "math/big"++ "os"++ "path/filepath"++ "runtime"++)++++// JWK is the P-256 receive key shape used by openvibe-sdk/locker. The private fields stay in the Node's 0600 config++// file and are returned only to its token-protected loopback panel so the user can open an accepted envelope.++type JWK struct {++ Kty string `json:"kty"`++ Crv string `json:"crv"`++ X string `json:"x"`++ Y string `json:"y"`++ D string `json:"d,omitempty"`++}++++func LoadOrCreate(dir string) (JWK, error) {++ path := filepath.Join(dir, "link-send-key.json")++ if b, err := os.ReadFile(path); err == nil {++ info, statErr := os.Lstat(path)++ if statErr != nil || !info.Mode().IsRegular() || (runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0) {++ return JWK{}, errors.New("the saved Link receive key must be a regular file accessible only to this account")++ }++ var key JWK++ if json.Unmarshal(b, &key) != nil || !valid(key) {++ return JWK{}, errors.New("the saved Link receive key is invalid")++ }++ return key, nil++ } else if !errors.Is(err, os.ErrNotExist) {++ return JWK{}, err++ }++ if err := os.MkdirAll(dir, 0o700); err != nil {++ return JWK{}, err++ }++ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)++ if err != nil {++ return JWK{}, err++ }++ key := JWK{Kty: "EC", Crv: "P-256", X: b64(private.X), Y: b64(private.Y), D: b64(private.D)}++ b, err := json.Marshal(key)++ if err != nil {++ return JWK{}, err++ }++ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)++ if err != nil {++ if errors.Is(err, os.ErrExist) {++ return LoadOrCreate(dir)++ }++ return JWK{}, err++ }++ if _, err = f.Write(append(b, '\n')); err != nil {++ _ = f.Close()++ return JWK{}, err++ }++ if err = f.Close(); err != nil {++ return JWK{}, err++ }++ return key, nil++}++++func Public(key JWK) JWK { key.D = ""; return key }++++func valid(key JWK) bool {++ if key.Kty != "EC" || key.Crv != "P-256" || key.D == "" {++ return false++ }++ x, errX := unb64(key.X)++ y, errY := unb64(key.Y)++ d, errD := unb64(key.D)++ curve := elliptic.P256()++ if errX != nil || errY != nil || errD != nil || len(x) != 32 || len(y) != 32 || len(d) != 32 {++ return false++ }++ return curve.IsOnCurve(new(big.Int).SetBytes(x), new(big.Int).SetBytes(y))++}++++func b64(n *big.Int) string {++ raw := make([]byte, 32)++ n.FillBytes(raw)++ return base64.RawURLEncoding.EncodeToString(raw)++}++func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }nnew file mode 100644@@@ -0,0 +1,60 @@++package send++++import (++ "os"++ "path/filepath"++ "runtime"++ "testing"++)++++func TestReceiveKeyPersistsPrivatelyAndPublishesOnlyPublicFields(t *testing.T) {++ dir := t.TempDir()++ key, err := LoadOrCreate(dir)++ if err != nil {++ t.Fatal(err)++ }++ if key.Kty != "EC" || key.Crv != "P-256" || key.D == "" {++ t.Fatalf("bad private key: %#v", key)++ }++ info, err := os.Stat(filepath.Join(dir, "link-send-key.json"))++ if err != nil {++ t.Fatal(err)++ }++ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {++ t.Fatalf("key mode = %o, want 600", info.Mode().Perm())++ }++ public := Public(key)++ if public.D != "" || public.X != key.X || public.Y != key.Y {++ t.Fatalf("bad public projection: %#v", public)++ }++ loaded, err := LoadOrCreate(dir)++ if err != nil {++ t.Fatal(err)++ }++ if loaded != key {++ t.Fatal("receive key changed across restart")++ }++}++++func TestReceiveKeyRejectsMalformedSavedKey(t *testing.T) {++ dir := t.TempDir()++ if err := os.WriteFile(filepath.Join(dir, "link-send-key.json"), []byte(`{"kty":"RSA"}`), 0o600); err != nil {++ t.Fatal(err)++ }++ if _, err := LoadOrCreate(dir); err == nil {++ t.Fatal("accepted a malformed saved key")++ }++}++++func TestReceiveKeyRejectsBroadPermissions(t *testing.T) {++ if runtime.GOOS == "windows" {++ t.Skip("Windows file modes do not expose POSIX group and other permissions")++ }++ dir := t.TempDir()++ if err := os.WriteFile(filepath.Join(dir, "link-send-key.json"), []byte(`{"kty":"EC","crv":"P-256","x":"x","y":"y","d":"d"}`), 0o644); err != nil {++ t.Fatal(err)++ }++ if _, err := LoadOrCreate(dir); err == nil {++ t.Fatal("accepted a receive key file readable by other accounts")++ }++}
@@ -114,8 +114,10 @@ turns on a feature you turned off here.
@@ -114,8 +114,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
**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
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,
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 files (not in this version). 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.
+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
+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.
**Your machines:** [openvibe.link/machines](https://openvibe.link/machines) opens the signed-in machines hub: each
**Your machines:** [openvibe.link/machines](https://openvibe.link/machines) opens the signed-in machines hub: each
paired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a
paired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.49.0",
- "release": "v0.49.0: Link can host a shared party desktop in a temporary neko Google Chrome container, with a bridge-bound public-web egress proxy and managed Chrome policy. The party owner can stop it from the Link window.",
+ "version": "0.50.0",
+ "release": "v0.50.0: Send to my machine. Link's window sends a file or a note, encrypted on this computer with openvibe-sdk/locker, to another of your paired machines (or from openvibe.link in a browser); the receiving window lists the sender and size and asks before saving.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -39,7 +39,13 @@ A restart makes a new port and a new token: an old link stops working.
@@ -39,7 +39,13 @@ 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 |
| `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 |
| `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/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/files` | Sends files to your other devices | `501` with a reason: not in the daemon yet |
+| `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": "nod_…", "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/: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` omits what the Node cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`state` omits what the Node cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approval
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approval
@@ -17,9 +17,11 @@ import (
@@ -17,9 +17,11 @@ import (
"time"
"time"
"github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
+ "github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+ "github.com/OpenVibers/OpenVibe.Node/internal/send"
)
)
// panelCapabilities are the switches the window shows, in its order. Every one is a switch the daemon already has:
// panelCapabilities are the switches the window shows, in its order. Every one is a switch the daemon already has:
@@ -69,6 +71,75 @@ type panelFirstRun struct {
@@ -69,6 +71,75 @@ type panelFirstRun struct {
Uses []string `json:"uses,omitempty"`
Uses []string `json:"uses,omitempty"`
}
}
+type panelSend struct {
+ key func() any
+ targets func() ([]panel.SendMachine, error)
+ send func(string, string) error
+ inbox func() ([]panel.IncomingSend, error)
+ accept func(string) (string, error)
+ decline func(string) error
+ complete func(string) error
+}
+
+func (n *Node) panelSendOptions(ctx context.Context) panelSend {
+ if n.opt.Creds == nil || !n.opt.Creds.NetworkPaired() {
+ return panelSend{}
+ }
+ key, err := send.LoadOrCreate(n.opt.Paths.ConfigDir)
+ if err != nil {
+ n.log.Warn("Link Send is unavailable: the receive key could not be opened", "err", err)
+ return panelSend{}
+ }
+ client := &send.Client{BaseURL: n.opt.Creds.Network,
+ Tokens: link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, "openvibe.network"),
+ Agent: "openvibe-node/" + n.opt.Version}
+ go func() {
+ t := time.NewTicker(time.Minute)
+ defer t.Stop()
+ for {
+ if err := client.Register(ctx, key); err == nil {
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+ }()
+ return panelSend{
+ key: func() any {
+ return map[string]any{"private_key": key, "subject": map[string]string{"type": "node", "id": n.opt.Creds.Principal}}
+ },
+ targets: func() ([]panel.SendMachine, error) {
+ machines, err := client.Targets(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]panel.SendMachine, 0, len(machines))
+ for _, m := range machines {
+ out = append(out, panel.SendMachine{ID: m.ID, Name: m.Name, Online: m.Online, PublicKey: m.PublicKey})
+ }
+ return out, nil
+ },
+ send: func(target, envelope string) error { return client.Send(ctx, target, envelope) },
+ inbox: func() ([]panel.IncomingSend, error) {
+ items, err := client.Inbox(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]panel.IncomingSend, 0, len(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})
+ }
+ 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) },
+ }
+}
+
type panelSaved struct {
type panelSaved struct {
Off []string `json:"off"`
Off []string `json:"off"`
// FirstRun is absent in a file written before the question existed.
// FirstRun is absent in a file written before the question existed.
@@ -123,6 +194,7 @@ func (n *Node) startPanel(ctx context.Context) {
@@ -123,6 +194,7 @@ func (n *Node) startPanel(ctx context.Context) {
n.log.Warn("Link's window off: no token", "err", err)
n.log.Warn("Link's window off: no token", "err", err)
return
return
}
}
+ transfer := n.panelSendOptions(ctx)
srv, err := panel.Listen(panel.Options{
srv, err := panel.Listen(panel.Options{
Token: token,
Token: token,
State: n.panelState,
State: n.panelState,
@@ -133,6 +205,13 @@ func (n *Node) startPanel(ctx context.Context) {
@@ -133,6 +205,13 @@ func (n *Node) startPanel(ctx context.Context) {
Unshare: n.shareRemove,
Unshare: n.shareRemove,
FirstRun: n.applyFirstRun,
FirstRun: n.applyFirstRun,
CodesStop: n.panelStopCodesTask,
CodesStop: n.panelStopCodesTask,
+ SendKey: transfer.key,
+ SendTargets: transfer.targets,
+ SendFile: transfer.send,
+ SendInbox: transfer.inbox,
+ SendAccept: transfer.accept,
+ SendDecline: transfer.decline,
+ SendComplete: transfer.complete,
Log: n.log,
Log: n.log,
})
})
if err != nil {
if err != nil {
@@ -154,6 +154,22 @@ type FirstRunAnswer struct {
@@ -154,6 +154,22 @@ type FirstRunAnswer struct {
Skip bool `json:"skip"`
Skip bool `json:"skip"`
}
}
+// SendMachine and IncomingSend are the Link Send payloads shown in the window. An IncomingSend carries no ciphertext:
+// the window asks for it through accept when the person chooses to receive the item.
+type SendMachine struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Online bool `json:"online"`
+ PublicKey any `json:"public_key"`
+}
+type IncomingSend struct {
+ ID string `json:"id"`
+ Sender string `json:"sender_node,omitempty"`
+ SenderName string `json:"sender_name,omitempty"`
+ Bytes int64 `json:"bytes"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
// Options are what the daemon gives the panel: its state, and the actions the window can ask for.
// Options are what the daemon gives the panel: its state, and the actions the window can ask for.
type Options struct {
type Options struct {
// Token is the per-run secret the page sends as X-Link-Panel. Required.
// Token is the per-run secret the page sends as X-Link-Panel. Required.
@@ -174,8 +190,15 @@ type Options struct {
@@ -174,8 +190,15 @@ type Options struct {
FirstRun func(uses []string, skip bool) error
FirstRun func(uses []string, skip bool) error
// CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the
// 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.
// task is not running on this computer.
- CodesStop func(id string) error
- Log *slog.Logger
+ CodesStop func(id string) error
+ SendKey func() any
+ SendTargets func() ([]SendMachine, error)
+ SendFile func(target, envelope string) error
+ SendInbox func() ([]IncomingSend, error)
+ SendAccept func(id string) (string, error)
+ SendDecline func(id string) error
+ SendComplete func(id string) error
+ Log *slog.Logger
}
}
// Server is one running panel server.
// Server is one running panel server.
@@ -277,7 +300,7 @@ func (s *Server) api() http.Handler {
@@ -277,7 +300,7 @@ func (s *Server) api() http.Handler {
writeJSON(w, http.StatusForbidden, errBody("forbidden"))
writeJSON(w, http.StatusForbidden, errBody("forbidden"))
return
return
}
}
- r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ r.Body = http.MaxBytesReader(w, r.Body, 40<<20)
path := strings.TrimPrefix(r.URL.Path, "/panel/api/")
path := strings.TrimPrefix(r.URL.Path, "/panel/api/")
switch {
switch {
case path == "state" && r.Method == http.MethodGet:
case path == "state" && r.Method == http.MethodGet:
@@ -286,6 +309,34 @@ func (s *Server) api() http.Handler {
@@ -286,6 +309,34 @@ func (s *Server) api() http.Handler {
st = s.opt.State()
st = s.opt.State()
}
}
writeJSON(w, http.StatusOK, st)
writeJSON(w, http.StatusOK, st)
+ case path == "send/key" && r.Method == http.MethodGet:
+ if s.opt.SendKey == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"private_key": s.opt.SendKey()})
+ case path == "send/targets" && r.Method == http.MethodGet:
+ if s.opt.SendTargets == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("sending is not available on this machine"))
+ return
+ }
+ items, err := s.opt.SendTargets()
+ if err != nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody(err.Error()))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"machines": items})
+ case path == "send/inbox" && r.Method == http.MethodGet:
+ if s.opt.SendInbox == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
+ return
+ }
+ items, err := s.opt.SendInbox()
+ if err != nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody(err.Error()))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"items": items})
case path == "pause" && r.Method == http.MethodPost:
case path == "pause" && r.Method == http.MethodPost:
var body struct {
var body struct {
Paused *bool `json:"paused"`
Paused *bool `json:"paused"`
@@ -412,8 +463,57 @@ func (s *Server) api() http.Handler {
@@ -412,8 +463,57 @@ func (s *Server) api() http.Handler {
}
}
writeJSON(w, http.StatusOK, map[string]bool{"first_run": false})
writeJSON(w, http.StatusOK, map[string]bool{"first_run": false})
case path == "files" && r.Method == http.MethodPost:
case path == "files" && r.Method == http.MethodPost:
- // Sending files between devices is not in the daemon yet; the window already says it could not send them.
- writeJSON(w, http.StatusNotImplemented, errBody("sending files between devices is not available in this version of Link"))
+ var body struct {
+ Target string `json:"target_node"`
+ Envelope string `json:"envelope"`
+ }
+ if !decode(w, r, &body) || body.Target == "" || body.Envelope == "" {
+ writeJSON(w, http.StatusBadRequest, errBody("choose a machine and provide an encrypted envelope"))
+ return
+ }
+ if s.opt.SendFile == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("sending is not available on this machine"))
+ return
+ }
+ if err := s.opt.SendFile(body.Target, body.Envelope); err != nil {
+ writeJSON(w, http.StatusConflict, errBody(err.Error()))
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]bool{"sent": true})
+ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/accept") && r.Method == http.MethodPost:
+ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/accept")
+ if s.opt.SendAccept == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
+ return
+ }
+ envelope, err := s.opt.SendAccept(id)
+ if err != nil {
+ writeJSON(w, http.StatusConflict, errBody(err.Error()))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"envelope": envelope})
+ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/decline") && r.Method == http.MethodPost:
+ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/decline")
+ if s.opt.SendDecline == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
+ return
+ }
+ if err := s.opt.SendDecline(id); err != nil {
+ writeJSON(w, http.StatusConflict, errBody(err.Error()))
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ case strings.HasPrefix(path, "files/") && strings.HasSuffix(path, "/complete") && r.Method == http.MethodPost:
+ id := strings.TrimSuffix(strings.TrimPrefix(path, "files/"), "/complete")
+ if s.opt.SendComplete == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("receiving is not available on this machine"))
+ return
+ }
+ if err := s.opt.SendComplete(id); err != nil {
+ writeJSON(w, http.StatusConflict, errBody(err.Error()))
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
default:
default:
http.NotFound(w, r)
http.NotFound(w, r)
}
}
@@ -433,7 +533,7 @@ func (s *Server) authorized(r *http.Request) bool {
@@ -433,7 +533,7 @@ func (s *Server) authorized(r *http.Request) bool {
}
}
func decode(w http.ResponseWriter, r *http.Request, out any) bool {
func decode(w http.ResponseWriter, r *http.Request, out any) bool {
- dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
+ dec := json.NewDecoder(io.LimitReader(r.Body, 40<<20))
dec.DisallowUnknownFields()
dec.DisallowUnknownFields()
return dec.Decode(out) == nil
return dec.Decode(out) == nil
}
}
@@ -333,17 +333,36 @@ func TestFirstRunWithoutAWayToRecordItSaysSo(t *testing.T) {
@@ -333,17 +333,36 @@ func TestFirstRunWithoutAWayToRecordItSaysSo(t *testing.T) {
}
}
}
}
-func TestFilesAnswer501WithAReason(t *testing.T) {
- srv := testServer(t, Options{})
- w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", "")
- if w.Code != http.StatusNotImplemented {
- t.Fatalf("files: %d", w.Code)
+func TestFilesSendEncryptedEnvelope(t *testing.T) {
+ var gotTarget, gotEnvelope string
+ srv := testServer(t, Options{SendFile: func(target, envelope string) error { gotTarget, gotEnvelope = target, envelope; return nil }})
+ w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", `{"target_node":"nod_example","envelope":"Y2lwaGVydGV4dA=="}`)
+ if w.Code != http.StatusCreated || gotTarget != "nod_example" || gotEnvelope != "Y2lwaGVydGV4dA==" {
+ t.Fatalf("send: status %d target %q envelope %q body %s", w.Code, gotTarget, gotEnvelope, w.Body)
+ }
+}
+
+func TestIncomingItemsNeedAnExplicitAcceptOrDecline(t *testing.T) {
+ accepted, declined, completed := false, false, false
+ srv := testServer(t, Options{
+ SendInbox: func() ([]IncomingSend, error) {
+ return []IncomingSend{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Desk PC", Bytes: 2048}}, nil
+ },
+ SendAccept: func(id string) (string, error) { accepted = id != ""; return "ciphertext", nil },
+ SendDecline: func(id string) error { declined = id != ""; return nil },
+ SendComplete: func(id string) error { completed = id != ""; return nil },
+ })
+ if w := do(t, srv, http.MethodGet, "/panel/api/send/inbox", testToken, "", ""); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Desk PC") || strings.Contains(w.Body.String(), "envelope") {
+ t.Fatalf("inbox: %d %s", w.Code, w.Body)
+ }
+ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/accept", testToken, "", "{}"); w.Code != http.StatusOK || !accepted || !strings.Contains(w.Body.String(), "ciphertext") {
+ t.Fatalf("accept: %d %s", w.Code, w.Body)
}
}
- var body struct {
- Error string `json:"error"`
+ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/complete", testToken, "", "{}"); w.Code != http.StatusNoContent || !completed {
+ t.Fatalf("complete: %d %s", w.Code, w.Body)
}
}
- if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || !strings.Contains(body.Error, "files") {
- t.Fatalf("reason: %q %v", w.Body, err)
+ if w := do(t, srv, http.MethodPost, "/panel/api/files/snd_0123456789abcdef0123456789abcdef/decline", testToken, "", "{}"); w.Code != http.StatusNoContent || !declined {
+ t.Fatalf("decline: %d %s", w.Code, w.Body)
}
}
}
}
@@ -21,7 +21,13 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
@@ -21,7 +21,13 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. |
| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. |
| `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. |
| `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. |
| `DELETE /panel/api/shares/:port` | — | Ends that share. |
| `DELETE /panel/api/shares/:port` | — | Ends that share. |
-| `POST /panel/api/files` | multipart `file`… | Sends the files to the person's other devices. |
+| `GET /panel/api/send/targets` | — | The person's other paired machines with receive keys. |
+| `GET /panel/api/send/inbox` | — | Pending encrypted items, shown with Accept and Decline. |
+| `GET /panel/api/send/key` | — | This machine's Locker private key, returned only to the token-protected loopback window. |
+| `POST /panel/api/files` | `{ "target_node": "nod_…", "envelope": "base64" }` | Relays Locker ciphertext to one of the person's other machines. |
+| `POST /panel/api/files/:id/accept` | — | Returns ciphertext for local Locker opening after the person chooses Accept. |
+| `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/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. |
## `GET /panel/api/state`
## `GET /panel/api/state`
@@ -60,9 +60,15 @@
@@ -60,9 +60,15 @@
<section class="lk-sec" data-lk-sec="drop" aria-labelledby="lk-drop-h">
<section class="lk-sec" data-lk-sec="drop" aria-labelledby="lk-drop-h">
<h2 id="lk-drop-h" class="lk-sr">Send files</h2>
<h2 id="lk-drop-h" class="lk-sr">Send files</h2>
- <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send files to your other devices</b><small>Drop them here, or choose</small></label>
- <input id="lk-files" class="lk-sr" type="file" multiple data-lk-files>
- <p class="lk-fine" data-lk-drop-say role="status"></p>
+ <label class="lk-fine" for="lk-send-target">Send to</label>
+ <select id="lk-send-target" data-lk-send-target><option value="">Loading your machines…</option></select>
+ <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send a file</b><small>Drop it here, or choose</small></label>
+ <input id="lk-files" class="lk-sr" type="file" data-lk-files>
+ <label class="lk-fine" for="lk-send-note">Or send a text note</label>
+ <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>
+ <h3>Waiting for you</h3><ul class="lk-send-inbox" data-lk-send-inbox></ul>
</section>
</section>
</div>
</div>
@@ -113,6 +113,12 @@ a.lk-btn.is-primary { color: var(--on-accent); }
@@ -113,6 +113,12 @@ a.lk-btn.is-primary { color: var(--on-accent); }
.lk-drop-ic { width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 18%, transparent); position: relative; }
.lk-drop-ic { width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 18%, transparent); position: relative; }
.lk-drop-ic::after { content: "↑"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent-2); font-weight: 900; }
.lk-drop-ic::after { content: "↑"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent-2); font-weight: 900; }
.lk-drop small { color: var(--muted); }
.lk-drop small { color: var(--muted); }
+.lk-sec[data-lk-sec="drop"] select,.lk-sec[data-lk-sec="drop"] textarea { width:100%;box-sizing:border-box;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text);font:inherit }
+.lk-sec[data-lk-sec="drop"]>button,.lk-send-item button { padding:8px 11px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text);font:inherit;font-weight:650;cursor:pointer }
+.lk-send-inbox { display:grid;gap:8px;list-style:none;margin:8px 0 0;padding:0 }
+.lk-send-item { display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border:1px solid var(--line);border-radius:12px }
+.lk-send-item span { color:var(--muted);font-size:12px }
+.lk-send-item div { display:flex;gap:6px }
.lk-gpu-name { margin: 0 0 8px; font-weight: 700; }
.lk-gpu-name { margin: 0 0 8px; font-weight: 700; }
.lk-models { display: grid; gap: 6px; }
.lk-models { display: grid; gap: 6px; }
@@ -173,18 +173,87 @@
@@ -173,18 +173,87 @@
if (!(port >= 1 && port <= 65535)) return;
if (!(port >= 1 && port <= 65535)) return;
var f = this; send('shares', 'POST', { port: port }).then(function () { f.reset(); });
var f = this; send('shares', 'POST', { port: port }).then(function () { f.reset(); });
});
});
- var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]');
- function sendFiles(files) {
- if (!files || !files.length) return;
- var fd = new FormData(); for (var i = 0; i < files.length; i++) fd.append('file', files[i]);
- say.textContent = 'Sending ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…';
- fetch('/panel/api/files', { method: 'POST', body: fd, headers: { 'X-Link-Panel': token }, credentials: 'same-origin' })
- .then(function (r) { say.textContent = r.ok ? 'Sent to your other devices.' : 'Link could not send them (' + r.status + ').'; }, function () { say.textContent = 'Link is not answering.'; });
+ var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
+ var sendKey = null, lockerModule = null;
+ 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) {
+ if (!r.ok) return r.json().catch(function () { return {}; }).then(function (x) { throw new Error(x.error || 'Link said ' + r.status); });
+ return r.status === 204 ? null : r.json();
+ });
}
}
+ 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' }));
+ 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; })); }
+ function loadTargets() {
+ panelApi('send/targets').then(function (body) {
+ var old = targetSelect.value; targetSelect.replaceChildren(new Option('Choose one of your machines', ''));
+ (body.machines || []).forEach(function (m) { var opt = new Option(m.name + (m.online ? '' : ' (offline)'), m.id); opt.dataset.publicKey = JSON.stringify(m.public_key); targetSelect.add(opt); });
+ if (old) targetSelect.value = old;
+ 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) {
+ 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);
+ return locker.seal(content, { lock: 'people', keys: [{ subject: { type: 'node', id: opt.value }, publicKey: publicKey }] }).then(function (sealed) {
+ say('Sending encrypted data…');
+ return panelApi('files', 'POST', { target_node: opt.value, envelope: base64(sealed.bytes) });
+ });
+ }).then(function () { say('Sent. The receiving machine will ask before saving it.'); loadTargets(); })
+ .catch(function (e) { say(e.message || 'Link could not send this item.'); });
+ }
+ 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 downloadAccepted(item) {
+ var path = 'files/' + encodeURIComponent(item.id);
+ return Promise.all([moduleLocker(), panelApi('send/key'), panelApi(path + '/accept', 'POST', {})]).then(function (parts) {
+ var locker = parts[0], own = parts[1], envelope = unbase64(parts[2].envelope || '');
+ if (!envelope.length) throw new Error('The encrypted item is no longer waiting.');
+ return locker.open(envelope, { privateKey: own.private_key.privateKey || own.private_key, subject: own.subject });
+ }).then(function (bytes) {
+ 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.'); });
+ });
+ }
+ function size(bytes) {
+ var n = Number(bytes) || 0;
+ return n < 1024 ? n + ' B' : n < 1048576 ? Math.round(n / 1024) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
+ }
+ function loadInbox() {
+ panelApi('send/inbox').then(function (body) {
+ var list = $('[data-lk-send-inbox]');
+ 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');
+ accept.type = decline.type = 'button';
+ accept.addEventListener('click', function () { accept.disabled = true; say('Opening the encrypted item…'); downloadAccepted(item).catch(function (e) { accept.disabled = false; say(e.message || 'The item could not be opened. It is still waiting.'); }); });
+ decline.addEventListener('click', function () { decline.disabled = true; panelApi('files/' + encodeURIComponent(item.id) + '/decline', 'POST', {}).then(loadInbox).catch(function (e) { decline.disabled = false; say(e.message); }); });
+ actions.append(accept, decline); li.append(label, actions); return li;
+ }));
+ }).catch(function () {});
+ }
+ var dropTarget = $('[data-lk-drop]');
['dragenter', 'dragover'].forEach(function (t) { drop.addEventListener(t, function (e) { e.preventDefault(); drop.classList.add('is-over'); }); });
['dragenter', 'dragover'].forEach(function (t) { drop.addEventListener(t, function (e) { e.preventDefault(); drop.classList.add('is-over'); }); });
['dragleave', 'drop'].forEach(function (t) { drop.addEventListener(t, function () { drop.classList.remove('is-over'); }); });
['dragleave', 'drop'].forEach(function (t) { drop.addEventListener(t, function () { drop.classList.remove('is-over'); }); });
- drop.addEventListener('drop', function (e) { e.preventDefault(); sendFiles(e.dataTransfer.files); });
- $('[data-lk-files]').addEventListener('change', function () { sendFiles(this.files); this.value = ''; });
+ 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);
+ loadTargets(); loadInbox(); setInterval(function () { if (document.visibilityState === 'visible') { loadTargets(); loadInbox(); } }, 3000);
function tick() { if (document.visibilityState === 'visible') load(); }
function tick() { if (document.visibilityState === 'visible') load(); }
load();
load();
timer = setInterval(tick, 3000);
timer = setInterval(tick, 3000);
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+package send
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
+)
+
+type Tokens interface {
+ Token(context.Context) (credentials.Secret, error)
+ Invalidate()
+}
+
+var itemID = regexp.MustCompile(`^snd_[a-f0-9]{32}$`)
+
+type Client struct {
+ BaseURL string
+ Tokens Tokens
+ HTTP *http.Client
+ Agent string
+}
+
+type Machine struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Online bool `json:"online"`
+ PublicKey JWK `json:"public_key"`
+}
+
+// Item is one waiting transfer as the inbox lists it: who sent it and how big it is. The ciphertext is not in the
+// list (it is polled every few seconds); Accept fetches it once.
+type Item struct {
+ ID string `json:"id"`
+ Sender string `json:"sender_node"`
+ SenderName string `json:"sender_name"`
+ Bytes int64 `json:"bytes"`
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func (c *Client) Register(ctx context.Context, key JWK) error {
+ return c.do(ctx, http.MethodPut, "/api/v1/node/self/send/key", map[string]any{"public_key": Public(key)}, nil)
+}
+func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
+ var out struct {
+ Machines []Machine `json:"machines"`
+ }
+ err := c.do(ctx, http.MethodGet, "/api/v1/node/self/send/targets", nil, &out)
+ return out.Machines, err
+}
+func (c *Client) Send(ctx context.Context, target, envelope string) error {
+ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil)
+}
+func (c *Client) Inbox(ctx context.Context) ([]Item, error) {
+ var out struct {
+ Items []Item `json:"items"`
+ }
+ err := c.do(ctx, http.MethodGet, "/api/v1/node/self/send/inbox", nil, &out)
+ return out.Items, err
+}
+func (c *Client) Accept(ctx context.Context, id string) (string, error) {
+ if !itemID.MatchString(id) {
+ return "", fmt.Errorf("this transfer is no longer available")
+ }
+ var out struct {
+ Envelope string `json:"envelope"`
+ }
+ err := c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/accept", map[string]string{}, &out)
+ return out.Envelope, err
+}
+func (c *Client) Decline(ctx context.Context, id string) error {
+ if !itemID.MatchString(id) {
+ return fmt.Errorf("this transfer is no longer available")
+ }
+ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/decline", map[string]string{}, nil)
+}
+func (c *Client) Complete(ctx context.Context, id string) error {
+ if !itemID.MatchString(id) {
+ return fmt.Errorf("this transfer is no longer available")
+ }
+ return c.do(ctx, http.MethodPost, "/api/v1/node/self/send/"+id+"/complete", map[string]string{}, nil)
+}
+
+func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
+ if c == nil || c.Tokens == nil || c.BaseURL == "" {
+ return fmt.Errorf("this machine is not paired for Link Send")
+ }
+ tok, err := c.Tokens.Token(ctx)
+ if err != nil {
+ return err
+ }
+ var data io.Reader
+ if body != nil {
+ b, e := json.Marshal(body)
+ if e != nil {
+ return e
+ }
+ data = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, data)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+tok.Reveal())
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if c.Agent != "" {
+ req.Header.Set("User-Agent", c.Agent)
+ }
+ client := c.HTTP
+ if client == nil {
+ client = &http.Client{Timeout: 45 * time.Second}
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("OpenVibe could not be reached")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ var problem struct {
+ Detail string `json:"detail"`
+ }
+ _ = json.Unmarshal(b, &problem)
+ if problem.Detail == "" {
+ problem.Detail = "the transfer was refused"
+ }
+ return fmt.Errorf("%s", problem.Detail)
+ }
+ if out == nil {
+ _, err = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+ return err
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 40<<20)).Decode(out); err != nil {
+ return fmt.Errorf("OpenVibe returned an unreadable transfer")
+ }
+ return nil
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+package send
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
+)
+
+type staticToken string
+
+func (s staticToken) Token(context.Context) (credentials.Secret, error) {
+ return credentials.NewSecret(string(s)), nil
+}
+func (staticToken) Invalidate() {}
+
+func TestClientUsesNodeTokenAndOnlyPublishesPublicReceiveKey(t *testing.T) {
+ var calls int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ if r.Header.Get("Authorization") != "Bearer node-token" {
+ t.Errorf("authorization = %q", r.Header.Get("Authorization"))
+ }
+ if r.URL.Path != "/api/v1/node/self/send/key" || r.Method != http.MethodPut {
+ t.Errorf("request = %s %s", r.Method, r.URL.Path)
+ }
+ var body struct {
+ PublicKey JWK `json:"public_key"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body.PublicKey.D != "" || body.PublicKey.X == "" {
+ t.Errorf("registered key includes private data or is empty: %#v", body.PublicKey)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"node_id":"nod_test","key_thumbprint":"thumb"}`))
+ }))
+ defer server.Close()
+ key, err := LoadOrCreate(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := &Client{BaseURL: server.URL, Tokens: staticToken("node-token"), HTTP: server.Client()}
+ if err := c.Register(context.Background(), key); err != nil {
+ t.Fatal(err)
+ }
+ if calls != 1 {
+ t.Fatalf("made %d calls", calls)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+package send
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "math/big"
+ "os"
+ "path/filepath"
+ "runtime"
+)
+
+// JWK is the P-256 receive key shape used by openvibe-sdk/locker. The private fields stay in the Node's 0600 config
+// file and are returned only to its token-protected loopback panel so the user can open an accepted envelope.
+type JWK struct {
+ Kty string `json:"kty"`
+ Crv string `json:"crv"`
+ X string `json:"x"`
+ Y string `json:"y"`
+ D string `json:"d,omitempty"`
+}
+
+func LoadOrCreate(dir string) (JWK, error) {
+ path := filepath.Join(dir, "link-send-key.json")
+ if b, err := os.ReadFile(path); err == nil {
+ info, statErr := os.Lstat(path)
+ if statErr != nil || !info.Mode().IsRegular() || (runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0) {
+ return JWK{}, errors.New("the saved Link receive key must be a regular file accessible only to this account")
+ }
+ var key JWK
+ if json.Unmarshal(b, &key) != nil || !valid(key) {
+ return JWK{}, errors.New("the saved Link receive key is invalid")
+ }
+ return key, nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return JWK{}, err
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return JWK{}, err
+ }
+ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return JWK{}, err
+ }
+ key := JWK{Kty: "EC", Crv: "P-256", X: b64(private.X), Y: b64(private.Y), D: b64(private.D)}
+ b, err := json.Marshal(key)
+ if err != nil {
+ return JWK{}, err
+ }
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ if errors.Is(err, os.ErrExist) {
+ return LoadOrCreate(dir)
+ }
+ return JWK{}, err
+ }
+ if _, err = f.Write(append(b, '\n')); err != nil {
+ _ = f.Close()
+ return JWK{}, err
+ }
+ if err = f.Close(); err != nil {
+ return JWK{}, err
+ }
+ return key, nil
+}
+
+func Public(key JWK) JWK { key.D = ""; return key }
+
+func valid(key JWK) bool {
+ if key.Kty != "EC" || key.Crv != "P-256" || key.D == "" {
+ return false
+ }
+ x, errX := unb64(key.X)
+ y, errY := unb64(key.Y)
+ d, errD := unb64(key.D)
+ curve := elliptic.P256()
+ if errX != nil || errY != nil || errD != nil || len(x) != 32 || len(y) != 32 || len(d) != 32 {
+ return false
+ }
+ return curve.IsOnCurve(new(big.Int).SetBytes(x), new(big.Int).SetBytes(y))
+}
+
+func b64(n *big.Int) string {
+ raw := make([]byte, 32)
+ n.FillBytes(raw)
+ return base64.RawURLEncoding.EncodeToString(raw)
+}
+func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+package send
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestReceiveKeyPersistsPrivatelyAndPublishesOnlyPublicFields(t *testing.T) {
+ dir := t.TempDir()
+ key, err := LoadOrCreate(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if key.Kty != "EC" || key.Crv != "P-256" || key.D == "" {
+ t.Fatalf("bad private key: %#v", key)
+ }
+ info, err := os.Stat(filepath.Join(dir, "link-send-key.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
+ t.Fatalf("key mode = %o, want 600", info.Mode().Perm())
+ }
+ public := Public(key)
+ if public.D != "" || public.X != key.X || public.Y != key.Y {
+ t.Fatalf("bad public projection: %#v", public)
+ }
+ loaded, err := LoadOrCreate(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded != key {
+ t.Fatal("receive key changed across restart")
+ }
+}
+
+func TestReceiveKeyRejectsMalformedSavedKey(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "link-send-key.json"), []byte(`{"kty":"RSA"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadOrCreate(dir); err == nil {
+ t.Fatal("accepted a malformed saved key")
+ }
+}
+
+func TestReceiveKeyRejectsBroadPermissions(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows file modes do not expose POSIX group and other permissions")
+ }
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "link-send-key.json"), []byte(`{"kty":"EC","crv":"P-256","x":"x","y":"y","d":"d"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadOrCreate(dir); err == nil {
+ t.Fatal("accepted a receive key file readable by other accounts")
+ }
+}