From d1911c14a6862ce861a748a456910fd8630d57d4 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Wed, 7 Jan 2026 13:27:33 -0300 Subject: [PATCH] bunker and build (work-in-progress). --- build.go | 105 +++++++++++++ bunker.go | 436 +++++++++++++++++++++++++++++++++++++++++++++++++++++ event.go | 8 +- go.mod | 10 +- go.sum | 6 +- helpers.go | 20 +++ main.go | 51 +++++-- paste.go | 41 ++++- req.go | 116 +++++++++----- serve.go | 81 +++++++++- status.go | 27 ++++ 11 files changed, 827 insertions(+), 74 deletions(-) create mode 100644 build.go create mode 100644 bunker.go create mode 100644 status.go diff --git a/build.go b/build.go new file mode 100644 index 0000000..b7331e4 --- /dev/null +++ b/build.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "slices" + "strconv" + + "fiatjaf.com/nostr/schema" + qt "github.com/mappu/miqt/qt6" +) + +type buildVars struct { + kindsCombo *qt.QComboBox +} + +type kindItem struct { + kind uint16 + schema.KindSchema +} + +var build = &buildVars{} + +func setupBuildTab() *qt.QWidget { + tab := qt.NewQWidget(window.QWidget) + layout := qt.NewQVBoxLayout2() + tab.SetLayout(layout.QLayout) + + // label + label := qt.NewQLabel2() + label.SetText("select a kind to build an event:") + layout.AddWidget(label.QWidget) + + // dropdown + build.kindsCombo = qt.NewQComboBox(tab) + layout.AddWidget(build.kindsCombo.QWidget) + + // load kinds + loadKinds() + + return tab +} + +func loadKinds() { + sch, err := schema.FetchSchemaFromURL(schema.DefaultSchemaURL) + if err != nil { + fmt.Println("error loading schema:", err) + setStatus(tabs.build, "error loading schema: %s", err) + return + } + + items := make([]kindItem, 0, len(sch.Kinds)) + for k, v := range sch.Kinds { + num := -1 + fmt.Sscanf(k, "%d", &num) + items = append(items, kindItem{kind: uint16(num), KindSchema: v}) + } + slices.SortFunc(items, func(a, b kindItem) int { return int(a.kind - b.kind) }) + + model := qt.NewQStandardItemModel2(len(items)+2, 2) + inUseCategory := qt.NewQStandardItem2("common") + inUseCategory.SetFlags(inUseCategory.Flags() ^ qt.ItemIsSelectable) // remove selectable flag + inUseCategory.SetData(qt.NewQVariant(), int(qt.FontRole)) // bold font + model.AppendRowWithItem(inUseCategory) + + notInUseCategory := qt.NewQStandardItem2("weird") + notInUseCategory.SetFlags(inUseCategory.Flags() ^ qt.ItemIsSelectable) // remove selectable flag + notInUseCategory.SetData(qt.NewQVariant(), int(qt.FontRole)) // bold font + model.AppendRowWithItem(notInUseCategory) + + for _, item := range items { + qitem := []*qt.QStandardItem{ + qt.NewQStandardItem2(strconv.Itoa(int(item.kind))), + qt.NewQStandardItem2(item.Description), + } + if item.InUse { + inUseCategory.AppendRow(qitem) + } else { + notInUseCategory.AppendRow(qitem) + } + } + + proxy := qt.NewQSortFilterProxyModel() + proxy.SetSourceModel(model.QAbstractItemModel) + proxy.SetFilterCaseSensitivity(qt.CaseInsensitive) + build.kindsCombo.SetModel(proxy.QAbstractItemModel) + build.kindsCombo.SetEditable(true) + + tableView := qt.NewQAbstractItemView2() + tableView.SetSelectionBehavior(qt.QAbstractItemView__SelectRows) + tableView.SetSelectionMode(qt.QAbstractItemView__SingleSelection) + build.kindsCombo.SetView(tableView) + + build.kindsCombo.OnCurrentIndexChanged(func(index int) { + sourceIndex := proxy.MapToSource(proxy.Index(index, 0, qt.NewQModelIndex())) + actualIndex := sourceIndex.Row() + selectedItem := items[actualIndex] + fmt.Println("selected", selectedItem) + }) + + build.kindsCombo.OnEditTextChanged(func(text string) { + fmt.Println("########", text) + + proxy.SetFilterFixedString(text) + }) +} diff --git a/bunker.go b/bunker.go new file mode 100644 index 0000000..81a87d9 --- /dev/null +++ b/bunker.go @@ -0,0 +1,436 @@ +package main + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip19" + "fiatjaf.com/nostr/nip46" + qt "github.com/mappu/miqt/qt6" + "github.com/mappu/miqt/qt6/mainthread" +) + +type bunkerVars struct { + tab *qt.QWidget + + loadProfileButton *qt.QPushButton + startButton *qt.QPushButton + stopButton *qt.QPushButton + + authorizedKeysList *qt.QListWidget + logsList *qt.QListWidget + bunkerURIInput *qt.QLineEdit + + relaysHBox *qt.QHBoxLayout + relaysEdits []*qt.QLineEdit + + config struct { + AuthorizedKeys []nostr.PubKey `json:"authorized-keys"` + Secret plainOrEncryptedKey `json:"sec"` + Relays []string `json:"relays"` + } + + relays []*nostr.Relay + signer nip46.StaticKeySigner + handlerWg sync.WaitGroup + cancel context.CancelFunc + newSecret string +} + +var bunkerTab = &bunkerVars{} + +type plainOrEncryptedKey struct { + Plain *nostr.SecretKey + Encrypted *string +} + +func (pe plainOrEncryptedKey) MarshalJSON() ([]byte, error) { + if pe.Plain != nil { + res := make([]byte, 66) + hex.Encode(res[1:], (*pe.Plain)[:]) + res[0] = '"' + res[65] = '"' + return res, nil + } else if pe.Encrypted != nil { + return json.Marshal(*pe.Encrypted) + } + + return nil, fmt.Errorf("no key to marshal") +} + +func (pe *plainOrEncryptedKey) UnmarshalJSON(buf []byte) error { + if len(buf) == 66 { + sk, err := nostr.SecretKeyFromHex(string(buf[1 : 1+64])) + if err != nil { + return err + } + pe.Plain = &sk + return nil + } else if bytes.HasPrefix(buf, []byte("\"nsec")) { + _, v, err := nip19.Decode(string(buf[1 : len(buf)-1])) + if err != nil { + return err + } + sk := v.(nostr.SecretKey) + pe.Plain = &sk + return nil + } else if bytes.HasPrefix(buf, []byte("\"ncryptsec1")) { + ncryptsec := string(buf[1 : len(buf)-1]) + pe.Encrypted = &ncryptsec + return nil + } + + return fmt.Errorf("unrecognized key format '%s'", string(buf)) +} + +func setupBunkerTab() *qt.QWidget { + bunkerTab.tab = qt.NewQWidget(window.QWidget) + layout := qt.NewQVBoxLayout2() + bunkerTab.tab.SetLayout(layout.QLayout) + + // buttons row + buttonsHBox := qt.NewQHBoxLayout2() + layout.AddLayout(buttonsHBox.QLayout) + + bunkerTab.loadProfileButton = qt.NewQPushButton5("load profile", bunkerTab.tab) + buttonsHBox.AddWidget(bunkerTab.loadProfileButton.QWidget) + + bunkerTab.startButton = qt.NewQPushButton5("start", bunkerTab.tab) + buttonsHBox.AddWidget(bunkerTab.startButton.QWidget) + + bunkerTab.stopButton = qt.NewQPushButton5("stop", bunkerTab.tab) + bunkerTab.stopButton.SetEnabled(false) + buttonsHBox.AddWidget(bunkerTab.stopButton.QWidget) + + // bunker URI input + uriHBox := qt.NewQHBoxLayout2() + layout.AddLayout(uriHBox.QLayout) + + uriLabel := qt.NewQLabel2() + uriLabel.SetText("bunker URI:") + uriHBox.AddWidget(uriLabel.QWidget) + + bunkerTab.bunkerURIInput = qt.NewQLineEdit(bunkerTab.tab) + bunkerTab.bunkerURIInput.SetReadOnly(true) + uriHBox.AddWidget(bunkerTab.bunkerURIInput.QWidget) + + // relays + relaysHBox := qt.NewQHBoxLayout2() + layout.AddLayout(relaysHBox.QLayout) + bunkerTab.relaysHBox = relaysHBox + relaysLabel := qt.NewQLabel2() + relaysLabel.SetText("relays:") + relaysHBox.AddWidget(relaysLabel.QWidget) + + bunkerTab.relaysEdits = []*qt.QLineEdit{} + bunkerTab.addRelay("") + + // tables + tablesHBox := qt.NewQHBoxLayout2() + layout.AddLayout(tablesHBox.QLayout) + + // authorized keys list + keysVBox := qt.NewQVBoxLayout2() + keysLabel := qt.NewQLabel2() + keysLabel.SetText("authorized keys:") + keysVBox.AddWidget(keysLabel.QWidget) + + bunkerTab.authorizedKeysList = qt.NewQListWidget(bunkerTab.tab) + keysVBox.AddWidget(bunkerTab.authorizedKeysList.QWidget) + tablesHBox.AddLayout(keysVBox.QLayout) + + // logs list + logsVBox := qt.NewQVBoxLayout2() + logsLabel := qt.NewQLabel2() + logsLabel.SetText("logs:") + logsVBox.AddWidget(logsLabel.QWidget) + + bunkerTab.logsList = qt.NewQListWidget(bunkerTab.tab) + logsVBox.AddWidget(bunkerTab.logsList.QWidget) + tablesHBox.AddLayout(logsVBox.QLayout) + + // connect signals + bunkerTab.loadProfileButton.OnClicked(bunkerTab.loadProfile) + bunkerTab.startButton.OnClicked(bunkerTab.startBunker) + bunkerTab.stopButton.OnClicked(bunkerTab.stopBunker) + + return bunkerTab.tab +} + +func (b *bunkerVars) loadProfile() { + fileDialog := qt.NewQFileDialog(window.QWidget) + fileDialog.SetFileMode(qt.QFileDialog__ExistingFile) + fileDialog.SetNameFilter("JSON files (*.json)") + fileDialog.SetWindowTitle("select bunker profile") + + if fileDialog.Exec() == int(qt.QDialog__Accepted) { + selectedFiles := fileDialog.SelectedFiles() + if len(selectedFiles) > 0 { + filename := selectedFiles[0] + b.loadProfileFromFile(filename) + } + } +} + +func (b *bunkerVars) loadProfileFromFile(filename string) { + data, err := os.ReadFile(filename) + if err != nil { + setStatus(tabs.bunker, "failed to read profile: %v", err) + return + } + + if err := json.Unmarshal(data, &b.config); err != nil { + setStatus(tabs.bunker, "failed to parse profile: %v", err) + return + } + + // normalize relays + for i, url := range b.config.Relays { + b.config.Relays[i] = nostr.NormalizeURL(url) + } + + // set currentSec and currentKeyer if secret is available + if b.config.Secret.Plain != nil { + secEdit.SetText(b.config.Secret.Plain.Hex()) + } else if b.config.Secret.Encrypted != nil { + secEdit.SetText(*b.config.Secret.Encrypted) + } + keyChanged(secEdit.Text()) + + b.updateAuthorizedKeysTable() + b.updateRelaysUI() + setStatus(tabs.bunker, "loaded profile from %s", filepath.Base(filename)) +} + +func (b *bunkerVars) updateAuthorizedKeysTable() { + mainthread.Start(func() { + b.authorizedKeysList.Clear() + for _, pubkey := range b.config.AuthorizedKeys { + npub := nip19.EncodeNpub(pubkey) + item := qt.NewQListWidgetItem2(fmt.Sprintf("%s %s", pubkey.Hex(), npub)) + b.authorizedKeysList.AddItemWithItem(item) + } + }) +} + +func (b *bunkerVars) addRelay(value string) { + edit := qt.NewQLineEdit(b.tab) + edit.SetText(value) + b.relaysEdits = append(b.relaysEdits, edit) + b.relaysHBox.AddWidget(edit.QWidget) + edit.OnTextChanged(func(text string) { + if strings.TrimSpace(text) != "" { + if edit == b.relaysEdits[len(b.relaysEdits)-1] { + b.addRelay("") + } + } else { + n := len(b.relaysEdits) + if n >= 2 && strings.TrimSpace(b.relaysEdits[n-1].Text()) == "" && strings.TrimSpace(b.relaysEdits[n-2].Text()) == "" { + b.relaysHBox.RemoveWidget(b.relaysEdits[n-1].QWidget) + b.relaysEdits[n-1].DeleteLater() + b.relaysEdits = b.relaysEdits[0 : n-1] + } + } + b.updateConfigRelays() + }) +} + +func (b *bunkerVars) updateConfigRelays() { + b.config.Relays = []string{} + for _, edit := range b.relaysEdits { + text := strings.TrimSpace(edit.Text()) + if text != "" { + b.config.Relays = append(b.config.Relays, text) + } + } +} + +func (b *bunkerVars) updateRelaysUI() { + mainthread.Start(func() { + // clear existing + for _, edit := range b.relaysEdits { + b.relaysHBox.RemoveWidget(edit.QWidget) + edit.DeleteLater() + } + b.relaysEdits = []*qt.QLineEdit{} + // add from config + for _, relay := range b.config.Relays { + b.addRelay(relay) + } + b.addRelay("") // extra + }) +} + +func (bunker *bunkerVars) log(message string, args ...any) { + msg := fmt.Sprintf(message, args...) + timestamp := time.Now().Format("15:04:05") + mainthread.Start(func() { + pos := bunker.logsList.VerticalScrollBar().SliderPosition() + bunker.logsList.InsertItem(0, qt.NewQListWidgetItem2(fmt.Sprintf("%s %s", timestamp, msg))) + if pos == 0 { + bunker.logsList.ScrollToTop() + } + }) +} + +func (bunker *bunkerVars) startBunker() { + if len(bunker.config.Relays) == 0 { + setStatus(tabs.bunker, "no relays configured") + return + } + + if currentSec == [32]byte{} { + setStatus(tabs.bunker, "no secret key configured") + return + } + + bunker.startButton.SetEnabled(false) + bunker.stopButton.SetEnabled(true) + + ctx, cancel := context.WithCancel(context.Background()) + bunker.cancel = cancel + + // generate new secret for bunker URI + bunker.newSecret = randString(12) + + // connect to relays + bunker.relays = connectToAllRelays(ctx, bunker, bunker.config.Relays, nil, nostr.PoolOptions{}) + if len(bunker.relays) == 0 { + setStatus(tabs.bunker, "failed to connect to any relays") + bunker.stopBunker() + return + } + + // update bunker URI + bunker.updateBunkerURI() + + // setup signer + bunker.signer = nip46.NewStaticKeySigner(currentSec) + + // setup authorization + bunker.signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool { + if secret == bunker.newSecret { + // store this key + bunker.config.AuthorizedKeys = appendUnique(bunker.config.AuthorizedKeys, from) + // discard this and generate a new secret + bunker.newSecret = randString(12) + // update URI + bunker.updateBunkerURI() + // update table + bunker.updateAuthorizedKeysTable() + bunker.log("new client authorized: %s", from.Hex()) + return true + } + + authorized := slices.Contains(bunker.config.AuthorizedKeys, from) || slices.Contains([]string{}, secret) // TODO: authorized secrets + if authorized { + if harmless { + bunker.log("harmless request from %s", from.Hex()) + } else { + bunker.log("signing request from %s", from.Hex()) + } + } + return authorized + } + + // subscribe to requests + pubkey := currentSec.Public() + relayURLs := make([]string, len(bunker.relays)) + for i, relay := range bunker.relays { + relayURLs[i] = relay.URL + } + events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{ + Kinds: []nostr.Kind{nostr.KindNostrConnect}, + Tags: nostr.TagMap{"p": []string{pubkey.Hex()}}, + Since: nostr.Now(), + LimitZero: true, + }, nostr.SubscriptionOptions{ + Label: "vnak-bunker", + }) + + bunker.log("bunker started") + + go func() { + for ie := range events { + _, _, eventResponse, err := bunker.signer.HandleRequest(ctx, ie.Event) + if err != nil { + bunker.log("failed to handle request from %s: %s", ie.Event.PubKey, err.Error()) + continue + } + + bunker.log("request from %s", ie.Event.PubKey.Hex()) + + bunker.handlerWg.Add(len(bunker.relays)) + for _, relay := range bunker.relays { + go func(relay *nostr.Relay) { + defer bunker.handlerWg.Done() + if relay != nil { + err := relay.Publish(ctx, eventResponse) + if err != nil { + bunker.log("failed to send response: %s", err) + } else { + bunker.log("sent response through %s", relay.URL) + } + } + }(relay) + } + bunker.handlerWg.Wait() + } + }() +} + +func (bunker *bunkerVars) stopBunker() { + if bunker.cancel != nil { + bunker.cancel() + } + bunker.startButton.SetEnabled(true) + bunker.stopButton.SetEnabled(false) + bunker.bunkerURIInput.SetText("") + bunker.log("bunker stopped") +} + +func (bunker *bunkerVars) updateBunkerURI() { + if bunker.config.Secret.Plain == nil { + return + } + + pubkey := bunker.config.Secret.Plain.Public() + qs := url.Values{} + for _, relay := range bunker.relays { + qs.Add("relay", relay.URL) + } + qs.Set("secret", bunker.newSecret) + bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode()) + + mainthread.Start(func() { + bunker.bunkerURIInput.SetText(bunkerURI) + }) +} + +func connectToAllRelays(ctx context.Context, b *bunkerVars, relayURLs []string, onConnect func(*nostr.Relay), opts nostr.PoolOptions) []*nostr.Relay { + relays := make([]*nostr.Relay, 0, len(relayURLs)) + for _, relayURL := range relayURLs { + relay, err := sys.Pool.EnsureRelay(relayURL) + if err != nil { + b.log("failed to connect to %s: %v", relayURL, err) + continue + } + relays = append(relays, relay) + if onConnect != nil { + onConnect(relay) + } + b.log("connected to %s", relayURL) + } + return relays +} diff --git a/event.go b/event.go index 287ad7a..7980c86 100644 --- a/event.go +++ b/event.go @@ -94,7 +94,7 @@ func setupEventTab() *qt.QWidget { copyButton := qt.NewQPushButton5("➡️", event.tab) outputHBox.AddWidget(copyButton.QWidget) copyButton.OnClicked(func() { - tabWidget.SetCurrentIndex(tabIndexes.paste) + tabWidget.SetCurrentIndex(tabs.paste) paste.inputEdit.SetPlainText(event.outputEdit.ToPlainText()) }) @@ -153,7 +153,7 @@ func setupEventTab() *qt.QWidget { sendButton.OnClicked(func() { if event.currentEvent == nil { - statusLabel.SetText("no event to publish") + setStatus(tabs.event, "no event to publish") return } @@ -166,7 +166,7 @@ func setupEventTab() *qt.QWidget { } } if len(relays) == 0 { - statusLabel.SetText("no relays specified") + setStatus(tabs.event, "no relays specified") return } @@ -304,7 +304,7 @@ func (event *eventVars) updateEvent() { if err := currentKeyer.SignEvent(ctx, &result); err == nil { finalize() } else { - statusLabel.SetText("failed to sign: " + err.Error()) + setStatus(tabs.event, "failed to sign: "+err.Error()) } } } diff --git a/go.mod b/go.mod index 4446283..1ebddc1 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/fiatjaf/vnak -go 1.25.4 +go 1.25 require ( fiatjaf.com/lib v0.3.2 fiatjaf.com/nostr v0.0.0-20251126120447-7261a4b515ed - github.com/bep/debounce v1.2.1 github.com/btcsuite/btcd/btcec/v2 v2.3.4 + github.com/mailru/easyjson v0.9.0 github.com/mappu/miqt v0.12.0 github.com/puzpuzpuz/xsync/v3 v3.5.1 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 @@ -16,6 +16,7 @@ require ( github.com/FastFilter/xorfilter v0.2.1 // indirect github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect github.com/andybalholm/brotli v1.1.1 // indirect + github.com/bep/debounce v1.2.1 // indirect github.com/bluekeyes/go-gitdiff v0.7.1 // indirect github.com/btcsuite/btcd v0.24.2 // indirect github.com/btcsuite/btcd/btcutil v1.1.5 // indirect @@ -34,11 +35,11 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/liamg/magic v0.0.1 // indirect - github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/rs/cors v1.11.1 // indirect github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/templexxx/cpu v0.0.1 // indirect github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -52,4 +53,7 @@ require ( golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.26.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace fiatjaf.com/nostr => ../nostrlib diff --git a/go.sum b/go.sum index 752613b..d5421b5 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q= fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= -fiatjaf.com/nostr v0.0.0-20251126120447-7261a4b515ed h1:dip4Bxlfj4/N2oIIGCZpcYRrvfMObcXRcyBM5uYswnM= -fiatjaf.com/nostr v0.0.0-20251126120447-7261a4b515ed/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= @@ -141,8 +139,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY= github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= diff --git a/helpers.go b/helpers.go index aa68651..cec48c6 100644 --- a/helpers.go +++ b/helpers.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "slices" "strings" "time" @@ -116,3 +117,22 @@ func niceRelayURLs(urls []string) []string { } return nices } + +func randString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[time.Now().UnixNano()%int64(len(letters))] + time.Sleep(1 * time.Nanosecond) // ensure different values + } + return string(b) +} + +func appendUnique[T comparable](slice []T, items ...T) []T { + for _, item := range items { + if !slices.Contains(slice, item) { + slice = append(slice, item) + } + } + return slice +} diff --git a/main.go b/main.go index f24f0ca..5c744ed 100644 --- a/main.go +++ b/main.go @@ -23,15 +23,19 @@ var ( window *qt.QMainWindow tabWidget *qt.QTabWidget + keyChanged func(string) + secEdit *qt.QLineEdit currentSec nostr.SecretKey currentKeyer nostr.Keyer - tabIndexes struct { - event int - req int - paste int - serve int + + tabs struct { + event int + req int + paste int + serve int + bunker int + build int } - statusLabel *qt.QLabel debounced = debouncer.New(950 * time.Millisecond) sys = sdk.NewSystem() @@ -92,7 +96,7 @@ func main() { secHBox := qt.NewQHBoxLayout2() mainLayout.AddLayout(secHBox.QLayout) - secEdit := qt.NewQLineEdit(centralWidget) + secEdit = qt.NewQLineEdit(centralWidget) secHBox.AddWidget(secEdit.QWidget) generateButton := qt.NewQPushButton5("generate", centralWidget) secHBox.AddWidget(generateButton.QWidget) @@ -109,7 +113,7 @@ func main() { secPasswordEdit := qt.NewQLineEdit(passwordWidget) secPasswordEdit.SetEchoMode(qt.QLineEdit__Password) passwordHBox.AddWidget(secPasswordEdit.QWidget) - keyChanged := func(text string) { + keyChanged = func(text string) { text = strings.TrimSpace(text) var sk nostr.SecretKey @@ -168,33 +172,48 @@ func main() { }) tabWidget = qt.NewQTabWidget(centralWidget) + tabWidget.OnCurrentChanged(func(index int) { + maybeSetStatusOnTab(index) + }) eventTab := setupEventTab() reqTab := setupReqTab() pasteTab := setupPasteTab() serveTab := setupServeTab() + bunkerTab := setupBunkerTab() + buildTab := setupBuildTab() tabWidget.AddTab(eventTab, "event") - tabIndexes.event = 0 + tabs.event = 0 tabWidget.AddTab(reqTab, "req") - tabIndexes.req = 1 + tabs.req = 1 tabWidget.AddTab(pasteTab, "paste") - tabIndexes.paste = 2 + tabs.paste = 2 tabWidget.AddTab(serveTab, "serve") - tabIndexes.serve = 3 + tabs.serve = 3 + + tabWidget.AddTab(bunkerTab, "bunker") + tabs.bunker = 4 + + tabWidget.AddTab(buildTab, "build") + tabs.build = 5 switch *initialTab { case "event": - tabWidget.SetCurrentIndex(tabIndexes.event) + tabWidget.SetCurrentIndex(tabs.event) case "req": - tabWidget.SetCurrentIndex(tabIndexes.req) + tabWidget.SetCurrentIndex(tabs.req) case "paste": - tabWidget.SetCurrentIndex(tabIndexes.paste) + tabWidget.SetCurrentIndex(tabs.paste) case "serve": - tabWidget.SetCurrentIndex(tabIndexes.serve) + tabWidget.SetCurrentIndex(tabs.serve) + case "bunker": + tabWidget.SetCurrentIndex(tabs.bunker) + case "build": + tabWidget.SetCurrentIndex(tabs.build) default: tabWidget.SetCurrentIndex(0) } diff --git a/paste.go b/paste.go index 9ba8f95..be274ad 100644 --- a/paste.go +++ b/paste.go @@ -236,11 +236,34 @@ func (p *pasteVars) displayEventPointer(ep nostr.EventPointer) { p.outputVBox.AddWidget(authorEdit.QWidget) } p.displayRelays(ep.Relays) + + button := qt.NewQPushButton5("filter ➡️", window.QWidget) + button.OnClicked(func() { + filter := nostr.Filter{ + IDs: []nostr.ID{ep.ID}, + } + if ep.Author != nostr.ZeroPK { + filter.Authors = []nostr.PubKey{ep.Author} + } + req.populate(filter, ep.Relays) + tabWidget.SetCurrentIndex(tabs.req) + }) + p.outputVBox.AddWidget(button.QWidget) } func (p *pasteVars) displayProfilePointer(pp nostr.ProfilePointer) { p.displayPubKey(pp.PublicKey) p.displayRelays(pp.Relays) + + button := qt.NewQPushButton5("filter ➡️", window.QWidget) + button.OnClicked(func() { + filter := nostr.Filter{ + Authors: []nostr.PubKey{pp.PublicKey}, + } + req.populate(filter, pp.Relays) + tabWidget.SetCurrentIndex(tabs.req) + }) + p.outputVBox.AddWidget(button.QWidget) } func (p *pasteVars) displayRelays(relays []string) { @@ -283,6 +306,18 @@ func (p *pasteVars) displayAddressPointer(ap nostr.EntityPointer) { p.outputVBox.AddWidget(identifierEdit.QWidget) p.displayRelays(ap.Relays) + + button := qt.NewQPushButton5("filter ➡️", window.QWidget) + button.OnClicked(func() { + filter := nostr.Filter{ + Kinds: []nostr.Kind{ap.Kind}, + Authors: []nostr.PubKey{ap.PublicKey}, + Tags: map[string][]string{"d": []string{ap.Identifier}}, + } + req.populate(filter, ap.Relays) + tabWidget.SetCurrentIndex(tabs.req) + }) + p.outputVBox.AddWidget(button.QWidget) } func (p *pasteVars) displayNip05(identifier string) { @@ -328,7 +363,7 @@ func (p *pasteVars) displayEventButton(evt nostr.Event) { button.OnClicked(func() { event.populate(evt) // switch to event tab - tabWidget.SetCurrentIndex(tabIndexes.event) + tabWidget.SetCurrentIndex(tabs.event) }) p.outputVBox.AddWidget(button.QWidget) } @@ -336,9 +371,9 @@ func (p *pasteVars) displayEventButton(evt nostr.Event) { func (p *pasteVars) displayFilterButton(filter nostr.Filter) { button := qt.NewQPushButton5("filter ➡️", window.QWidget) button.OnClicked(func() { - req.populate(filter) + req.populate(filter, []string{}) // switch to req tab - tabWidget.SetCurrentIndex(tabIndexes.req) + tabWidget.SetCurrentIndex(tabs.req) }) p.outputVBox.AddWidget(button.QWidget) } diff --git a/req.go b/req.go index 0873a3a..fa836e7 100644 --- a/req.go +++ b/req.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "fmt" "strconv" "strings" "time" @@ -26,6 +25,7 @@ type reqVars struct { tagsVBox *qt.QVBoxLayout tagRows []reqTagRow tagsLayout *qt.QVBoxLayout + relaysHBox *qt.QHBoxLayout relaysEdits []*qt.QLineEdit sinceEdit *qt.QDateTimeEdit sinceCheck *qt.QCheckBox @@ -55,6 +55,29 @@ type reqTagRow struct { var req = &reqVars{} +func (req *reqVars) addRelayEdit() { + edit := qt.NewQLineEdit(req.tab) + req.relaysEdits = append(req.relaysEdits, edit) + req.relaysHBox.AddWidget(edit.QWidget) + edit.OnTextChanged(func(text string) { + if strings.TrimSpace(text) != "" { + if edit == req.relaysEdits[len(req.relaysEdits)-1] { + req.addRelayEdit() + } + } else { + n := len(req.relaysEdits) + if n >= 2 && strings.TrimSpace(req.relaysEdits[n-1].Text()) == "" && strings.TrimSpace(req.relaysEdits[n-2].Text()) == "" { + req.relaysHBox.RemoveWidget(req.relaysEdits[n-1].QWidget) + req.relaysEdits[n-1].DeleteLater() + req.relaysEdits = req.relaysEdits[0 : n-1] + } + } + }) + edit.OnReturnPressed(func() { + req.subscribe() + }) +} + func setupReqTab() *qt.QWidget { req.tab = qt.NewQWidget(window.QWidget) layout := qt.NewQVBoxLayout2() @@ -181,42 +204,18 @@ func setupReqTab() *qt.QWidget { outputHBox.AddWidget(req.outputEdit.QWidget) sendButton := qt.NewQPushButton5("send request", req.tab) - sendButton.OnClicked(func() { - req.subscribe() - }) + sendButton.OnClicked(req.subscribe) // relays relaysHBox := qt.NewQHBoxLayout2() + req.relaysHBox = relaysHBox layout.AddLayout(relaysHBox.QLayout) relaysLabel := qt.NewQLabel2() relaysLabel.SetText("relays:") relaysHBox.AddWidget(relaysLabel.QWidget) req.relaysEdits = []*qt.QLineEdit{} - var addRelayEdit func() - addRelayEdit = func() { - edit := qt.NewQLineEdit(req.tab) - req.relaysEdits = append(req.relaysEdits, edit) - relaysHBox.AddWidget(edit.QWidget) - edit.OnTextChanged(func(text string) { - if strings.TrimSpace(text) != "" { - if edit == req.relaysEdits[len(req.relaysEdits)-1] { - addRelayEdit() - } - } else { - n := len(req.relaysEdits) - if n >= 2 && strings.TrimSpace(req.relaysEdits[n-1].Text()) == "" && strings.TrimSpace(req.relaysEdits[n-2].Text()) == "" { - relaysHBox.RemoveWidget(req.relaysEdits[n-1].QWidget) - req.relaysEdits[n-1].DeleteLater() - req.relaysEdits = req.relaysEdits[0 : n-1] - } - } - }) - edit.OnReturnPressed(func() { - sendButton.Click() - }) - } - addRelayEdit() + req.addRelayEdit() // results resultsVBox := qt.NewQVBoxLayout2() @@ -233,11 +232,11 @@ func setupReqTab() *qt.QWidget { // double-click to show pretty JSON req.resultsList.OnItemDoubleClicked(func(item *qt.QListWidgetItem) { - var event nostr.Event - if err := json.Unmarshal([]byte(item.Text()), &event); err != nil { + var evt nostr.Event + if err := json.Unmarshal([]byte(item.Text()), &evt); err != nil { return } - pretty, _ := json.MarshalIndent(event, "", " ") + pretty, _ := json.MarshalIndent(evt, "", " ") dialog := qt.NewQDialog(window.QWidget) dialog.SetWindowTitle("event") dialog.SetMinimumWidth(400) @@ -248,9 +247,31 @@ func setupReqTab() *qt.QWidget { textEdit.SetReadOnly(true) textEdit.SetPlainText(string(pretty)) dlayout.AddWidget(textEdit.QWidget) + + // buttons + buttonsHBox := qt.NewQHBoxLayout2() + dlayout.AddLayout(buttonsHBox.QLayout) + + editButton := qt.NewQPushButton5("➡️ edit", dialog.QWidget) + editButton.OnClicked(func() { + paste.inputEdit.SetPlainText(string(pretty)) + tabWidget.SetCurrentIndex(tabs.paste) + dialog.Close() + }) + buttonsHBox.AddWidget(editButton.QWidget) + + publishButton := qt.NewQPushButton5("➡️ publish", dialog.QWidget) + publishButton.OnClicked(func() { + event.populate(evt) + tabWidget.SetCurrentIndex(tabs.event) + dialog.Close() + }) + buttonsHBox.AddWidget(publishButton.QWidget) + closeButton := qt.NewQPushButton5("close", dialog.QWidget) closeButton.OnClicked(func() { dialog.Close() }) - dlayout.AddWidget(closeButton.QWidget) + buttonsHBox.AddWidget(closeButton.QWidget) + dialog.Exec() }) @@ -267,6 +288,7 @@ func (req *reqVars) updateReq() { authors = append(authors, pk) } } + if len(authors) > 0 { req.filter.Authors = authors } @@ -360,7 +382,7 @@ func (req *reqVars) subscribe() { } } if len(relays) == 0 { - statusLabel.SetText("no relays specified") + setStatus(tabs.req, "no relays specified") return } @@ -371,7 +393,7 @@ func (req *reqVars) subscribe() { if len(relays) == 1 { relay, err := sys.Pool.EnsureRelay(relays[0]) if err != nil { - statusLabel.SetText(fmt.Sprintf("failed to connect to %s: %s", niceRelayURL(relays[0]), err)) + setStatus(tabs.req, "failed to connect to %s: %s", niceRelayURL(relays[0]), err) return } @@ -380,17 +402,17 @@ func (req *reqVars) subscribe() { return currentKeyer.SignEvent(ctx, evt) }) if err != nil { - statusLabel.SetText(fmt.Sprintf("failed to auth to %s: %s", niceRelayURL(relay.URL), err)) + setStatus(tabs.req, "failed to auth to %s: %s", niceRelayURL(relay.URL), err) return } } - statusLabel.SetText("subscribed to " + niceRelayURL(relay.URL)) + setStatus(tabs.req, "subscribed to "+niceRelayURL(relay.URL)) sub, err := relay.Subscribe(ctx, req.filter, nostr.SubscriptionOptions{ Label: "vnak-req-1", }) if err != nil { - statusLabel.SetText(fmt.Sprintf("failed to subscribe to %s: %s", niceRelayURL(relay.URL), err)) + setStatus(tabs.req, "failed to subscribe to %s: %s", niceRelayURL(relay.URL), err) return } @@ -401,11 +423,11 @@ func (req *reqVars) subscribe() { reason := <-sub.ClosedReason time.Sleep(time.Second) mainthread.Wait(func() { - statusLabel.SetText(fmt.Sprintf("subscription closed: %s", reason)) + setStatus(tabs.req, "subscription closed: %s", reason) }) }() } else { - statusLabel.SetText("subscribed to " + strings.Join(niceRelayURLs(relays), ", ")) + setStatus(tabs.req, "subscribed to "+strings.Join(niceRelayURLs(relays), ", ")) eoseChan = make(chan struct{}) eventsChan = make(chan nostr.Event) @@ -435,7 +457,7 @@ func (req *reqVars) subscribe() { } }) } - statusLabel.SetText("subscription ended") + setStatus(tabs.req, "subscription ended") }() go func() { @@ -444,7 +466,7 @@ func (req *reqVars) subscribe() { }() } -func (req *reqVars) populate(filter nostr.Filter) { +func (req *reqVars) populate(filter nostr.Filter, relays []string) { // clear all authors except the first, set the first to "" for _, authorEdit := range req.authorsEdits { req.authorsVBox.RemoveWidget(authorEdit.QWidget) @@ -539,6 +561,18 @@ func (req *reqVars) populate(filter nostr.Filter) { req.limitCheck.SetChecked(false) } + // set relays + for _, relayEdit := range req.relaysEdits { + req.relaysHBox.RemoveWidget(relayEdit.QWidget) + relayEdit.DeleteLater() + } + req.relaysEdits = req.relaysEdits[:0] + for _, relay := range relays { + req.addRelayEdit() + req.relaysEdits[len(req.relaysEdits)-1].SetText(relay) + } + req.addRelayEdit() // extra empty + req.updateReq() } diff --git a/serve.go b/serve.go index 61a3ee9..b2efc91 100644 --- a/serve.go +++ b/serve.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/json" @@ -13,6 +14,7 @@ import ( "path/filepath" "strings" "sync/atomic" + "time" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore/slicestore" @@ -108,9 +110,17 @@ func setupServeTab() *qt.QWidget { // events column eventsVBox := qt.NewQVBoxLayout2() + eventsHBox := qt.NewQHBoxLayout2() + eventsVBox.AddLayout(eventsHBox.QLayout) + eventsLabel := qt.NewQLabel2() eventsLabel.SetText("events:") - eventsVBox.AddWidget(eventsLabel.QWidget) + eventsHBox.AddWidget(eventsLabel.QWidget) + + loadEventsButton := qt.NewQPushButton5("load events", serve.tab) + loadEventsButton.OnClicked(serve.loadEventsFromFile) + eventsHBox.AddWidget(loadEventsButton.QWidget) + serve.eventsList = qt.NewQListWidget(serve.tab) serve.eventsList.SetMinimumHeight(200) eventsVBox.AddWidget(serve.eventsList.QWidget) @@ -362,10 +372,10 @@ func (serve *serveVars) stopRelay() { func (serve *serveVars) log(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) + timestamp := time.Now().Format("15:04:05") mainthread.Start(func() { - item := qt.NewQListWidgetItem2(msg) pos := serve.logsList.VerticalScrollBar().SliderPosition() - serve.logsList.InsertItem(0, item) + serve.logsList.InsertItem(0, qt.NewQListWidgetItem2(fmt.Sprintf("%s %s", timestamp, msg))) if pos == 0 { serve.logsList.ScrollToTop() } @@ -433,6 +443,71 @@ func (serve *serveVars) updateBlossomBlobsList() { }) } +func (serve *serveVars) loadEventsFromFile() { + if serve.db == nil { + serve.log("relay not started") + return + } + + dialog := qt.NewQFileDialog(serve.tab) + dialog.SetFileMode(qt.QFileDialog__ExistingFile) + dialog.SetNameFilter("JSONL Files (*.jsonl);;All Files (*)") + + if dialog.Exec() == 0 { + return + } + + filePaths := dialog.SelectedFiles() + if len(filePaths) == 0 { + return + } + + filePath := filePaths[0] + + go func() { + file, err := os.Open(filePath) + if err != nil { + serve.log("failed to open file: %v", err) + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + loaded := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var event nostr.Event + if err := json.Unmarshal([]byte(line), &event); err != nil { + serve.log("failed to parse line %d: %v", lineNum, err) + continue + } + + if serve.relay != nil { + if _, err := serve.relay.AddEvent(context.Background(), event); err != nil { + serve.log("failed to save event from line %d: %v", lineNum, err) + continue + } + } + + loaded++ + } + + if err := scanner.Err(); err != nil { + serve.log("error reading file: %v", err) + } + + serve.log("loaded %d events from %s", loaded, filePath) + serve.updateEventsList() + }() +} + func (serve *serveVars) updateEventsList() { mainthread.Start(func() { serve.eventsList.Clear() diff --git a/status.go b/status.go new file mode 100644 index 0000000..ed291ec --- /dev/null +++ b/status.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "reflect" + + qt "github.com/mappu/miqt/qt6" +) + +var statusLabel *qt.QLabel + +var statuses []string = make([]string, reflect.TypeOf(tabs).NumField()) + +func setStatus(fromTab int, msg string, args ...any) { + msg = fmt.Sprintf(msg, args...) + + statuses[fromTab] = msg + if tabWidget.CurrentIndex() == fromTab { + statusLabel.SetText(msg) + } +} + +func maybeSetStatusOnTab(tab int) { + if statusLabel != nil && statuses[tab] != "" { + statusLabel.SetText(statuses[tab]) + } +}