From 41e00859adb4c981e0127161153b97d771aa8d3a Mon Sep 17 00:00:00 2001 From: Morgan Date: Thu, 12 Sep 2024 13:17:32 +0200 Subject: [PATCH] feat(examples): add `r/morgan/{home,guestbook}` (#2345) I wanted a simple realm that allows any user to interact with it and see something happen. This is a guestbook realm; it allows anyone to leave a small message, which will be associated with their address. It also is a nice showcase of how to effectively do pagination with AVL trees. I don't think we have many such examples so far. --- .../gno.land/r/morgan/guestbook/admin.gno | 25 ++++ examples/gno.land/r/morgan/guestbook/gno.mod | 7 + .../gno.land/r/morgan/guestbook/guestbook.gno | 126 +++++++++++++++++ .../r/morgan/guestbook/guestbook_test.gno | 131 ++++++++++++++++++ examples/gno.land/r/morgan/home/gno.mod | 1 + examples/gno.land/r/morgan/home/home.gno | 10 ++ 6 files changed, 300 insertions(+) create mode 100644 examples/gno.land/r/morgan/guestbook/admin.gno create mode 100644 examples/gno.land/r/morgan/guestbook/gno.mod create mode 100644 examples/gno.land/r/morgan/guestbook/guestbook.gno create mode 100644 examples/gno.land/r/morgan/guestbook/guestbook_test.gno create mode 100644 examples/gno.land/r/morgan/home/gno.mod create mode 100644 examples/gno.land/r/morgan/home/home.gno diff --git a/examples/gno.land/r/morgan/guestbook/admin.gno b/examples/gno.land/r/morgan/guestbook/admin.gno new file mode 100644 index 00000000000..fb7f9e1461c --- /dev/null +++ b/examples/gno.land/r/morgan/guestbook/admin.gno @@ -0,0 +1,25 @@ +package guestbook + +import ( + "gno.land/p/demo/ownable" + "gno.land/p/demo/seqid" +) + +var owner = ownable.New() + +// AdminDelete removes the guestbook message with the given ID. +// The user will still be marked as having submitted a message, so they +// won't be able to re-submit a new message. +func AdminDelete(signatureID string) { + owner.AssertCallerIsOwner() + + id, err := seqid.FromString(signatureID) + if err != nil { + panic(err) + } + idb := id.Binary() + if !guestbook.Has(idb) { + panic("signature does not exist") + } + guestbook.Remove(idb) +} diff --git a/examples/gno.land/r/morgan/guestbook/gno.mod b/examples/gno.land/r/morgan/guestbook/gno.mod new file mode 100644 index 00000000000..2591643d33d --- /dev/null +++ b/examples/gno.land/r/morgan/guestbook/gno.mod @@ -0,0 +1,7 @@ +module gno.land/r/morgan/guestbook + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/ownable v0.0.0-latest + gno.land/p/demo/seqid v0.0.0-latest +) diff --git a/examples/gno.land/r/morgan/guestbook/guestbook.gno b/examples/gno.land/r/morgan/guestbook/guestbook.gno new file mode 100644 index 00000000000..b3a56d88397 --- /dev/null +++ b/examples/gno.land/r/morgan/guestbook/guestbook.gno @@ -0,0 +1,126 @@ +// Realm guestbook contains an implementation of a simple guestbook. +// Come and sign yourself up! +package guestbook + +import ( + "std" + "strconv" + "strings" + "time" + "unicode" + + "gno.land/p/demo/avl" + "gno.land/p/demo/seqid" +) + +// Signature is a single entry in the guestbook. +type Signature struct { + Message string + Author std.Address + Time time.Time +} + +const ( + maxMessageLength = 140 + maxPerPage = 25 +) + +var ( + signatureID seqid.ID + guestbook avl.Tree // id -> Signature + hasSigned avl.Tree // address -> struct{} +) + +func init() { + Sign("You reached the end of the guestbook!") +} + +const ( + errNotAUser = "this guestbook can only be signed by users" + errAlreadySigned = "you already signed the guestbook!" + errInvalidCharacterInMessage = "invalid character in message" +) + +// Sign signs the guestbook, with the specified message. +func Sign(message string) { + prev := std.PrevRealm() + switch { + case !prev.IsUser(): + panic(errNotAUser) + case hasSigned.Has(prev.Addr().String()): + panic(errAlreadySigned) + } + message = validateMessage(message) + + guestbook.Set(signatureID.Next().Binary(), Signature{ + Message: message, + Author: prev.Addr(), + // NOTE: time.Now() will yield the "block time", which is deterministic. + Time: time.Now(), + }) + hasSigned.Set(prev.Addr().String(), struct{}{}) +} + +func validateMessage(msg string) string { + if len(msg) > maxMessageLength { + panic("Keep it brief! (max " + strconv.Itoa(maxMessageLength) + " bytes!)") + } + out := "" + for _, ch := range msg { + switch { + case unicode.IsLetter(ch), + unicode.IsNumber(ch), + unicode.IsSpace(ch), + unicode.IsPunct(ch): + out += string(ch) + default: + panic(errInvalidCharacterInMessage) + } + } + return out +} + +func Render(maxID string) string { + var bld strings.Builder + + bld.WriteString("# Guestbook 📝\n\n[Come sign the guestbook!](./guestbook?help&__func=Sign)\n\n---\n\n") + + var maxIDBinary string + if maxID != "" { + mid, err := seqid.FromString(maxID) + if err != nil { + panic(err) + } + + // AVL iteration is exclusive, so we need to decrease the ID value to get the "true" maximum. + mid-- + maxIDBinary = mid.Binary() + } + + var lastID seqid.ID + var printed int + guestbook.ReverseIterate("", maxIDBinary, func(key string, val interface{}) bool { + sig := val.(Signature) + message := strings.ReplaceAll(sig.Message, "\n", "\n> ") + bld.WriteString("> " + message + "\n>\n") + idValue, ok := seqid.FromBinary(key) + if !ok { + panic("invalid seqid id") + } + + bld.WriteString("> _Written by " + sig.Author.String() + " at " + sig.Time.Format(time.DateTime) + "_ (#" + idValue.String() + ")\n\n---\n\n") + lastID = idValue + + printed++ + // stop after exceeding limit + return printed >= maxPerPage + }) + + if printed == 0 { + bld.WriteString("No messages!") + } else if printed >= maxPerPage { + bld.WriteString("

Next page

") + } + + return bld.String() +} diff --git a/examples/gno.land/r/morgan/guestbook/guestbook_test.gno b/examples/gno.land/r/morgan/guestbook/guestbook_test.gno new file mode 100644 index 00000000000..b14fee45b42 --- /dev/null +++ b/examples/gno.land/r/morgan/guestbook/guestbook_test.gno @@ -0,0 +1,131 @@ +package guestbook + +import ( + "std" + "strings" + "testing" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ownable" +) + +func TestSign(t *testing.T) { + guestbook = avl.Tree{} + hasSigned = avl.Tree{} + + std.TestSetRealm(std.NewUserRealm("g1user")) + Sign("Hello!") + + std.TestSetRealm(std.NewUserRealm("g1user2")) + Sign("Hello2!") + + res := Render("") + t.Log(res) + if !strings.Contains(res, "> Hello!\n>\n> _Written by g1user ") { + t.Error("does not contain first user's message") + } + if !strings.Contains(res, "> Hello2!\n>\n> _Written by g1user2 ") { + t.Error("does not contain second user's message") + } + if guestbook.Size() != 2 { + t.Error("invalid guestbook size") + } +} + +func TestSign_FromRealm(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/demo/users")) + + defer func() { + rec := recover() + if rec == nil { + t.Fatal("expected panic") + } + recString, ok := rec.(string) + if !ok { + t.Fatal("not a string", rec) + } else if recString != errNotAUser { + t.Fatal("invalid error", recString) + } + }() + Sign("Hey!") +} + +func TestSign_Double(t *testing.T) { + // Should not allow signing twice. + guestbook = avl.Tree{} + hasSigned = avl.Tree{} + + std.TestSetRealm(std.NewUserRealm("g1user")) + Sign("Hello!") + + defer func() { + rec := recover() + if rec == nil { + t.Fatal("expected panic") + } + recString, ok := rec.(string) + if !ok { + t.Error("type assertion failed", rec) + } else if recString != errAlreadySigned { + t.Error("invalid error message", recString) + } + }() + + Sign("Hello again!") +} + +func TestSign_InvalidMessage(t *testing.T) { + // Should not allow control characters in message. + guestbook = avl.Tree{} + hasSigned = avl.Tree{} + + std.TestSetRealm(std.NewUserRealm("g1user")) + + defer func() { + rec := recover() + if rec == nil { + t.Fatal("expected panic") + } + recString, ok := rec.(string) + if !ok { + t.Error("type assertion failed", rec) + } else if recString != errInvalidCharacterInMessage { + t.Error("invalid error message", recString) + } + }() + Sign("\x00Hello!") +} + +func TestAdminDelete(t *testing.T) { + const ( + userAddr std.Address = "g1user" + adminAddr std.Address = "g1admin" + ) + + guestbook = avl.Tree{} + hasSigned = avl.Tree{} + owner = ownable.NewWithAddress(adminAddr) + signatureID = 0 + + std.TestSetRealm(std.NewUserRealm(userAddr)) + + const bad = "Very Bad Message! Nyeh heh heh!" + Sign(bad) + + if rnd := Render(""); !strings.Contains(rnd, bad) { + t.Fatal("render does not contain bad message", rnd) + } + + std.TestSetRealm(std.NewUserRealm(adminAddr)) + AdminDelete(signatureID.String()) + + if rnd := Render(""); strings.Contains(rnd, bad) { + t.Error("render contains bad message", rnd) + } + if guestbook.Size() != 0 { + t.Error("invalid guestbook size") + } + if hasSigned.Size() != 1 { + t.Error("invalid hasSigned size") + } +} diff --git a/examples/gno.land/r/morgan/home/gno.mod b/examples/gno.land/r/morgan/home/gno.mod new file mode 100644 index 00000000000..573a7e139e7 --- /dev/null +++ b/examples/gno.land/r/morgan/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/morgan/home diff --git a/examples/gno.land/r/morgan/home/home.gno b/examples/gno.land/r/morgan/home/home.gno new file mode 100644 index 00000000000..33d7e0b2df7 --- /dev/null +++ b/examples/gno.land/r/morgan/home/home.gno @@ -0,0 +1,10 @@ +package home + +const staticHome = `# morgan's (gn)home + +- [📝 sign my guestbook](/r/morgan/guestbook) +` + +func Render(path string) string { + return staticHome +}