Files
vnak/serve.go
2026-01-08 18:58:15 -03:00

521 lines
14 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/khatru"
"fiatjaf.com/nostr/khatru/blossom"
"fiatjaf.com/nostr/khatru/grasp"
"github.com/mailru/easyjson"
qt "github.com/mappu/miqt/qt6"
"github.com/mappu/miqt/qt6/mainthread"
"github.com/puzpuzpuz/xsync/v3"
)
type serveVars struct {
tab *qt.QWidget
graspCheck *qt.QCheckBox
blossomCheck *qt.QCheckBox
negentropyCheck *qt.QCheckBox
serverAddressInput *qt.QLineEdit
startButton *qt.QPushButton
stopButton *qt.QPushButton
logsList *qt.QListWidget
eventsList *qt.QListWidget
graspReposList *serveSpecialBox
blossomBlobsList *serveSpecialBox
bottomHBox *qt.QHBoxLayout
relay *khatru.Relay
db *slicestore.SliceStore
blobStore *xsync.MapOf[string, []byte]
repoDir string
}
type serveSpecialBox struct {
vbox *qt.QVBoxLayout
list *qt.QListWidget
label *qt.QLabel
}
var serve = &serveVars{}
func setupServeTab() *qt.QWidget {
serve.tab = qt.NewQWidget(window.QWidget)
layout := qt.NewQVBoxLayout2()
serve.tab.SetLayout(layout.QLayout)
// checkboxes
optionsHBox := qt.NewQHBoxLayout2()
layout.AddLayout(optionsHBox.QLayout)
serve.negentropyCheck = qt.NewQCheckBox(serve.tab)
serve.negentropyCheck.SetText("negentropy")
optionsHBox.AddWidget(serve.negentropyCheck.QWidget)
serve.graspCheck = qt.NewQCheckBox(serve.tab)
serve.graspCheck.SetText("grasp")
optionsHBox.AddWidget(serve.graspCheck.QWidget)
serve.blossomCheck = qt.NewQCheckBox(serve.tab)
serve.blossomCheck.SetText("blossom")
optionsHBox.AddWidget(serve.blossomCheck.QWidget)
serve.serverAddressInput = qt.NewQLineEdit(serve.tab)
serve.serverAddressInput.SetReadOnly(true)
optionsHBox.AddWidget(serve.serverAddressInput.QWidget)
// buttons
buttonsHBox := qt.NewQHBoxLayout2()
layout.AddLayout(buttonsHBox.QLayout)
serve.startButton = qt.NewQPushButton5("start", serve.tab)
buttonsHBox.AddWidget(serve.startButton.QWidget)
serve.stopButton = qt.NewQPushButton5("stop", serve.tab)
serve.stopButton.SetEnabled(false)
buttonsHBox.AddWidget(serve.stopButton.QWidget)
// logs
logsLabel := qt.NewQLabel2()
logsLabel.SetText("logs:")
layout.AddWidget(logsLabel.QWidget)
serve.logsList = qt.NewQListWidget(serve.tab)
serve.logsList.SetMinimumHeight(150)
layout.AddWidget(serve.logsList.QWidget)
// bottom layout with columns
serve.bottomHBox = qt.NewQHBoxLayout2()
layout.AddLayout(serve.bottomHBox.QLayout)
// events column
eventsVBox := qt.NewQVBoxLayout2()
eventsHBox := qt.NewQHBoxLayout2()
eventsVBox.AddLayout(eventsHBox.QLayout)
eventsLabel := qt.NewQLabel2()
eventsLabel.SetText("events:")
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)
serve.bottomHBox.AddLayout(eventsVBox.QLayout)
// double-click events
serve.eventsList.OnItemDoubleClicked(func(item *qt.QListWidgetItem) {
var event nostr.Event
if err := json.Unmarshal([]byte(item.Text()), &event); err != nil {
return
}
pretty, _ := json.MarshalIndent(event, "", " ")
dialog := qt.NewQDialog(window.QWidget)
dialog.SetWindowTitle("event")
dialog.SetMinimumWidth(400)
dialog.SetMinimumHeight(500)
dlayout := qt.NewQVBoxLayout2()
dialog.SetLayout(dlayout.QLayout)
textEdit := qt.NewQTextEdit(dialog.QWidget)
textEdit.SetReadOnly(true)
textEdit.SetPlainText(string(pretty))
dlayout.AddWidget(textEdit.QWidget)
closeButton := qt.NewQPushButton5("close", dialog.QWidget)
closeButton.OnClicked(func() { dialog.Close() })
dlayout.AddWidget(closeButton.QWidget)
dialog.Exec()
})
serve.startButton.OnClicked(serve.startRelay)
serve.stopButton.OnClicked(serve.stopRelay)
return serve.tab
}
func (serve *serveVars) startRelay() {
serve.startButton.SetEnabled(false)
serve.stopButton.SetEnabled(true)
serve.negentropyCheck.SetEnabled(false)
serve.blossomCheck.SetEnabled(false)
serve.graspCheck.SetEnabled(false)
// clear blossom and grasp boxes
if serve.blossomBlobsList != nil {
serve.blossomBlobsList.vbox.RemoveWidget(serve.blossomBlobsList.label.QWidget)
serve.blossomBlobsList.label.DeleteLater()
serve.blossomBlobsList.vbox.RemoveWidget(serve.blossomBlobsList.list.QWidget)
serve.blossomBlobsList.list.DeleteLater()
serve.bottomHBox.RemoveItem(serve.blossomBlobsList.vbox.QLayoutItem)
serve.blossomBlobsList.vbox.DeleteLater()
serve.blossomBlobsList = nil
}
if serve.graspReposList != nil {
serve.graspReposList.vbox.RemoveWidget(serve.graspReposList.label.QWidget)
serve.graspReposList.label.DeleteLater()
serve.graspReposList.vbox.RemoveWidget(serve.graspReposList.list.QWidget)
serve.graspReposList.list.DeleteLater()
serve.bottomHBox.RemoveItem(serve.graspReposList.vbox.QLayoutItem)
serve.graspReposList.vbox.DeleteLater()
serve.graspReposList = nil
}
// setup relay
if serve.db == nil {
serve.db = &slicestore.SliceStore{}
}
serve.relay = khatru.NewRelay()
serve.relay.Info.Name = "vnak serve"
serve.relay.Info.Description = "a local relay for testing, debugging and development."
serve.relay.Info.Software = "https://github.com/fiatjaf/vnak"
serve.relay.Info.Version = "dev"
serve.relay.UseEventstore(serve.db, 500)
if serve.negentropyCheck.IsChecked() {
serve.relay.Negentropy = true
}
started := make(chan bool)
exited := make(chan error)
hostname := "localhost"
port := 10547
if serve.blossomCheck.IsChecked() {
// setup blossom
if serve.blobStore == nil {
serve.blobStore = xsync.NewMapOf[string, []byte]()
}
bs := blossom.New(serve.relay, fmt.Sprintf("http://%s:%d", hostname, port))
bs.Store = blossom.NewMemoryBlobIndex()
bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
serve.blobStore.Store(sha256+ext, body)
serve.log("blob stored: %s", sha256+ext)
serve.updateBlossomBlobsList()
return nil
}
bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) {
if body, ok := serve.blobStore.Load(sha256 + ext); ok {
serve.log("blob download: %s", sha256+ext)
return bytes.NewReader(body), nil, nil
}
return nil, nil, nil
}
bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error {
serve.blobStore.Delete(sha256 + ext)
serve.log("blob delete: %s", sha256+ext)
serve.updateBlossomBlobsList()
return nil
}
// display blossom box
serve.blossomBlobsList = &serveSpecialBox{
vbox: qt.NewQVBoxLayout2(),
label: qt.NewQLabel2(),
list: qt.NewQListWidget(serve.tab),
}
serve.blossomBlobsList.list.SetMinimumWidth(300)
serve.blossomBlobsList.label.SetText("blossom blobs:")
serve.blossomBlobsList.vbox.AddWidget(serve.blossomBlobsList.label.QWidget)
serve.blossomBlobsList.vbox.AddWidget(serve.blossomBlobsList.list.QWidget)
serve.bottomHBox.AddLayout(serve.blossomBlobsList.vbox.QLayout)
serve.updateBlossomBlobsList()
}
if serve.graspCheck.IsChecked() {
// setup grasp
if serve.repoDir == "" {
var err error
serve.repoDir, err = os.MkdirTemp("", "vnak-serve-grasp-repos-")
if err != nil {
serve.log("failed to create grasp repos directory: %w", err)
return
}
}
g := grasp.New(serve.relay, serve.repoDir)
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
serve.log("git read by '%s' at '%s'", pubkey.Hex(), repo)
serve.updateGraspReposList()
return false, ""
}
g.OnWrite = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
serve.log("git write by '%s' at '%s'", pubkey.Hex(), repo)
serve.updateGraspReposList()
return false, ""
}
// display grasp vbox
serve.graspReposList = &serveSpecialBox{
vbox: qt.NewQVBoxLayout2(),
label: qt.NewQLabel2(),
list: qt.NewQListWidget(serve.tab),
}
serve.graspReposList.list.SetMinimumWidth(300)
serve.graspReposList.label.SetText("grasp repos:")
serve.graspReposList.vbox.AddWidget(serve.graspReposList.label.QWidget)
serve.graspReposList.vbox.AddWidget(serve.graspReposList.list.QWidget)
serve.bottomHBox.AddLayout(serve.graspReposList.vbox.QLayout)
serve.updateGraspReposList()
}
go func() {
err := serve.relay.Start(hostname, port, started)
exited <- err
}()
// relay logging
serve.relay.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
negentropy := ""
if khatru.IsNegentropySession(ctx) {
negentropy = "negentropy "
}
serve.log("%srequest: %s", negentropy, filter)
return false, ""
}
serve.relay.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
serve.log("count request: %s", filter)
return false, ""
}
serve.relay.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
serve.log("event: %s", event)
return false, ""
}
serve.relay.OnEventSaved = func(ctx context.Context, event nostr.Event) {
serve.updateEventsList()
}
totalConnections := atomic.Int32{}
serve.relay.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
<-started
serve.log("relay running at %s", fmt.Sprintf("ws://%s:%d", hostname, port))
mainthread.Start(func() {
serve.serverAddressInput.SetText(fmt.Sprintf("ws://%s:%d", hostname, port))
if serve.graspCheck.IsChecked() {
serve.updateGraspReposList()
}
if serve.blossomCheck.IsChecked() {
serve.updateBlossomBlobsList()
}
})
if serve.graspCheck.IsChecked() {
serve.log("grasp repos at %s", serve.repoDir)
}
go func() {
err := <-exited
if err != nil {
serve.log("relay exited with error: %s", err)
}
mainthread.Wait(func() {
serve.startButton.SetEnabled(true)
serve.stopButton.SetEnabled(false)
})
}()
}
func (serve *serveVars) stopRelay() {
if serve.relay != nil {
serve.relay.Shutdown(ctx)
}
serve.startButton.SetEnabled(true)
serve.stopButton.SetEnabled(false)
serve.negentropyCheck.SetEnabled(true)
serve.blossomCheck.SetEnabled(true)
serve.graspCheck.SetEnabled(true)
serve.serverAddressInput.SetText("")
serve.log("relay stopped")
}
func (serve *serveVars) log(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
timestamp := time.Now().Format("15:04:05")
mainthread.Start(func() {
pos := serve.logsList.VerticalScrollBar().SliderPosition()
serve.logsList.InsertItem(0, qt.NewQListWidgetItem2(fmt.Sprintf("%s %s", timestamp, msg)))
if pos == 0 {
serve.logsList.ScrollToTop()
}
})
}
func calculateDirSize(path string) int64 {
var size int64
filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
info, err := d.Info()
if err == nil {
size += info.Size()
}
}
return nil
})
return size
}
func getHeadCommit(repoPath string) string {
cmd := exec.Command("git", "log", "--oneline", "-1")
cmd.Dir = repoPath
out, err := cmd.Output()
if err != nil {
return "no commits"
}
return strings.TrimSpace(string(out))
}
func (serve *serveVars) updateGraspReposList() {
mainthread.Start(func() {
serve.graspReposList.list.Clear()
if serve.repoDir == "" {
return
}
entries, err := os.ReadDir(serve.repoDir)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
d := entry.Name()
repoPath := filepath.Join(serve.repoDir, d)
size := calculateDirSize(repoPath)
head := getHeadCommit(repoPath)
item := qt.NewQListWidgetItem2(fmt.Sprintf("d: %s\npath: %s\nsize: %d bytes\nhead: %s", d, repoPath, size, head))
serve.graspReposList.list.AddItemWithItem(item)
}
})
}
func (serve *serveVars) updateBlossomBlobsList() {
mainthread.Start(func() {
serve.blossomBlobsList.list.Clear()
for key, value := range serve.blobStore.Range {
item := qt.NewQListWidgetItem2(fmt.Sprintf("%s (%d bytes)", key, len(value)))
serve.blossomBlobsList.list.AddItemWithItem(item)
}
})
}
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()
for evt := range serve.db.QueryEvents(nostr.Filter{}, 5000) {
evtj, _ := easyjson.Marshal(evt)
item := qt.NewQListWidgetItem2(string(evtj))
serve.eventsList.AddItemWithItem(item)
}
})
}