Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(examples): add r/morgan/{home,guestbook} #2345

Merged
merged 7 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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() {
thehowl marked this conversation as resolved.
Show resolved Hide resolved
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
}
Loading