Compare commits
5 Commits
0e8ddbf4f4
...
d233f4d79e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d233f4d79e | ||
|
|
95fd29ae8d | ||
|
|
340735f992 | ||
|
|
1308082c7b | ||
|
|
31e13fd509 |
173
EXTENSIONS.md
Normal file
173
EXTENSIONS.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# waste-go Protocol Extensions
|
||||||
|
|
||||||
|
These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go.
|
||||||
|
They do **not** break compatibility — YAW/2-only peers silently ignore all new
|
||||||
|
fields. Where a waste-go peer connects to a YAW/2-only peer, the extension
|
||||||
|
simply has no effect on that peer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-001 — Signed Invites
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** `waste:` invite format, `hello` DataChannel message
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
The base YAW/2 network model is open to anyone who knows the anchor URL and
|
||||||
|
network name (or hash). This extension adds opt-in cryptographic membership
|
||||||
|
gating: invites are signed by an existing peer, and peers that enforce
|
||||||
|
`RequireInvite` reject hellos that carry no valid signed invite.
|
||||||
|
|
||||||
|
### Invite format changes
|
||||||
|
|
||||||
|
The `waste:` invite payload (base64-encoded JSON) gains two optional fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"anchor": "wss://...",
|
||||||
|
"network": "friends",
|
||||||
|
"net": "<64-hex SHA-256(yaw2-net:name)>",
|
||||||
|
"inviter": "<64-hex Ed25519 pubkey of signing peer>",
|
||||||
|
"sig": "<hex Ed25519 signature>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The signature covers the following bytes (null-separated):
|
||||||
|
|
||||||
|
```
|
||||||
|
anchor \x00 network \x00 net \x00 inviter
|
||||||
|
```
|
||||||
|
|
||||||
|
Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat.
|
||||||
|
|
||||||
|
### Hello message extension
|
||||||
|
|
||||||
|
The YAW/2 §6 hello message gains one optional field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"id": "<hex pubkey>",
|
||||||
|
"nick": "alice",
|
||||||
|
"caps": ["chat", "file"],
|
||||||
|
"sig": "<DTLS binding sig>",
|
||||||
|
"invite": "waste:eyJ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`invite` carries the full `waste:` string the connecting peer used to join.
|
||||||
|
YAW/2-only peers ignore this field.
|
||||||
|
|
||||||
|
### Enforcement
|
||||||
|
|
||||||
|
Per-network flag `RequireInvite` (set via `join_network` IPC command).
|
||||||
|
When enabled:
|
||||||
|
|
||||||
|
1. A peer that presents no `invite` in hello is disconnected immediately.
|
||||||
|
2. A peer that presents an invite with no signature is disconnected.
|
||||||
|
3. A peer whose invite signature is invalid is disconnected.
|
||||||
|
4. A peer whose invite was signed by an unknown peer ID (not in the store or
|
||||||
|
currently connected) is disconnected.
|
||||||
|
|
||||||
|
The inviter's key must be a **known peer** — i.e. previously connected and
|
||||||
|
stored in the per-network SQLite store, or currently connected. This forms a
|
||||||
|
chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can
|
||||||
|
invite Carol, whose invite Alice will also accept.
|
||||||
|
|
||||||
|
**Default:** off. Networks opt in. Existing networks with no RequireInvite
|
||||||
|
behave exactly as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-002 — Hash-based Hang Link
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** web UI URL handling only, no wire changes
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
A shareable URL that pre-fills the join form without conveying cryptographic
|
||||||
|
membership. Suitable for public announcements ("come hang out here"). The
|
||||||
|
fragment is never sent to the server, keeping the network name opaque to
|
||||||
|
server logs and HTTP intermediaries.
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
```
|
||||||
|
https://host/#waste:eyJ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The fragment payload is the standard `waste:` base64 JSON with only `network`
|
||||||
|
and `anchor` fields — no `inviter`, no `sig`. This does **not** grant
|
||||||
|
membership on networks with `RequireInvite` enabled; it only pre-fills the
|
||||||
|
join form.
|
||||||
|
|
||||||
|
The web UI generates hang links via the 🔗 button in the Networks sidebar
|
||||||
|
section. Arriving users see the join form pre-populated and still need a
|
||||||
|
proper signed invite (if the network enforces it) to be accepted by peers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-003 — Multi-Share Configuration
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** IPC protocol only, no peer-to-peer wire changes
|
||||||
|
|
||||||
|
### New IPC commands
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{"type":"add_share","path":"/home/alice/Music"} // global
|
||||||
|
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // scoped
|
||||||
|
{"type":"remove_share","path":"/home/alice/Music"}
|
||||||
|
{"type":"list_shares"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New IPC event
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
`shares.json` in the data directory (next to `identity.json`). Each entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "path": "/absolute/path", "networks": ["*"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`networks: ["*"]` = global (all networks). Specific network IDs = scoped.
|
||||||
|
Coexists with the legacy `set_share_dir` single-dir mechanism.
|
||||||
|
|
||||||
|
File listings returned by `get_file_list` and `MsgFileListReq` include
|
||||||
|
entries from all applicable share roots, with relative `path` fields
|
||||||
|
(e.g. `"path": "docs/report.pdf"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-004 — TURN Relay (browser mode)
|
||||||
|
|
||||||
|
**Status:** implemented (browser mode); pending (daemon mode)
|
||||||
|
**Affects:** ICE server configuration only, no wire changes
|
||||||
|
|
||||||
|
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`
|
||||||
|
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are
|
||||||
|
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme).
|
||||||
|
|
||||||
|
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
|
||||||
|
opt-in via server configuration and does not affect peers that omit it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-005 — Per-Network Path in FileEntry
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** `MsgFileListResp` wire message (additive field)
|
||||||
|
|
||||||
|
`FileEntry` gains an optional `path` field carrying the file's relative path
|
||||||
|
within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand
|
||||||
|
this field continue to use `name` for display and download requests.
|
||||||
|
|
||||||
|
`MsgFileListReq` / `get` requests use `path` as the lookup key when present,
|
||||||
|
falling back to `name` for backward compat with peers that don't send `path`.
|
||||||
29
FUTURE.md
29
FUTURE.md
@@ -38,12 +38,12 @@ React + Vite frontend. Two modes:
|
|||||||
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
||||||
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
|
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
|
||||||
|
|
||||||
### TURN relay ✅ (shipped, browser mode)
|
### TURN relay ✅ (shipped)
|
||||||
Browser mode now supports TURN relay. `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. Mobile/CGNAT peers that fail STUN hole-punching automatically fall back to TURN relay.
|
Both browser and daemon modes support TURN relay.
|
||||||
|
|
||||||
The peer dot in the sidebar turns yellow for relayed connections (`candidate_type: relay`).
|
**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. The peer dot turns yellow for relayed connections (`candidate_type: relay`).
|
||||||
|
|
||||||
**Daemon mode:** not yet wired. Add `-turn-url` / `-turn-secret` flags to `cmd/daemon/main.go` and pass them into the `ICEServers` slice in `internal/anchor/client.go`.
|
**Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection.
|
||||||
|
|
||||||
### Signaling ✅ YAW/2.1 (shipped)
|
### Signaling ✅ YAW/2.1 (shipped)
|
||||||
Forward-secret signaling via per-session ephemeral X25519 keys. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
|
Forward-secret signaling via per-session ephemeral X25519 keys. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
|
||||||
@@ -76,10 +76,14 @@ Multiple share roots per network, with global (all networks) or scoped visibilit
|
|||||||
- **Daemon:** `shares.json` next to `identity.json` in the data dir. `add_share`/`remove_share`/`list_shares` IPC commands. File listing recursively walks all share roots, returning relative paths. Backward compatible with the existing `set_share_dir` single-dir mechanism.
|
- **Daemon:** `shares.json` next to `identity.json` in the data dir. `add_share`/`remove_share`/`list_shares` IPC commands. File listing recursively walks all share roots, returning relative paths. Backward compatible with the existing `set_share_dir` single-dir mechanism.
|
||||||
- **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
|
- **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
|
||||||
|
|
||||||
### Additional Channels / Rooms ✅ (shipped, web UI)
|
### Additional Channels / Rooms ✅ (shipped)
|
||||||
The `+` button in the Rooms sidebar section creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically. DM rooms (`dm:<peerId>`) appear automatically when messages arrive.
|
Custom rooms are supported in both the web UI and the TUI.
|
||||||
|
|
||||||
**Not yet done:** TUI room creation, daemon-side SQLite persistence of room lists across restarts.
|
**Web UI:** The `+` button in the Rooms sidebar creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically.
|
||||||
|
|
||||||
|
**TUI:** Type `/room <name>` in the input to create a room. The daemon persists it in the `rooms` SQLite table and echoes a `room_created` IPC event back. On reconnect, rooms are restored via `state_snapshot`. Rooms that receive messages while not active show a `*` prefix in the sidebar; the marker clears when you switch to that room.
|
||||||
|
|
||||||
|
DM rooms (`dm:<peerId>`) appear automatically in both interfaces when messages arrive.
|
||||||
|
|
||||||
### File Transfer UX ✅ (shipped)
|
### File Transfer UX ✅ (shipped)
|
||||||
- Manual accept/reject via the Transfers panel in the sidebar
|
- Manual accept/reject via the Transfers panel in the sidebar
|
||||||
@@ -89,9 +93,6 @@ The `+` button in the Rooms sidebar section creates custom rooms, stored in `cus
|
|||||||
|
|
||||||
**Not yet done:** resume after disconnection, daemon-side download directory.
|
**Not yet done:** resume after disconnection, daemon-side download directory.
|
||||||
|
|
||||||
### TURN Relay for Daemon Mode
|
|
||||||
The daemon doesn't yet support TURN. Add `-turn-url` and `-turn-secret` flags to `cmd/daemon/main.go` and wire them into the ICE server list in `internal/anchor/client.go`. The credential generation is the same HMAC-SHA1 scheme already implemented in browser mode.
|
|
||||||
|
|
||||||
### Native UI
|
### Native UI
|
||||||
Web frontend (React, already built) + Tauri shell for native packaging. The IPC protocol is the full boundary — the UI is already a pure consumer. Main work: Tauri setup, system tray, OS notifications.
|
Web frontend (React, already built) + Tauri shell for native packaging. The IPC protocol is the full boundary — the UI is already a pure consumer. Main work: Tauri setup, system tray, OS notifications.
|
||||||
|
|
||||||
@@ -121,8 +122,12 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC
|
|||||||
| ✅ shipped | Session persistence + logout (browser mode) |
|
| ✅ shipped | Session persistence + logout (browser mode) |
|
||||||
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
||||||
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
||||||
| next | TURN relay for daemon mode |
|
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
|
||||||
| next | TUI room creation + daemon-side room persistence |
|
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
|
||||||
|
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
|
||||||
|
| ✅ shipped | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) |
|
||||||
|
| ✅ shipped | TUI room creation + daemon-side room persistence |
|
||||||
|
| ✅ shipped | Unread room indicators in TUI (`*` prefix) |
|
||||||
| next | File transfer resume after disconnection |
|
| next | File transfer resume after disconnection |
|
||||||
| future | Native UI (React + Tauri) |
|
| future | Native UI (React + Tauri) |
|
||||||
|
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -169,6 +169,8 @@ The `use-auth-secret` mode generates short-lived TURN credentials from the share
|
|||||||
|
|
||||||
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
|
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
|
||||||
|
|
||||||
|
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works: daemon vs browser mode
|
## How it works: daemon vs browser mode
|
||||||
@@ -284,6 +286,24 @@ The invite encodes the anchor URL and network name. Sharing it only lets the rec
|
|||||||
|
|
||||||
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
||||||
|
|
||||||
|
### Signed invites and invite-only networks (waste-go extension)
|
||||||
|
|
||||||
|
Invites generated by `generate_invite` are **cryptographically signed** by the generating peer. The `waste:` payload carries an `inviter` field (Ed25519 public key) and a `sig` field (signature over anchor + network + inviter). When Bob joins, the invite is forwarded in the `hello` message so Alice can verify it.
|
||||||
|
|
||||||
|
To enable invite-only enforcement on a network, pass `require_invite: true` in the `join_network` command. Peers presenting no invite, an unsigned invite, or an invite signed by an unknown peer are rejected.
|
||||||
|
|
||||||
|
### "Come hang" hang links
|
||||||
|
|
||||||
|
The 🔗 button in the web UI copies a **hash-based hang link**:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-domain.com/#waste:eyJ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks.
|
||||||
|
|
||||||
|
See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Terminal UI
|
## Terminal UI
|
||||||
@@ -300,6 +320,8 @@ go run ./cmd/tui -network friends
|
|||||||
|
|
||||||
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
||||||
|
|
||||||
|
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## IPC protocol
|
## IPC protocol
|
||||||
@@ -320,6 +342,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
|||||||
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
|
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
|
||||||
{"type":"remove_share","path":"/home/alice/Music"}
|
{"type":"remove_share","path":"/home/alice/Music"}
|
||||||
{"type":"list_shares"}
|
{"type":"list_shares"}
|
||||||
|
{"type":"create_room","room":"dev"}
|
||||||
{"type":"export_identity","passphrase":"..."}
|
{"type":"export_identity","passphrase":"..."}
|
||||||
{"type":"import_identity","passphrase":"...","backup":"..."}
|
{"type":"import_identity","passphrase":"...","backup":"..."}
|
||||||
```
|
```
|
||||||
@@ -335,6 +358,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
|||||||
{"type":"invite_generated","invite":"waste:<base64>"}
|
{"type":"invite_generated","invite":"waste:<base64>"}
|
||||||
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
|
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
|
||||||
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
|
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
|
||||||
|
{"type":"room_created","network_id":"...","room":"dev"}
|
||||||
{"type":"identity_exported","backup":"..."}
|
{"type":"identity_exported","backup":"..."}
|
||||||
{"type":"error","error_message":"..."}
|
{"type":"error","error_message":"..."}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ func main() {
|
|||||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||||
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
||||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||||
|
turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478")
|
||||||
|
turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential")
|
||||||
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
||||||
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -74,6 +76,8 @@ func main() {
|
|||||||
StoreDir: dir,
|
StoreDir: dir,
|
||||||
AnchorURL: *anchorURL,
|
AnchorURL: *anchorURL,
|
||||||
ShareDir: expandHome(*shareDir),
|
ShareDir: expandHome(*shareDir),
|
||||||
|
TurnURL: *turnURL,
|
||||||
|
TurnSecret: *turnSecret,
|
||||||
})
|
})
|
||||||
|
|
||||||
if autoJoinNetwork != "" {
|
if autoJoinNetwork != "" {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ type model struct {
|
|||||||
rooms []string // "general" always first; DM rooms appended
|
rooms []string // "general" always first; DM rooms appended
|
||||||
activeRoom int
|
activeRoom int
|
||||||
messages map[string][]entry
|
messages map[string][]entry
|
||||||
|
unread map[string]bool // rooms with messages since last viewed
|
||||||
|
|
||||||
peers map[proto.PeerID]string // connected peers: id → alias
|
peers map[proto.PeerID]string // connected peers: id → alias
|
||||||
peerOrder []proto.PeerID
|
peerOrder []proto.PeerID
|
||||||
@@ -131,6 +132,7 @@ func newModel(ipcPort int, network string) model {
|
|||||||
networkName: network,
|
networkName: network,
|
||||||
rooms: []string{"general"},
|
rooms: []string{"general"},
|
||||||
messages: make(map[string][]entry),
|
messages: make(map[string][]entry),
|
||||||
|
unread: make(map[string]bool),
|
||||||
peers: make(map[proto.PeerID]string),
|
peers: make(map[proto.PeerID]string),
|
||||||
input: ti,
|
input: ti,
|
||||||
status: "connecting…",
|
status: "connecting…",
|
||||||
@@ -215,9 +217,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m, cmds = m.doSend(cmds)
|
m, cmds = m.doSend(cmds)
|
||||||
case msg.Type == tea.KeyTab:
|
case msg.Type == tea.KeyTab:
|
||||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
||||||
|
delete(m.unread, m.activeRoomName())
|
||||||
m = m.refreshViewport()
|
m = m.refreshViewport()
|
||||||
case msg.Type == tea.KeyShiftTab:
|
case msg.Type == tea.KeyShiftTab:
|
||||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
||||||
|
delete(m.unread, m.activeRoomName())
|
||||||
m = m.refreshViewport()
|
m = m.refreshViewport()
|
||||||
default:
|
default:
|
||||||
var tiCmd tea.Cmd
|
var tiCmd tea.Cmd
|
||||||
@@ -251,8 +255,15 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
m.peers[p.ID] = p.Alias
|
m.peers[p.ID] = p.Alias
|
||||||
m.peerOrder = append(m.peerOrder, p.ID)
|
m.peerOrder = append(m.peerOrder, p.ID)
|
||||||
}
|
}
|
||||||
|
for _, r := range evt.Rooms {
|
||||||
|
m = m.addRoom(r)
|
||||||
|
}
|
||||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||||
|
|
||||||
|
case proto.EvtRoomCreated:
|
||||||
|
m = m.addRoom(evt.Room)
|
||||||
|
m = m.refreshViewport()
|
||||||
|
|
||||||
case proto.EvtSessionReady:
|
case proto.EvtSessionReady:
|
||||||
if evt.PeerID != nil {
|
if evt.PeerID != nil {
|
||||||
pid := *evt.PeerID
|
pid := *evt.PeerID
|
||||||
@@ -283,7 +294,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case proto.EvtInviteGenerated:
|
case proto.EvtInviteGenerated:
|
||||||
m.invitePopup = evt.InviteString
|
m.invitePopup = evt.InviteGenerated
|
||||||
|
|
||||||
case proto.EvtMessageReceived:
|
case proto.EvtMessageReceived:
|
||||||
if evt.Message != nil {
|
if evt.Message != nil {
|
||||||
@@ -296,6 +307,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
}
|
}
|
||||||
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
||||||
m = m.addRoom(msg.Room)
|
m = m.addRoom(msg.Room)
|
||||||
|
if msg.Room != m.activeRoomName() {
|
||||||
|
m.unread[msg.Room] = true
|
||||||
|
}
|
||||||
m = m.refreshViewport()
|
m = m.refreshViewport()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +323,14 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.input.SetValue("")
|
m.input.SetValue("")
|
||||||
|
|
||||||
|
if strings.HasPrefix(body, "/room ") {
|
||||||
|
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
|
||||||
|
if name != "" {
|
||||||
|
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
|
||||||
|
}
|
||||||
|
return m, cmds
|
||||||
|
}
|
||||||
|
|
||||||
room := m.rooms[m.activeRoom]
|
room := m.rooms[m.activeRoom]
|
||||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||||
if strings.HasPrefix(room, "dm:") {
|
if strings.HasPrefix(room, "dm:") {
|
||||||
@@ -430,7 +452,7 @@ func (m model) View() string {
|
|||||||
if m.errMsg != "" {
|
if m.errMsg != "" {
|
||||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||||
} else {
|
} else {
|
||||||
hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit"
|
hint := " tab: rooms · /room <name>: new room · ctrl+i: invite · ctrl+c: quit"
|
||||||
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +490,11 @@ func (m model) renderRooms(boxH int) string {
|
|||||||
if i == m.activeRoom {
|
if i == m.activeRoom {
|
||||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, styleRoom.Width(innerW).Render(" "+label))
|
prefix := " "
|
||||||
|
if m.unread[room] {
|
||||||
|
prefix = "* "
|
||||||
|
}
|
||||||
|
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for len(lines) < contentH {
|
for len(lines) < contentH {
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
// Answerer: we haven't created a session yet, do it now.
|
// Answerer: we haven't created a session yet, do it now.
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
log.Printf("anchor: new PC for answerer: %v", err)
|
log.Printf("anchor: new PC for answerer: %v", err)
|
||||||
@@ -412,7 +412,7 @@ func dispatchSignaling(
|
|||||||
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
||||||
// the peer's ekey, then sends the offer (ephemeral or static).
|
// the peer's ekey, then sends the offer (ephemeral or static).
|
||||||
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -473,7 +473,7 @@ func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m
|
|||||||
|
|
||||||
// answerOffer processes an incoming offer and returns the PeerConnection.
|
// answerOffer processes an incoming offer and returns the PeerConnection.
|
||||||
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
|
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -618,10 +618,9 @@ func hashNetName(name string) string {
|
|||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPC() (*webrtc.PeerConnection, error) {
|
func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
|
||||||
return webrtc.NewPeerConnection(webrtc.Configuration{
|
servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
|
||||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool { return &b }
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ func (id *Identity) PeerInfo() proto.PeerInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerIDHex satisfies the invite.Signer interface.
|
||||||
|
func (id *Identity) PeerIDHex() string { return string(id.PeerID()) }
|
||||||
|
|
||||||
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
||||||
func (id *Identity) Sign(data []byte) string {
|
func (id *Identity) Sign(data []byte) string {
|
||||||
sig := ed25519.Sign(id.privateKey, data)
|
sig := ed25519.Sign(id.privateKey, data)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
||||||
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
||||||
// the same network without knowing the plaintext name.
|
// the same network without knowing the plaintext name.
|
||||||
|
//
|
||||||
|
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
|
||||||
|
// the invite was issued by a known peer. Receiving peers that enforce
|
||||||
|
// RequireInvite will reject hellos that carry no valid signed invite.
|
||||||
|
// YAW/2-only peers ignore both fields.
|
||||||
package invite
|
package invite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,29 +26,69 @@ const prefix = "waste:"
|
|||||||
|
|
||||||
// Invite holds the information needed to join a network.
|
// Invite holds the information needed to join a network.
|
||||||
type Invite struct {
|
type Invite struct {
|
||||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||||
Network string `json:"network"` // plaintext network name
|
Network string `json:"network"` // plaintext network name
|
||||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
||||||
|
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
|
||||||
|
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode returns a waste: invite string for the given anchor URL and network name.
|
// IsSigned reports whether the invite carries a signature.
|
||||||
|
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
|
||||||
|
|
||||||
|
// Signer can sign data and report its own peer ID.
|
||||||
|
type Signer interface {
|
||||||
|
Sign(data []byte) string
|
||||||
|
PeerIDHex() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier verifies an Ed25519 signature given a hex public key.
|
||||||
|
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
|
||||||
|
|
||||||
|
// Encode returns an unsigned waste: invite string (backward compatible).
|
||||||
func Encode(anchor, network string) (string, error) {
|
func Encode(anchor, network string) (string, error) {
|
||||||
|
return marshal(Invite{
|
||||||
|
Anchor: anchor,
|
||||||
|
Network: network,
|
||||||
|
Net: NetHash(network),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeSigned returns a signed waste: invite string.
|
||||||
|
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
|
||||||
|
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
|
||||||
if anchor == "" {
|
if anchor == "" {
|
||||||
return "", fmt.Errorf("anchor URL is required")
|
return "", fmt.Errorf("anchor URL is required")
|
||||||
}
|
}
|
||||||
if network == "" {
|
if network == "" {
|
||||||
return "", fmt.Errorf("network name is required")
|
return "", fmt.Errorf("network name is required")
|
||||||
}
|
}
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + network))
|
inviter := signer.PeerIDHex()
|
||||||
b, err := json.Marshal(Invite{
|
net := NetHash(network)
|
||||||
|
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
|
||||||
|
return marshal(Invite{
|
||||||
Anchor: anchor,
|
Anchor: anchor,
|
||||||
Network: network,
|
Network: network,
|
||||||
Net: hex.EncodeToString(h[:]),
|
Net: net,
|
||||||
|
Inviter: inviter,
|
||||||
|
Sig: sig,
|
||||||
})
|
})
|
||||||
if err != nil {
|
}
|
||||||
return "", err
|
|
||||||
|
// Verify checks the invite signature and that the inviter is in the trusted set.
|
||||||
|
// Unsigned invites return nil — the caller decides whether to accept them.
|
||||||
|
func Verify(inv Invite, trusted map[string]bool, verify Verifier) error {
|
||||||
|
if !inv.IsSigned() {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter)
|
||||||
|
if err := verify(inv.Inviter, payload, inv.Sig); err != nil {
|
||||||
|
return fmt.Errorf("invite signature invalid: %w", err)
|
||||||
|
}
|
||||||
|
if !trusted[inv.Inviter] {
|
||||||
|
return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode parses a waste: invite string and returns the Invite.
|
// Decode parses a waste: invite string and returns the Invite.
|
||||||
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
|
|||||||
return inv, nil
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetHash returns the full 64-char hex network hash for the given name
|
// NetHash returns the full 64-char hex network hash for the given name.
|
||||||
// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor.
|
|
||||||
func NetHash(name string) string {
|
func NetHash(name string) string {
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshal(inv Invite) (string, error) {
|
||||||
|
b, err := json.Marshal(inv)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sigPayload(anchor, network, net, inviter string) []byte {
|
||||||
|
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
@@ -146,7 +147,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
case cmd.NetworkName != "":
|
case cmd.NetworkName != "":
|
||||||
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
||||||
case len(cmd.NetworkHash) == 64:
|
case len(cmd.NetworkHash) == 64:
|
||||||
// yaw2-compatible: join by full 64-char hex hash (net field)
|
|
||||||
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
||||||
default:
|
default:
|
||||||
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
||||||
@@ -156,7 +156,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash.
|
if n, ok := mgr.Get(netID); ok {
|
||||||
|
if cmd.RequireInvite {
|
||||||
|
n.Mesh.RequireInvite = true
|
||||||
|
}
|
||||||
|
if cmd.InviteString != "" {
|
||||||
|
n.Mesh.InviteString = cmd.InviteString
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = netID
|
_ = netID
|
||||||
|
|
||||||
case proto.CmdLeaveNetwork:
|
case proto.CmdLeaveNetwork:
|
||||||
@@ -233,6 +240,23 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case proto.CmdCreateRoom:
|
||||||
|
n := mgr.Resolve(cmd.NetworkID)
|
||||||
|
if n == nil {
|
||||||
|
send(errMsg("create_room: not joined to any network"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(cmd.Room)
|
||||||
|
if name == "" || name == "general" {
|
||||||
|
send(errMsg("create_room: room name is required and cannot be 'general'"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := n.Store.SaveRoom(name); err != nil {
|
||||||
|
send(errMsg(fmt.Sprintf("create_room: %v", err)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
send(proto.IpcMessage{Type: proto.EvtRoomCreated, NetworkID: n.ID, Room: name})
|
||||||
|
|
||||||
case proto.CmdGetState:
|
case proto.CmdGetState:
|
||||||
send(stateSnapshot(mgr))
|
send(stateSnapshot(mgr))
|
||||||
|
|
||||||
@@ -298,15 +322,15 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
inv, err := invite.Encode(mgr.AnchorURL(), n.Name)
|
inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
send(proto.IpcMessage{
|
send(proto.IpcMessage{
|
||||||
Type: proto.EvtInviteGenerated,
|
Type: proto.EvtInviteGenerated,
|
||||||
NetworkID: n.ID,
|
NetworkID: n.ID,
|
||||||
InviteString: inv,
|
InviteGenerated: inv,
|
||||||
})
|
})
|
||||||
|
|
||||||
case proto.CmdSetShareDir:
|
case proto.CmdSetShareDir:
|
||||||
@@ -410,6 +434,11 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
|||||||
pi := all[0].Identity.PeerInfo()
|
pi := all[0].Identity.PeerInfo()
|
||||||
msg.LocalPeer = &pi
|
msg.LocalPeer = &pi
|
||||||
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
||||||
|
if extra, err := all[0].Store.Rooms(); err == nil {
|
||||||
|
for _, r := range extra {
|
||||||
|
msg.Rooms = append(msg.Rooms, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
"github.com/waste-go/internal/store"
|
"github.com/waste-go/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ICEServer mirrors webrtc.ICEServer so callers don't import pion directly.
|
||||||
|
type ICEServer = webrtc.ICEServer
|
||||||
|
|
||||||
// PeerConn is a live connection to one peer.
|
// PeerConn is a live connection to one peer.
|
||||||
type PeerConn struct {
|
type PeerConn struct {
|
||||||
Info proto.PeerInfo
|
Info proto.PeerInfo
|
||||||
@@ -27,11 +30,14 @@ type PeerConn struct {
|
|||||||
type Mesh struct {
|
type Mesh struct {
|
||||||
Identity *crypto.Identity
|
Identity *crypto.Identity
|
||||||
Store *store.Store // may be nil if persistence is disabled
|
Store *store.Store // may be nil if persistence is disabled
|
||||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||||
DownloadDir string // directory where received files are saved
|
DownloadDir string // directory where received files are saved
|
||||||
|
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
|
||||||
|
InviteString string // the invite this peer used to join (sent in hello to other peers)
|
||||||
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
||||||
// multi-share scanning without the mesh needing to know about shares.json.
|
// multi-share scanning without the mesh needing to know about shares.json.
|
||||||
ScanFiles func() []proto.FileEntry
|
ScanFiles func() []proto.FileEntry
|
||||||
|
ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
peers map[proto.PeerID]*PeerConn
|
peers map[proto.PeerID]*PeerConn
|
||||||
@@ -63,6 +69,29 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trustedPeerIDs returns a set of peer IDs trusted on this network:
|
||||||
|
// all currently connected peers plus all peers in the persistent store.
|
||||||
|
func (m *Mesh) trustedPeerIDs() map[string]bool {
|
||||||
|
trusted := map[string]bool{}
|
||||||
|
// Own identity is always trusted.
|
||||||
|
trusted[string(m.Identity.PeerID())] = true
|
||||||
|
// Connected peers.
|
||||||
|
m.mu.RLock()
|
||||||
|
for id := range m.peers {
|
||||||
|
trusted[string(id)] = true
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
// Previously seen peers from the store.
|
||||||
|
if m.Store != nil {
|
||||||
|
if known, err := m.Store.KnownPeers(); err == nil {
|
||||||
|
for id := range known {
|
||||||
|
trusted[string(id)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
|
}
|
||||||
|
|
||||||
// ScanShareDir returns the list of files in the local share directory.
|
// ScanShareDir returns the list of files in the local share directory.
|
||||||
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||||
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/pion/webrtc/v3"
|
"github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
|
"github.com/waste-go/internal/invite"
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,11 +44,12 @@ func WireDataChannel(
|
|||||||
localFP, remoteFP := dtlsFingerprints(pc)
|
localFP, remoteFP := dtlsFingerprints(pc)
|
||||||
bindBytes := proto.HelloBindString(localFP, remoteFP)
|
bindBytes := proto.HelloBindString(localFP, remoteFP)
|
||||||
hello := proto.HelloMessage{
|
hello := proto.HelloMessage{
|
||||||
Type: "hello",
|
Type: "hello",
|
||||||
ID: string(id.PeerID()),
|
ID: string(id.PeerID()),
|
||||||
Nick: id.Alias,
|
Nick: id.Alias,
|
||||||
Caps: []string{"chat", "file"},
|
Caps: []string{"chat", "file"},
|
||||||
Sig: id.Sign(bindBytes),
|
Sig: id.Sign(bindBytes),
|
||||||
|
Invite: m.InviteString,
|
||||||
}
|
}
|
||||||
helloJSON, _ := json.Marshal(hello)
|
helloJSON, _ := json.Marshal(hello)
|
||||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
if err := dc.SendText(string(helloJSON)); err != nil {
|
||||||
@@ -149,6 +151,37 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
|||||||
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invite enforcement (waste-go extension).
|
||||||
|
if m.RequireInvite {
|
||||||
|
if hello.Invite == "" {
|
||||||
|
log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short())
|
||||||
|
m.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtError,
|
||||||
|
ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inv, err := invite.Decode(hello.Invite)
|
||||||
|
if err != nil || !inv.IsSigned() {
|
||||||
|
log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err)
|
||||||
|
m.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtError,
|
||||||
|
ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trusted := m.trustedPeerIDs()
|
||||||
|
if err := invite.Verify(inv, trusted, crypto.Verify); err != nil {
|
||||||
|
log.Printf("peer: rejecting %s — %v", from.Short(), err)
|
||||||
|
m.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtError,
|
||||||
|
ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update alias once we have the verified nick.
|
// Update alias once we have the verified nick.
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if conn, ok := m.peers[from]; ok {
|
if conn, ok := m.peers[from]; ok {
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ package netmgr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
"github.com/waste-go/internal/anchor"
|
"github.com/waste-go/internal/anchor"
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
@@ -28,6 +35,8 @@ type Config struct {
|
|||||||
StoreDir string // base directory for per-network SQLite files
|
StoreDir string // base directory for per-network SQLite files
|
||||||
AnchorURL string // WebSocket anchor URL used for all networks
|
AnchorURL string // WebSocket anchor URL used for all networks
|
||||||
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
||||||
|
TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478"
|
||||||
|
TurnSecret string // shared secret for coturn use-auth-secret HMAC credential
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network is a single joined network context.
|
// Network is a single joined network context.
|
||||||
@@ -113,6 +122,9 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
|||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
|
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
|
||||||
capturedNetID := netID
|
capturedNetID := netID
|
||||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
||||||
|
if ice := mgr.turnICEServers(); ice != nil {
|
||||||
|
m.ICEServers = ice
|
||||||
|
}
|
||||||
|
|
||||||
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
@@ -198,6 +210,9 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
|||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
|
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
|
||||||
capturedNetID2 := netID
|
capturedNetID2 := netID
|
||||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
||||||
|
if ice := mgr.turnICEServers(); ice != nil {
|
||||||
|
m.ICEServers = ice
|
||||||
|
}
|
||||||
|
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -411,6 +426,26 @@ func (mgr *Manager) emit(msg proto.IpcMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// turnICEServers returns TURN ICE servers if TurnURL and TurnSecret are set,
|
||||||
|
// using coturn's use-auth-secret HMAC-SHA1 time-limited credential scheme.
|
||||||
|
// Returns nil if TURN is not configured.
|
||||||
|
func (mgr *Manager) turnICEServers() []webrtc.ICEServer {
|
||||||
|
if mgr.cfg.TurnURL == "" || mgr.cfg.TurnSecret == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Username = Unix timestamp 1 hour from now.
|
||||||
|
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
|
||||||
|
mac := hmac.New(sha1.New, []byte(mgr.cfg.TurnSecret))
|
||||||
|
mac.Write([]byte(expiry))
|
||||||
|
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return []webrtc.ICEServer{{
|
||||||
|
URLs: []string{mgr.cfg.TurnURL},
|
||||||
|
Username: expiry,
|
||||||
|
Credential: credential,
|
||||||
|
CredentialType: webrtc.ICECredentialTypePassword,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func hashNetName(name string) string {
|
func hashNetName(name string) string {
|
||||||
|
|||||||
@@ -139,12 +139,16 @@ type FileOffer struct {
|
|||||||
|
|
||||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||||
// The signature binds this identity to the specific DTLS session.
|
// The signature binds this identity to the specific DTLS session.
|
||||||
|
// The Invite field is a waste-go extension (§ waste-go/extensions.md):
|
||||||
|
// when RequireInvite is enabled on a network, peers that omit or present
|
||||||
|
// an invalid signed invite are disconnected. YAW/2-only peers ignore this field.
|
||||||
type HelloMessage struct {
|
type HelloMessage struct {
|
||||||
Type string `json:"type"` // always "hello"
|
Type string `json:"type"` // always "hello"
|
||||||
ID string `json:"id"` // hex pubkey
|
ID string `json:"id"` // hex pubkey
|
||||||
Nick string `json:"nick"` // alias
|
Nick string `json:"nick"` // alias
|
||||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
||||||
|
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HelloBindString returns the bytes the hello signature covers:
|
// HelloBindString returns the bytes the hello signature covers:
|
||||||
@@ -232,6 +236,7 @@ const (
|
|||||||
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||||
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||||
|
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
@@ -251,6 +256,7 @@ const (
|
|||||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||||
EvtSharesList IpcMsgType = "shares_list"
|
EvtSharesList IpcMsgType = "shares_list"
|
||||||
|
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||||
@@ -276,9 +282,11 @@ type IpcMessage struct {
|
|||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
|
|
||||||
// join_network / leave_network
|
// join_network / leave_network
|
||||||
NetworkName string `json:"network_name,omitempty"`
|
NetworkName string `json:"network_name,omitempty"`
|
||||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
||||||
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
||||||
|
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
|
||||||
|
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
|
||||||
|
|
||||||
// send_file / set_share_dir / file_complete path
|
// send_file / set_share_dir / file_complete path
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
@@ -301,7 +309,7 @@ type IpcMessage struct {
|
|||||||
// multi-network: all joined networks (additive)
|
// multi-network: all joined networks (additive)
|
||||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteString string `json:"invite,omitempty"`
|
InviteGenerated string `json:"invite,omitempty"`
|
||||||
Files []FileEntry `json:"files,omitempty"`
|
Files []FileEntry `json:"files,omitempty"`
|
||||||
Shares []ShareEntry `json:"shares,omitempty"`
|
Shares []ShareEntry `json:"shares,omitempty"`
|
||||||
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ CREATE TABLE IF NOT EXISTS peers (
|
|||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
last_seen DATETIME NOT NULL
|
last_seen DATETIME NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
// Store is a local SQLite-backed message and peer store.
|
// Store is a local SQLite-backed message and peer store.
|
||||||
@@ -119,6 +124,33 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
|||||||
return msgs, rows.Err()
|
return msgs, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveRoom persists a room name. Duplicate names are silently ignored.
|
||||||
|
func (s *Store) SaveRoom(name string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO rooms (name, created_at) VALUES (?, ?)`,
|
||||||
|
name, time.Now().UTC(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rooms returns all persisted room names, ordered by creation time.
|
||||||
|
func (s *Store) Rooms() ([]string, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT name FROM rooms ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// KnownPeers returns all peers seen since this daemon started storing data.
|
// KnownPeers returns all peers seen since this daemon started storing data.
|
||||||
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||||
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export function Sidebar() {
|
|||||||
const displayId = localPeer?.id ?? masterId ?? ''
|
const displayId = localPeer?.id ?? masterId ?? ''
|
||||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
||||||
|
|
||||||
|
function copyHangLink() {
|
||||||
|
const net = networks.find(n => n.network_id === activeNetworkId)
|
||||||
|
if (!net) return
|
||||||
|
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
|
||||||
|
const anchor = cfg?.signalURL ?? ''
|
||||||
|
const payload = btoa(JSON.stringify({ network: net.network_name, anchor }))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||||
|
const url = `${window.location.origin}/#waste:${payload}`
|
||||||
|
navigator.clipboard?.writeText(url)
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
||||||
logout(clearId)
|
logout(clearId)
|
||||||
@@ -82,7 +93,12 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<span className="sidebar-label">Networks</span>
|
<div className="sidebar-label-row">
|
||||||
|
<span className="sidebar-label">Networks</span>
|
||||||
|
{activeNetworkId && (
|
||||||
|
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{networks.map(n => (
|
{networks.map(n => (
|
||||||
<button
|
<button
|
||||||
key={n.network_id}
|
key={n.network_id}
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ interface Props {
|
|||||||
// ?a=<url> anchor URL hint
|
// ?a=<url> anchor URL hint
|
||||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||||
const p = new URLSearchParams(window.location.search)
|
const p = new URLSearchParams(window.location.search)
|
||||||
const inviteString = p.get('invite') ?? ''
|
let inviteString = p.get('invite') ?? ''
|
||||||
let network = p.get('n') ?? p.get('network') ?? ''
|
let network = p.get('n') ?? p.get('network') ?? ''
|
||||||
let netHash = p.get('net') ?? ''
|
let netHash = p.get('net') ?? ''
|
||||||
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
||||||
|
|
||||||
|
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
|
||||||
|
const hash = window.location.hash.slice(1) // strip leading #
|
||||||
|
if (!inviteString && hash.startsWith('waste:')) {
|
||||||
|
inviteString = hash
|
||||||
|
}
|
||||||
|
|
||||||
if (inviteString.startsWith('waste:')) {
|
if (inviteString.startsWith('waste:')) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||||
|
|||||||
Reference in New Issue
Block a user