Skip to content

Commit

Permalink
feat(examples): add r/morgan/{home,guestbook} (#2345)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
thehowl authored Sep 12, 2024
1 parent 30e4a59 commit 41e0085
Show file tree
Hide file tree
Showing 6 changed files with 300 additions and 0 deletions.
25 changes: 25 additions & 0 deletions examples/gno.land/r/morgan/guestbook/admin.gno
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions examples/gno.land/r/morgan/guestbook/gno.mod
Original file line number Diff line number Diff line change
@@ -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
)
126 changes: 126 additions & 0 deletions examples/gno.land/r/morgan/guestbook/guestbook.gno
Original file line number Diff line number Diff line change
@@ -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("<p style='text-align:right'><a href='./guestbook:" + lastID.String() + "'>Next page</a></p>")
}

return bld.String()
}
131 changes: 131 additions & 0 deletions examples/gno.land/r/morgan/guestbook/guestbook_test.gno
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions examples/gno.land/r/morgan/home/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/morgan/home
10 changes: 10 additions & 0 deletions examples/gno.land/r/morgan/home/home.gno
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 41e0085

Please sign in to comment.