router.mx raw
1 package main
2
3 import (
4 "smesh.lol/web/common/helpers"
5 "smesh.lol/web/common/jsbridge/sw"
6 "smesh.lol/web/common/nostr"
7 )
8
9 // Subscription router: central dispatcher.
10
11 var (
12 clientSubs map[string]*clientSub
13 proxySubs map[string]*proxySub
14 )
15
16 type clientSub struct {
17 filter *nostr.Filter
18 filterRaw string
19 clientID string
20 }
21
22 type proxySub struct {
23 remoteIDs map[string]bool
24 relayCount int
25 eoseCount int
26 timer sw.Timer
27 done bool
28 }
29
30 func initRouter() {
31 clientSubs = map[string]*clientSub{}
32 proxySubs = map[string]*proxySub{}
33 }
34
35 func routeMessage(clientID string, w *mw, msgType string) {
36 switch msgType {
37 case "REQ":
38 subID := w.str()
39 filterRaw := w.raw()
40 routerReq(clientID, subID, filterRaw)
41
42 case "CLOSE":
43 subID := w.str()
44 routerClose(subID)
45
46 case "EVENT":
47 eventRaw := w.raw()
48 routerPublish(clientID, eventRaw)
49
50 case "PROXY":
51 subID := w.str()
52 filterRaw := w.raw()
53 relayURLs := w.strs()
54 routerProxy(clientID, subID, filterRaw, relayURLs)
55
56 case "RELAY_INFO":
57 relayURL := w.str()
58 handleRelayInfo(clientID, relayURL)
59
60 case "SET_KEY":
61 hexKey := w.str()
62 identitySetKey(hexKey)
63 sendToClient(clientID, "[\"KEY_SET\"]")
64
65 case "SET_PUBKEY":
66 identitySetPubkey(w.str())
67
68 case "CLEAR_KEY":
69 identityClearKey()
70 writeRelays = nil
71
72 case "SET_WRITE_RELAYS":
73 all := w.strs()
74 writeRelays = nil
75 for _, u := range all {
76 if isAllowedRelay(u) {
77 writeRelays = append(writeRelays, u)
78 }
79 }
80
81 case "SIGN":
82 requestID := w.str()
83 eventRaw := w.raw()
84 routerSign(clientID, requestID, eventRaw)
85
86 case "BROADCAST":
87 pubkey := w.str()
88 relayURLs := w.strs()
89 routerBroadcast(clientID, pubkey, relayURLs)
90
91 case "SEND_DM":
92 recipientPubkey := w.str()
93 content := w.str()
94 relayURLs := w.strs()
95 routerSendDM(clientID, recipientPubkey, content, relayURLs)
96
97 case "DM_SUB":
98 relayURLs := w.strs()
99 routerDMSub(clientID, relayURLs)
100
101 case "DM_LIST":
102 routerDMList(clientID)
103
104 case "DM_HISTORY":
105 peer := w.str()
106 limit := int(w.num())
107 if limit <= 0 || limit > 500 {
108 limit = 50
109 }
110 until := w.num()
111 routerDMHistory(clientID, peer, limit, until)
112
113 case "CRYPTO_RESULT":
114 id := int(w.num())
115 result := w.str()
116 errMsg := w.str()
117 if fn, ok := cryptoCBs[id]; ok {
118 delete(cryptoCBs, id)
119 fn(result, errMsg)
120 }
121 }
122 }
123
124 // --- REQ / CLOSE / EVENT ---
125
126 func routerReq(clientID, subID, filterRaw string) {
127 f := nostr.ParseFilter(filterRaw)
128 if f == nil {
129 return
130 }
131 clientSubs[subID] = &clientSub{filter: f, filterRaw: filterRaw, clientID: clientID}
132
133 cacheQuery(filterRaw, func(eventsJSON string) {
134 events := nostr.ParseEventsJSON(eventsJSON)
135 for _, ev := range events {
136 sendToClient(clientID, "[\"EVENT\","+jstr(subID)+","+ev.ToJSON()+"]")
137 }
138 sendToClient(clientID, "[\"EOSE\","+jstr(subID)+"]")
139 })
140 }
141
142 func routerClose(subID string) {
143 delete(clientSubs, subID)
144 routerCleanupProxy(subID)
145 }
146
147 func routerPublish(clientID, eventRaw string) {
148 ev := nostr.ParseEvent(eventRaw)
149 if ev == nil {
150 return
151 }
152 cacheStore(eventRaw, func(saved bool) {
153 if saved {
154 pushToMatchingSubs(ev)
155 }
156 })
157 relayPublish(ev)
158 sendToClient(clientID, "[\"OK\","+jstr(ev.ID)+",true,\"\"]")
159 }
160
161 // --- PROXY subscriptions ---
162
163 func isAllowedRelay(url string) bool {
164 if len(url) >= 6 && url[:6] == "wss://" {
165 return true
166 }
167 if len(url) >= 16 && url[:16] == "ws://localhost:" {
168 return true
169 }
170 if len(url) >= 15 && url[:15] == "ws://127.0.0.1:" {
171 return true
172 }
173 return false
174 }
175
176 func isHex(s string) bool {
177 for i := 0; i < len(s); i++ {
178 c := s[i]
179 if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
180 return false
181 }
182 }
183 return true
184 }
185
186 func routerProxy(clientID, subID, filterRaw string, relayURLs []string) {
187 routerCleanupProxy(subID)
188
189 f := nostr.ParseFilter(filterRaw)
190 if f == nil {
191 return
192 }
193 clientSubs[subID] = &clientSub{filter: f, filterRaw: filterRaw, clientID: clientID}
194
195 remoteIDs := map[string]bool{}
196 base := "p_" + subID + "_"
197
198 proxySubs[subID] = &proxySub{
199 remoteIDs: remoteIDs,
200 relayCount: len(relayURLs),
201 }
202
203 for _, url := range relayURLs {
204 if !isAllowedRelay(url) {
205 sw.Log("rejected relay URL: " + url)
206 continue
207 }
208 suffix := urlSuffix(url)
209 rSubID := base + suffix
210 remoteIDs[rSubID] = true
211 c := getConn(url)
212 c.Subscribe(rSubID, []*nostr.Filter{f})
213 }
214
215 proxyID := subID
216 proxySubs[subID].timer = sw.SetTimeout(15000, func() {
217 info, ok := proxySubs[proxyID]
218 if ok && !info.done {
219 info.done = true
220 if cs, ok := clientSubs[proxyID]; ok {
221 sendToClient(cs.clientID, "[\"EOSE\","+jstr(proxyID)+"]")
222 }
223 }
224 })
225 }
226
227 func routerCleanupProxy(proxyID string) {
228 info, ok := proxySubs[proxyID]
229 if !ok {
230 return
231 }
232 sw.ClearTimeout(info.timer)
233
234 if !info.done {
235 if cs, ok := clientSubs[proxyID]; ok {
236 sendToClient(cs.clientID, "[\"EOSE\","+jstr(proxyID)+"]")
237 }
238 }
239
240 for rSubID := range info.remoteIDs {
241 for _, url := range rpool.URLs() {
242 c := rpool.Get(url)
243 if c != nil && c.IsOpen() {
244 c.CloseSubscription(rSubID)
245 }
246 }
247 }
248 delete(proxySubs, proxyID)
249 delete(clientSubs, proxyID)
250 }
251
252 // --- Relay event callbacks ---
253
254 func routerOnRelayEvent(relayURL string, remoteSubID string, ev *nostr.Event) {
255 // Verify event ID hash only — signature verification happens in the signer extension.
256 if !ev.CheckID() {
257 return
258 }
259
260 // Reject events with timestamps too far in the future (>1 hour).
261 now := sw.NowSeconds()
262 if ev.CreatedAt > now+3600 {
263 return
264 }
265
266 evJSON := ev.ToJSON()
267 pushToProxySub(remoteSubID, relayURL, ev)
268 // Cache only — never re-publish received events to other relays.
269 // Relays exchange events through their own connections; the client
270 // is not a transport. The user's own publishes already fan out via
271 // routerPublish → relayPublish. Re-publishing here created an echo
272 // loop that leaked profile-sub events back into the broad feed sub.
273 cacheStore(evJSON, func(saved bool) {
274 if saved && (ev.Kind == 4 || ev.Kind == 1059) && myPubkey != "" {
275 routerDecryptDM(ev)
276 }
277 })
278 }
279
280 func routerDecryptDM(ev *nostr.Event) {
281 // Determine peer pubkey.
282 var peer string
283 if ev.PubKey == myPubkey {
284 // We sent this DM — peer is the "p" tag recipient.
285 pTag := ev.Tags.GetFirst("p")
286 if pTag == nil {
287 return
288 }
289 peer = pTag.Value()
290 } else {
291 peer = ev.PubKey
292 }
293 if len(peer) != 64 || !isHex(peer) {
294 return
295 }
296
297 method := "nip04.decrypt"
298 if ev.Kind == 1059 {
299 method = "nip44.decrypt"
300 }
301
302 requestCrypto("", method, peer, ev.Content, func(plaintext, errMsg string) {
303 if errMsg != "" || plaintext == "" {
304 return
305 }
306 protocol := "nip04"
307 if ev.Kind == 1059 {
308 protocol = "nip44"
309 }
310 dm := makeDMRecord(peer, ev.PubKey, plaintext, ev.CreatedAt, protocol, ev.ID)
311 routerSaveDMRecordJSON(dm.ToJSON())
312 })
313 }
314
315 func routerOnRelayEOSE(subID string) {
316 for proxyID, info := range proxySubs {
317 if info.remoteIDs[subID] {
318 info.eoseCount++
319 if info.eoseCount >= info.relayCount && !info.done {
320 info.done = true
321 sw.ClearTimeout(info.timer)
322 if cs, ok := clientSubs[proxyID]; ok {
323 sendToClient(cs.clientID, "[\"EOSE\","+jstr(proxyID)+"]")
324 }
325 }
326 }
327 }
328 }
329
330 // pushToProxySub dispatches a relay event only to the clientSub that owns
331 // the remote subscription. Prevents profile events leaking into the feed.
332 func pushToProxySub(remoteSubID string, relayURL string, ev *nostr.Event) {
333 for proxyID, info := range proxySubs {
334 if info.remoteIDs[remoteSubID] {
335 if cs, ok := clientSubs[proxyID]; ok && cs.filter.Matches(ev) {
336 sendToClient(cs.clientID, "[\"EVENT\","+jstr(proxyID)+","+ev.ToJSON()+"]")
337 sendToClient(cs.clientID, "[\"SEEN_ON\","+jstr(ev.ID)+","+jstr(relayURL)+"]")
338 }
339 return
340 }
341 }
342 }
343
344 func pushToMatchingSubs(ev *nostr.Event) {
345 for subID, cs := range clientSubs {
346 if cs.filter.Matches(ev) {
347 sendToClient(cs.clientID, "[\"EVENT\","+jstr(subID)+","+ev.ToJSON()+"]")
348 }
349 }
350 }
351
352 // --- Signing ---
353
354 func routerSign(clientID, requestID, eventRaw string) {
355 if !hasKey {
356 sendToClient(clientID, "[\"SIGN_ERROR\","+jstr(requestID)+",\"no key\"]")
357 return
358 }
359 ev := nostr.ParseEvent(eventRaw)
360 if ev == nil {
361 sendToClient(clientID, "[\"SIGN_ERROR\","+jstr(requestID)+",\"parse error\"]")
362 return
363 }
364 if identitySignEvent(ev) {
365 sendToClient(clientID, "[\"SIGNED\","+jstr(requestID)+","+ev.ToJSON()+"]")
366 } else {
367 sendToClient(clientID, "[\"SIGN_ERROR\","+jstr(requestID)+",\"sign failed\"]")
368 }
369 }
370
371 // --- DM routing ---
372
373 func routerSaveDMRecordJSON(dmJSON string) {
374 cacheSaveDM(dmJSON, func(result string) {
375 if result != "duplicate" {
376 broadcastToClients("[\"DM_RECEIVED\"," + dmJSON + "]")
377 }
378 })
379 }
380
381 func routerSendDM(clientID, recipientPubkey, content string, relayURLs []string) {
382 if myPubkey == "" || !hasKey {
383 return
384 }
385 requestCrypto(clientID, "nip04.encrypt", recipientPubkey, content, func(ciphertext, errMsg string) {
386 if errMsg != "" {
387 sendToClient(clientID, "[\"DM_SENT\","+jstr(recipientPubkey)+",false,"+jstr(errMsg)+"]")
388 return
389 }
390 ev := &nostr.Event{
391 Kind: 4,
392 Content: ciphertext,
393 CreatedAt: sw.NowSeconds(),
394 Tags: nostr.Tags{nostr.Tag{"p", recipientPubkey}},
395 }
396 if !identitySignEvent(ev) {
397 sendToClient(clientID, "[\"DM_SENT\","+jstr(recipientPubkey)+",false,\"sign failed\"]")
398 return
399 }
400 for _, url := range relayURLs {
401 if isAllowedRelay(url) {
402 getConn(url).Publish(ev)
403 }
404 }
405 sendToClient(clientID, "[\"DM_SENT\","+jstr(recipientPubkey)+",true,\"\"]")
406 })
407 }
408
409 func routerDMSub(_ string, relayURLs []string) {
410 if myPubkey == "" || len(relayURLs) == 0 {
411 return
412 }
413 dmRelayURLs = relayURLs
414
415 for rSubID := range dmSubIDs {
416 for _, url := range rpool.URLs() {
417 c := rpool.Get(url)
418 if c != nil && c.IsOpen() {
419 c.CloseSubscription(rSubID)
420 }
421 }
422 }
423 dmSubIDs = map[string]bool{}
424
425 for _, url := range relayURLs {
426 if !isAllowedRelay(url) {
427 continue
428 }
429 suffix := urlSuffix(url)
430 id1 := "dm4in_" + suffix
431 id2 := "dm4out_" + suffix
432 id3 := "dm17_" + suffix
433 dmSubIDs[id1] = true
434 dmSubIDs[id2] = true
435 dmSubIDs[id3] = true
436
437 c := getConn(url)
438 c.Subscribe(id1, []*nostr.Filter{{Kinds: []int{4}, Tags: map[string][]string{"#p": {myPubkey}}, Limit: 100}})
439 c.Subscribe(id2, []*nostr.Filter{{Kinds: []int{4}, Authors: []string{myPubkey}, Limit: 100}})
440 c.Subscribe(id3, []*nostr.Filter{{Kinds: []int{1059}, Tags: map[string][]string{"#p": {myPubkey}}, Limit: 100}})
441 }
442 }
443
444 func routerDMList(clientID string) {
445 cacheGetConversationList(func(listJSON string) {
446 sendToClient(clientID, "[\"DM_LIST\","+listJSON+"]")
447 })
448 }
449
450 func routerDMHistory(clientID, peer string, limit int, until int64) {
451 if limit <= 0 {
452 limit = 50
453 }
454 cacheQueryDMs(peer, limit, until, func(msgsJSON string) {
455 sendToClient(clientID, "[\"DM_HISTORY\","+jstr(peer)+","+msgsJSON+"]")
456 })
457 }
458
459 // --- Broadcast ---
460
461 func routerBroadcast(clientID, pubkey string, relayURLs []string) {
462 filterJSON := "{\"authors\":[" + jstr(pubkey) + "],\"kinds\":[0,3,10002,10050,10051]}"
463 cacheQuery(filterJSON, func(eventsJSON string) {
464 events := nostr.ParseEventsJSON(eventsJSON)
465 byKind := map[int]*nostr.Event{}
466 for _, ev := range events {
467 if prev, ok := byKind[ev.Kind]; !ok || ev.CreatedAt > prev.CreatedAt {
468 byKind[ev.Kind] = ev
469 }
470 }
471
472 userRelays := relayURLs
473 if relayEv, ok := byKind[10002]; ok {
474 userRelays = nil
475 for _, t := range relayEv.Tags.GetAll("r") {
476 userRelays = append(userRelays, t.Value())
477 }
478 }
479 if len(userRelays) == 0 {
480 userRelays = writeRelays
481 }
482
483 if _, ok := byKind[10050]; !ok && hasKey && len(userRelays) > 0 {
484 ev := createRelayListEvent(10050, pubkey, userRelays)
485 if ev != nil {
486 cacheStore(ev.ToJSON(), func(_ bool) {})
487 byKind[10050] = ev
488 }
489 }
490 if _, ok := byKind[10051]; !ok && hasKey && len(userRelays) > 0 {
491 ev := createRelayListEvent(10051, pubkey, userRelays)
492 if ev != nil {
493 cacheStore(ev.ToJSON(), func(_ bool) {})
494 byKind[10051] = ev
495 }
496 }
497
498 count := 0
499 for _, ev := range byKind {
500 for _, url := range relayURLs {
501 if !isAllowedRelay(url) {
502 continue
503 }
504 getConn(url).Publish(ev)
505 }
506 count++
507 }
508 sendToClient(clientID, "[\"BROADCAST_DONE\","+helpers.Itoa(int64(count))+","+helpers.Itoa(int64(len(relayURLs)))+"]")
509 })
510 }
511
512 func createRelayListEvent(kind int, _ string, relays []string) *nostr.Event {
513 tagKey := "relay"
514 var tags nostr.Tags
515 for _, r := range relays {
516 tags = append(tags, nostr.Tag{tagKey, r})
517 }
518 ev := &nostr.Event{
519 Kind: kind,
520 Content: "",
521 Tags: tags,
522 CreatedAt: sw.NowSeconds(),
523 }
524 if !identitySignEvent(ev) {
525 return nil
526 }
527 return ev
528 }
529
530 func stringsToJSON(ss []string) string {
531 if len(ss) == 0 {
532 return "[]"
533 }
534 b := "["
535 for i, s := range ss {
536 if i > 0 {
537 b += ","
538 }
539 b += jstr(s)
540 }
541 return b + "]"
542 }
543