Add noVerify flag to SaveEvent to prevent duplicate events and enhance test reliability

- pkg/database/save-event.go
  - Added `noVerify` parameter to `SaveEvent` function
  - Added check for existing event using `GetSerialById` when `noVerify` is false
  - Modified logic to handle event verification based on `noVerify` flag
- pkg/app/relay/server-publish.go
  - Added `false` as third argument to `SaveEvent` calls
- pkg/database/export_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/database/query-for-tags_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/database/query-for-kinds-authors_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/database/query-for-kinds-tags_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/database/query-for-serials_test.go
  - Added `false` as third argument to `SaveEvent` call
- main.go
  - Modified pprof handling to support different profiling types (cpu, memory, allocation)
  - Changed `Pprof` configuration from boolean to string with enum values
- pkg/app/config/config.go
  - Changed `Pprof` field type from `bool` to `string` with enum values
- pkg/database/query-for-kinds-authors-tags_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/version/version
  - Bumped version from v0.2.12 to v0.2.13
- pkg/database/fetch-event-by-serial_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/database/query-for-kinds_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/database/get-serials-by-range_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/database/query-events-multiple-param-replaceable_test.go
  - Added `false` as third argument to `SaveEvent` calls
- pkg/database/query-events_test.go
  - Added `false` as third argument to `SaveEvent` calls
- pkg/interfaces/store/store_interface.go
  - Updated `Saver` interface to include `noVerify` parameter in `SaveEvent` method
  - Added `SerialByIder` interface with `GetSerialById` method
- pkg/database/save-event_test.go
  - Added `false` as third argument to `SaveEvent` calls
  - Added new test case for saving existing event
- pkg/database/query-for-ids_test.go
  - Added `false` as third argument to `SaveEvent` call
- pkg/protocol/ws/client.go
  - Changed comment about context cancellation from "context is canceled" to "context is cancelled"
- pkg/app/relay/spider-fetch.go
  - Added signature checker for WebSocket connections
  - Modified logic to check for existing events before saving
  - Added logging and memory optimization improvements
This commit is contained in:
2025-07-23 13:51:02 +01:00
parent b72f2dd51e
commit eac5e05e77
25 changed files with 166 additions and 61 deletions

29
main.go
View File

@@ -1,28 +1,26 @@
// Package main is a nostr relay with a simple follow/mute list authentication
// scheme and the new HTTP REST based protocol. Configuration is via environment
// scheme and the new HTTP REST-based protocol. Configuration is via environment
// variables or an optional .env file.
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"orly.dev/pkg/protocol/openapi"
"orly.dev/pkg/protocol/servemux"
"os"
"github.com/pkg/profile"
_ "net/http/pprof"
app2 "orly.dev/pkg/app"
"orly.dev/pkg/app/config"
"orly.dev/pkg/app/relay"
"orly.dev/pkg/app/relay/options"
"orly.dev/pkg/database"
"orly.dev/pkg/protocol/openapi"
"orly.dev/pkg/protocol/servemux"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/interrupt"
"orly.dev/pkg/utils/log"
"orly.dev/pkg/utils/lol"
"orly.dev/pkg/version"
"os"
)
func main() {
@@ -45,11 +43,18 @@ func main() {
os.Exit(0)
}
lol.SetLogLevel(cfg.LogLevel)
if cfg.Pprof {
defer profile.Start(profile.MemProfile).Stop()
go func() {
chk.E(http.ListenAndServe("127.0.0.1:6060", nil))
}()
if cfg.Pprof != "" {
switch cfg.Pprof {
case "cpu":
prof := profile.Start(profile.CPUProfile)
defer prof.Stop()
case "memory":
prof := profile.Start(profile.MemProfile)
defer prof.Stop()
case "allocation":
prof := profile.Start(profile.MemProfileAllocs)
defer prof.Stop()
}
}
c, cancel := context.Cancel(context.Bg())
var storage *database.D

View File

@@ -34,7 +34,7 @@ type C struct {
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
DbLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
Pprof bool `env:"ORLY_PPROF" default:"false" usage:"enable pprof on 127.0.0.1:6060"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof on 127.0.0.1:6060" enum:"cpu,memory,allocation"`
AuthRequired bool `env:"ORLY_AUTH_REQUIRED" default:"false" usage:"require authentication for all requests"`
PublicReadable bool `env:"ORLY_PUBLIC_READABLE" default:"true" usage:"allow public read access to regardless of whether the client is authed"`
SpiderSeeds []string `env:"ORLY_SPIDER_SEEDS" usage:"seeds to use for the spider (relays that are looked up initially to find owner relay lists) (comma separated)" default:"wss://relay.nostr.band/,wss://relay.damus.io/,wss://nostr.wine/,wss://nostr.land/,wss://theforest.nostr1.com/"`

View File

@@ -88,7 +88,7 @@ func (s *Server) Publish(c context.T, evt *event.E) (err error) {
}
if isFollowed {
if _, _, err = sto.SaveEvent(
c, evt,
c, evt, false,
); err != nil && !errors.Is(
err, store.ErrDupEvent,
) {
@@ -110,7 +110,7 @@ func (s *Server) Publish(c context.T, evt *event.E) (err error) {
for _, pk := range owners {
if bytes.Equal(evt.Pubkey, pk) {
if _, _, err = sto.SaveEvent(
c, evt,
c, evt, false,
); err != nil && !errors.Is(
err, store.ErrDupEvent,
) {
@@ -222,7 +222,7 @@ func (s *Server) Publish(c context.T, evt *event.E) (err error) {
}
}
}
if _, _, err = sto.SaveEvent(c, evt); err != nil && !errors.Is(
if _, _, err = sto.SaveEvent(c, evt, false); err != nil && !errors.Is(
err, store.ErrDupEvent,
) {
return

View File

@@ -1,7 +1,9 @@
package relay
import (
"fmt"
"orly.dev/pkg/crypto/ec/schnorr"
"orly.dev/pkg/database/indexes/types"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/filter"
"orly.dev/pkg/encoders/hex"
@@ -10,7 +12,9 @@ import (
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/errorf"
"orly.dev/pkg/utils/log"
"orly.dev/pkg/utils/lol"
"runtime/debug"
)
@@ -126,7 +130,11 @@ func (s *Server) SpiderFetch(
var evss event.S
var cli *ws.Client
if cli, err = ws.RelayConnect(
context.Bg(), seed,
context.Bg(), seed, ws.WithSignatureChecker(
func(e *event.E) bool {
return true
},
),
); chk.E(err) {
err = nil
return
@@ -148,14 +156,37 @@ func (s *Server) SpiderFetch(
// If it doesn't exist or the new event is newer, store it and save to database
if !exists || ev.CreatedAtInt64() > existing.Timestamp {
var ser *types.Uint40
if ser, err = s.Storage().GetSerialById(ev.Id); err == nil && ser != nil {
err = errorf.E("event already exists: %0x", ev.Id)
return
} else {
// verify the signature
var valid bool
if valid, err = ev.Verify(); chk.E(err) || !valid {
continue
}
log.I.F("event %0x is valid", ev.Id)
}
// Save the event to the database
log.I.F("saving event:\n%s", ev.Marshal(nil))
if _, _, err = s.Storage().SaveEvent(
s.Ctx, ev,
s.Ctx, ev, true, // already verified
); chk.E(err) {
err = nil
continue
}
if lol.Level.Load() == lol.Trace {
log.T.C(
func() string {
return fmt.Sprintf(
"saved event:\n%s", ev.Marshal(nil),
)
},
)
} else {
log.I.F("saved event: %0x", ev.Id)
}
// Store the essential information
pkKindMap[pkKindKey] = &IdPkTs{
@@ -186,12 +217,11 @@ func (s *Server) SpiderFetch(
// Nil the event in the slice to free memory
evss[i] = nil
}
chk.E(s.Storage().Sync())
debug.FreeOSMemory()
}
}
}
chk.E(s.Storage().Sync())
debug.FreeOSMemory()
// If we're in noExtract mode, just return
if noExtract {

View File

@@ -55,7 +55,7 @@ func TestExport(t *testing.T) {
}
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event: %v", err)
}

View File

@@ -56,7 +56,7 @@ func TestFetchEventBySerial(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -53,7 +53,7 @@ func TestGetSerialById(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -60,7 +60,7 @@ func TestGetSerialsByRange(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -56,7 +56,7 @@ func (d *D) Import(rr io.Reader) {
continue
}
if _, _, err = d.SaveEvent(d.ctx, ev); err != nil {
if _, _, err = d.SaveEvent(d.ctx, ev, false); err != nil {
continue
}

View File

@@ -45,7 +45,7 @@ func TestMultipleParameterizedReplaceableEvents(t *testing.T) {
baseEvent.Sign(sign)
// Save the base parameterized replaceable event
if _, _, err := db.SaveEvent(ctx, baseEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, baseEvent, false); err != nil {
t.Fatalf("Failed to save base parameterized replaceable event: %v", err)
}
@@ -63,7 +63,7 @@ func TestMultipleParameterizedReplaceableEvents(t *testing.T) {
newerEvent.Sign(sign)
// Save the newer parameterized replaceable event
if _, _, err := db.SaveEvent(ctx, newerEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, newerEvent, false); err != nil {
t.Fatalf(
"Failed to save newer parameterized replaceable event: %v", err,
)
@@ -83,7 +83,7 @@ func TestMultipleParameterizedReplaceableEvents(t *testing.T) {
newestEvent.Sign(sign)
// Save the newest parameterized replaceable event
if _, _, err := db.SaveEvent(ctx, newestEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, newestEvent, false); err != nil {
t.Fatalf(
"Failed to save newest parameterized replaceable event: %v", err,
)

View File

@@ -62,7 +62,7 @@ func setupTestDB(t *testing.T) (
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}
@@ -202,7 +202,7 @@ func TestReplaceableEventsAndDeletion(t *testing.T) {
replaceableEvent.Tags = tags.New()
replaceableEvent.Sign(sign)
// Save the replaceable event
if _, _, err := db.SaveEvent(ctx, replaceableEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, replaceableEvent, false); err != nil {
t.Fatalf("Failed to save replaceable event: %v", err)
}
@@ -216,7 +216,7 @@ func TestReplaceableEventsAndDeletion(t *testing.T) {
newerEvent.Tags = tags.New()
newerEvent.Sign(sign)
// Save the newer event
if _, _, err := db.SaveEvent(ctx, newerEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, newerEvent, false); err != nil {
t.Fatalf("Failed to save newer event: %v", err)
}
@@ -293,7 +293,7 @@ func TestReplaceableEventsAndDeletion(t *testing.T) {
)
// Save the deletion event
if _, _, err = db.SaveEvent(ctx, deletionEvent); err != nil {
if _, _, err = db.SaveEvent(ctx, deletionEvent, false); err != nil {
t.Fatalf("Failed to save deletion event: %v", err)
}
@@ -379,7 +379,7 @@ func TestParameterizedReplaceableEventsAndDeletion(t *testing.T) {
paramEvent.Sign(sign)
// Save the parameterized replaceable event
if _, _, err := db.SaveEvent(ctx, paramEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, paramEvent, false); err != nil {
t.Fatalf("Failed to save parameterized replaceable event: %v", err)
}
@@ -405,7 +405,7 @@ func TestParameterizedReplaceableEventsAndDeletion(t *testing.T) {
paramDeletionEvent.Sign(sign)
// Save the parameterized deletion event
if _, _, err := db.SaveEvent(ctx, paramDeletionEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, paramDeletionEvent, false); err != nil {
t.Fatalf("Failed to save parameterized deletion event: %v", err)
}
@@ -438,7 +438,7 @@ func TestParameterizedReplaceableEventsAndDeletion(t *testing.T) {
paramDeletionEvent2.Sign(sign)
// Save the parameterized deletion event with e-tag
if _, _, err := db.SaveEvent(ctx, paramDeletionEvent2); err != nil {
if _, _, err := db.SaveEvent(ctx, paramDeletionEvent2, false); err != nil {
t.Fatalf(
"Failed to save parameterized deletion event with e-tag: %v", err,
)

View File

@@ -57,7 +57,7 @@ func TestQueryForAuthorsTags(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -56,7 +56,7 @@ func TestQueryForCreatedAt(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -60,7 +60,7 @@ func TestQueryForIds(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -58,7 +58,7 @@ func TestQueryForKindsAuthorsTags(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -58,7 +58,7 @@ func TestQueryForKindsAuthors(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -58,7 +58,7 @@ func TestQueryForKindsTags(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -57,7 +57,7 @@ func TestQueryForKinds(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -60,7 +60,7 @@ func TestQueryForSerials(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -57,7 +57,7 @@ func TestQueryForTags(t *testing.T) {
events = append(events, ev)
// Save the event to the database
if _, _, err = db.SaveEvent(ctx, ev); err != nil {
if _, _, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}

View File

@@ -20,11 +20,17 @@ import (
)
// SaveEvent saves an event to the database, generating all the necessary indexes.
func (d *D) SaveEvent(c context.T, ev *event.E) (kc, vc int, err error) {
// Get a buffer from the pool
buf := new(bytes.Buffer)
// Marshal the event to binary
ev.MarshalBinary(buf)
func (d *D) SaveEvent(c context.T, ev *event.E, noVerify bool) (
kc, vc int, err error,
) {
if !noVerify {
// check if the event already exists
var ser *types.Uint40
if ser, err = d.GetSerialById(ev.Id); err == nil && ser != nil {
err = errorf.E("event already exists: %0x", ev.Id)
return
}
}
// check if an existing delete event references this event submission
if ev.Kind.IsParameterizedReplaceable() {
@@ -59,7 +65,7 @@ func (d *D) SaveEvent(c context.T, ev *event.E) (kc, vc int, err error) {
// stable value but refers to any event from the author, of the
// kind, with the identifier. so we need to fetch the full ID index
// to get the timestamp and ensure that the event post-dates it.
// otherwise it should be rejected.
// otherwise, it should be rejected.
var idPkTss []*store.IdPkTs
for _, ser := range sers {
var fidpk *store.IdPkTs
@@ -88,7 +94,6 @@ func (d *D) SaveEvent(c context.T, ev *event.E) (kc, vc int, err error) {
}
} else {
var idxs []Range
// log.I.S(ev.Pubkey)
if idxs, err = GetIndexesFromFilter(
&filter.F{
Authors: tag.New(ev.Pubkey),
@@ -98,7 +103,6 @@ func (d *D) SaveEvent(c context.T, ev *event.E) (kc, vc int, err error) {
); chk.E(err) {
return
}
// log.I.S(idxs)
var sers types.Uint40s
for _, idx := range idxs {
var s types.Uint40s

View File

@@ -64,7 +64,7 @@ func TestSaveEvents(t *testing.T) {
// Save the event to the database
var k, v int
if k, v, err = db.SaveEvent(ctx, ev); err != nil {
if k, v, err = db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event #%d: %v", eventCount+1, err)
}
kc += k
@@ -125,7 +125,7 @@ func TestDeletionEventWithETagRejection(t *testing.T) {
regularEvent.Sign(sign)
// Save the regular event
if _, _, err := db.SaveEvent(ctx, regularEvent); err != nil {
if _, _, err := db.SaveEvent(ctx, regularEvent, false); err != nil {
t.Fatalf("Failed to save regular event: %v", err)
}
@@ -146,7 +146,7 @@ func TestDeletionEventWithETagRejection(t *testing.T) {
deletionEvent.Sign(sign)
// Try to save the deletion event, it should be rejected
_, _, err = db.SaveEvent(ctx, deletionEvent)
_, _, err = db.SaveEvent(ctx, deletionEvent, false)
if err == nil {
t.Fatal("Expected deletion event with e-tag to be rejected, but it was accepted")
}
@@ -154,6 +154,66 @@ func TestDeletionEventWithETagRejection(t *testing.T) {
// Verify the error message
expectedError := "deletion events referencing other events with 'e' tag are not allowed"
if err.Error() != expectedError {
t.Fatalf("Expected error message '%s', got '%s'", expectedError, err.Error())
t.Fatalf(
"Expected error message '%s', got '%s'", expectedError, err.Error(),
)
}
}
// TestSaveExistingEvent tests that attempting to save an event that already exists
// returns an error.
func TestSaveExistingEvent(t *testing.T) {
// Create a temporary directory for the database
tempDir, err := os.MkdirTemp("", "test-db-*")
if err != nil {
t.Fatalf("Failed to create temporary directory: %v", err)
}
defer os.RemoveAll(tempDir) // Clean up after the test
// Create a context and cancel function for the database
ctx, cancel := context.Cancel(context.Bg())
defer cancel()
// Initialize the database
db, err := New(ctx, cancel, tempDir, "info")
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
// Create a signer
sign := new(p256k.Signer)
if err := sign.Generate(); chk.E(err) {
t.Fatal(err)
}
// Create an event
ev := event.New()
ev.Kind = kind.TextNote // Kind 1 is a text note
ev.Pubkey = sign.Pub()
ev.CreatedAt = new(timestamp.T)
ev.CreatedAt.V = timestamp.Now().V
ev.Content = []byte("Test event")
ev.Tags = tags.New()
ev.Sign(sign)
// Save the event for the first time
if _, _, err := db.SaveEvent(ctx, ev, false); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Try to save the same event again, it should be rejected
_, _, err = db.SaveEvent(ctx, ev, false)
if err == nil {
t.Fatal("Expected error when saving an existing event, but got nil")
}
// Verify the error message
expectedErrorPrefix := "event already exists: "
if !bytes.HasPrefix([]byte(err.Error()), []byte(expectedErrorPrefix)) {
t.Fatalf(
"Expected error message to start with '%s', got '%s'",
expectedErrorPrefix, err.Error(),
)
}
}

View File

@@ -9,6 +9,7 @@ package store
import (
"io"
"orly.dev/pkg/app/config"
"orly.dev/pkg/database/indexes/types"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/eventid"
"orly.dev/pkg/encoders/eventidserial"
@@ -33,6 +34,7 @@ type I interface {
LogLeveler
EventIdSerialer
Initer
SerialByIder
}
type Initer interface {
@@ -81,7 +83,7 @@ type Deleter interface {
type Saver interface {
// SaveEvent is called once relay.AcceptEvent reports true.
SaveEvent(c context.T, ev *event.E) (kc, vc int, err error)
SaveEvent(c context.T, ev *event.E, noVerify bool) (kc, vc int, err error)
}
type Importer interface {
@@ -129,3 +131,7 @@ type EventIdSerialer interface {
err error,
)
}
type SerialByIder interface {
GetSerialById(id []byte) (ser *types.Uint40, err error)
}

View File

@@ -72,7 +72,7 @@ type writeRequest struct {
}
// NewRelay returns a new relay. The relay connection will be closed when the
// context is canceled.
// context is cancelled.
func NewRelay(c context.T, url string, opts ...RelayOption) *Client {
ctx, cancel := context.Cancel(c)
r := &Client{

View File

@@ -1 +1 @@
v0.2.12
v0.2.13