diff --git a/examples/gno.land/r/mouss/config/config.gno b/examples/gno.land/r/mouss/config/config.gno new file mode 100644 index 00000000000..8f613c21aaf --- /dev/null +++ b/examples/gno.land/r/mouss/config/config.gno @@ -0,0 +1,47 @@ +package config + +import ( + "errors" + "std" +) + +var ( + main std.Address // mouss's main address + backup std.Address // backup address + + ErrorInvalidAddr = errors.New("config: invalid address") + ErrorUnauthorized = errors.New("config: unauthorized") +) + +func init() { + main = "g1hrfvdh7jdvnlxpk2y20tp3scj9jqal3zzu7wjz" +} + +func Address() std.Address { + return main +} + +func Backup() std.Address { + return backup +} + +func SetBackup(newAddress std.Address) error { + if !newAddress.IsValid() { + return ErrorInvalidAddr + } + + if err := checkAuthorized(); err != nil { + return err + } + + backup = newAddress + return nil +} + +func checkAuthorized() error { + caller := std.GetOrigCaller() + if caller != main && caller != backup { + return ErrorUnauthorized + } + return nil +} diff --git a/examples/gno.land/r/mouss/config/config_test.gno b/examples/gno.land/r/mouss/config/config_test.gno new file mode 100644 index 00000000000..3219c153906 --- /dev/null +++ b/examples/gno.land/r/mouss/config/config_test.gno @@ -0,0 +1,63 @@ +package config + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" +) + +var ( + mainAddr = Address() + backupAddr = testutils.TestAddress("backup") + unauthorizedAddr = testutils.TestAddress("unauthorized") +) + +func TestAddress(t *testing.T) { + addr := Address() + expected := std.Address("g1hrfvdh7jdvnlxpk2y20tp3scj9jqal3zzu7wjz") + uassert.Equal(t, expected, addr, "Address() should return initialized main address") +} + +func TestSetBackup(t *testing.T) { + // Test setting backup as main address + std.TestSetOrigCaller(mainAddr) + validAddr := testutils.TestAddress("validbackup") + err := SetBackup(validAddr) + uassert.NoError(t, err, "main address should be able to set backup") + + // Test setting invalid address format + err = SetBackup(std.Address("invalid")) + uassert.ErrorIs(t, err, ErrorInvalidAddr, "should reject invalid address format") + + // Test setting empty address + err = SetBackup(std.Address("")) + uassert.ErrorIs(t, err, ErrorInvalidAddr, "should reject empty address") + + // Test unauthorized caller + std.TestSetOrigCaller(unauthorizedAddr) + err = SetBackup(validAddr) + uassert.ErrorIs(t, err, ErrorUnauthorized, "should reject unauthorized caller") +} + +func TestCheckAuthorized(t *testing.T) { + // Test main address authorization + std.TestSetOrigCaller(mainAddr) + err := checkAuthorized() + uassert.NoError(t, err, "main address should be authorized") + + // Test unauthorized address + std.TestSetOrigCaller(unauthorizedAddr) + err = checkAuthorized() + uassert.ErrorIs(t, err, ErrorUnauthorized, "random address should not be authorized") + + // Set and test backup address authorization + std.TestSetOrigCaller(mainAddr) + err = SetBackup(backupAddr) + uassert.NoError(t, err, "setting backup address should succeed") + + std.TestSetOrigCaller(backupAddr) + err = checkAuthorized() + uassert.NoError(t, err, "backup address should be authorized") +} diff --git a/examples/gno.land/r/mouss/config/gno.mod b/examples/gno.land/r/mouss/config/gno.mod new file mode 100644 index 00000000000..33b71de8f2c --- /dev/null +++ b/examples/gno.land/r/mouss/config/gno.mod @@ -0,0 +1 @@ +module gno.land/r/mouss/config diff --git a/examples/gno.land/r/mouss/home/gno.mod b/examples/gno.land/r/mouss/home/gno.mod new file mode 100644 index 00000000000..a4ebfa34d16 --- /dev/null +++ b/examples/gno.land/r/mouss/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/mouss/home diff --git a/examples/gno.land/r/mouss/home/home.gno b/examples/gno.land/r/mouss/home/home.gno new file mode 100644 index 00000000000..7c289fa2095 --- /dev/null +++ b/examples/gno.land/r/mouss/home/home.gno @@ -0,0 +1,221 @@ +package home + +import ( + "std" + "strconv" + "strings" + + "gno.land/p/demo/mux" + "gno.land/p/demo/ufmt" + + "gno.land/p/moul/addrset" + "gno.land/p/moul/md" + "gno.land/r/leon/hof" + "gno.land/r/mouss/config" +) + +// Profile represents my personal profile information. +type Profile struct { + AboutMe string + Avatar string + Email string + Github string + LinkedIn string + Followers *addrset.Set // Set of followers addresses. +} + +// Recipe represents a cooking recipe with its details. +type Recipe struct { + Name string + Origin string + Author std.Address + Ingredients string + Instructions string + Tips string +} + +const ( + RealmURL = "/r/mouss/home" + Rec = RealmURL + ":recipe/" + gnoArt = ` + -==++. + *@@@@= @- -@ + #@@@@@: -==-.-- :-::===: .-++-. @- .===:.- .-.-==- .===:=@ + #@@@@@@@: -@@%**%@@ #@@#*#@@- *@@**@@* @- +%=::-*@ +@=-:-@* +%=::-*@ + +@%#**#%@@ %@+ :@@ *@+ #@=+@% %@+ @= :@: -@ +% +%.@: -@ + -: - *@%:..+@@ *@+ #@=-@@: :@@= @- .@= =@ +@ *%.@= =@ + --:==+=-:=. =%@%#*@@ *@+ #@+ =%@%%@%= #* %#=.:%*===*@ +% +% -%*===*@ + -++++=++++. =-:::*@# . . .::. .. :: .:: . . .:: . + .-=+++=: .*###%#= + :: +` +) + +var ( + router = mux.NewRouter() + profile Profile + recipes []*Recipe + margheritaPizza *Recipe +) + +// init initializes the router with the home page and recipe routes +// sets up my profile information, and my recipe +// and registers the home page in the hall of fame. +func init() { + router.HandleFunc("", renderHomepage) + router.HandleFunc("recipe/", renderRecipes) + profile = Profile{ + AboutMe: "👋 I'm Mustapha, a contributor to gno.land project from France. I'm passionate about coding, exploring new technologies, and contributing to open-source projects. Besides my tech journey, I'm also a pizzaiolo 🍕 who loves cooking and savoring good food.", + Avatar: "https://github.com/mous1985/assets/blob/master/avatar.png?raw=true", + Email: "mustapha.benazzouz@outlook.fr", + Github: "https://github.com/mous1985", + LinkedIn: "https://www.linkedin.com/in/mustapha-benazzouz-88646887/", + Followers: &addrset.Set{}, + } + margheritaPizza = &Recipe{ + Name: "Authentic Margherita Pizza 🤌", + Origin: "Naples, 🇮🇹", + Author: config.Address(), + Ingredients: " 1kg 00 flour\n 500ml water\n 3g fresh yeast\n 20g sea salt\n San Marzano tomatoes\n Fresh buffalo mozzarella\n Fresh basil\n Extra virgin olive oil", + Instructions: " Mix flour and water until incorporated\n Add yeast and salt, knead for 20 minutes\n Let rise for 2 hours at room temperature\n Divide into 250g balls\n Cold ferment for 24-48 hours\n Shape by hand, being gentle with the dough\n Top with crushed tomatoes, torn mozzarella, and basil\n Cook at 450°C for 60-90 seconds", + Tips: "Use a pizza steel or stone preheated for at least 1 hour. The dough should be soft and extensible. For best results, cook in a wood-fired oven.", + } + hof.Register() +} + +// AddRecipe adds a new recipe in recipe page by users +func AddRecipe(name, origin, ingredients, instructions, tips string) string { + if err := validateRecipe(name, ingredients, instructions); err != nil { + panic(err) + } + recipe := &Recipe{ + Name: name, + Origin: origin, + Author: std.PrevRealm().Addr(), + Ingredients: ingredients, + Instructions: instructions, + Tips: tips, + } + recipes = append(recipes, recipe) + return "Recipe added successfully!" +} + +// validateRecipe checks if the provided recipe details are valid. +func validateRecipe(name, ingredients, instructions string) error { + if name == "" { + return ufmt.Errorf("recipe name cannot be empty") + } + if len(ingredients) == 0 { + return ufmt.Errorf("ingredients cannot be empty") + } + if len(instructions) == 0 { + return ufmt.Errorf("instructions cannot be empty") + } + return nil +} + +// Follow allows a users to follow my home page. +// It checks if the caller is a valid user and if the address is already being followed. +// If the caller is not authorized, it returns an error. +// If the address is already being followed, it returns an error. +// Otherwise, it adds the address to the list of followers and returns nil. +func Follow(addr std.Address) error { + caller := std.PrevRealm().Addr() + if !isUser(caller) { + return config.ErrorUnauthorized + } + if profile.Followers.Has(addr) { + return ufmt.Errorf("address %s is already following", addr) + } + profile.Followers.Add(addr) //can't add the same address twice + return nil +} + +// Unfollow allows a user to unfollow my home page. +func Unfollow(addr std.Address) error { + caller := std.PrevRealm().Addr() + if !isUser(caller) { + return config.ErrorUnauthorized + } + if !profile.Followers.Has(addr) { + return ufmt.Errorf("address %s is not following", addr) + } + profile.Followers.Remove(addr) + return nil +} + +func isUser(addr std.Address) bool { + return !isAuthorized(addr) +} + +func isAuthorized(addr std.Address) bool { + return addr == config.Address() || addr == config.Backup() +} + +func renderRecipes(res *mux.ResponseWriter, req *mux.Request) { + var b strings.Builder + b.WriteString("## World Kitchen\n\n------\n\n") + b.WriteString(margheritaPizza.Render()) + if len(recipes) == 0 { + b.WriteString("No recipes yet. Be the first to add one!\n") + res.Write(b.String()) + return + } + for _, recipe := range recipes { + b.WriteString(recipe.Render()) + } + res.Write(b.String()) +} + +func (r Recipe) Render() string { + var out string + out += md.H2(r.Name) + out += md.Bold("Author:") + "\n" + r.Author.String() + "\n\n" + out += md.Bold("Origin:") + "\n" + r.Origin + "\n\n" + out += md.Bold("Ingredients:") + "\n" + md.BulletList(strings.Split(r.Ingredients, "\n")) + "\n\n" + out += md.Bold("Instructions:") + "\n" + md.OrderedList(strings.Split(r.Instructions, "\n")) + "\n\n" + if r.Tips != "" { + out += md.Italic("💡 Tips:"+"\n"+r.Tips) + "\n\n" + } + out += md.HorizontalRule() + "\n" + return out +} + +func renderHomepage(res *mux.ResponseWriter, req *mux.Request) { + var out string + writeNavigation(&out) + out += profile.Render() + res.Write(out) +} + +func (p Profile) Render() string { + var out string + out += md.H1("Welcome to my Homepage") + "\n\n" + md.HorizontalRule() + "\n\n" + out += "```\n" + out += gnoArt + out += "```\n------" + out += md.HorizontalRule() + "\n\n" + md.H2("About Me") + "\n\n" + out += md.Image("avatar", p.Avatar) + "\n\n" + out += p.AboutMe + "\n\n" + md.HorizontalRule() + "\n\n" + out += md.H3("Contact") + "\n\n" + out += md.BulletList([]string{ + "Email: " + p.Email, + "GitHub: " + md.Link("@mous1985", p.Github), + "LinkedIn: " + md.Link("Mustapha", p.LinkedIn), + }) + out += "\n\n" + md.Bold("👤 Followers: ") + strconv.Itoa(p.Followers.Size()) + return out +} + +func writeNavigation(out *string) { + navItems := []string{ + md.Link("Home", ""), + md.Link("World Kitchen", Rec), + md.Link("Hackerspace", "https://github.com/gnolang/hackerspace/issues/86#issuecomment-2535795751"), + } + *out += strings.Join(navItems, " | ") + "\n\n" + md.HorizontalRule() + "\n\n" +} + +func Render(path string) string { + return router.Render(path) +} diff --git a/examples/gno.land/r/mouss/home/home_test.gno b/examples/gno.land/r/mouss/home/home_test.gno new file mode 100644 index 00000000000..c36eb9611d3 --- /dev/null +++ b/examples/gno.land/r/mouss/home/home_test.gno @@ -0,0 +1,97 @@ +package home + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/r/mouss/config" +) + +var ( + user1 = testutils.TestAddress("user1") + user2 = testutils.TestAddress("user2") + mainAddr = config.Address() +) + +func TestProfile(t *testing.T) { + uassert.NotEmpty(t, profile.AboutMe, "AboutMe should not be empty") + uassert.NotEmpty(t, profile.Avatar, "Avatar should not be empty") + uassert.NotEmpty(t, profile.Email, "Email should not be empty") + uassert.NotEmpty(t, profile.Github, "Github should not be empty") + uassert.NotEmpty(t, profile.LinkedIn, "LinkedIn should not be empty") +} + +func TestAddRecipe(t *testing.T) { + std.TestSetOrigCaller(user1) + name := "Test Recipe" + origin := "Test Origin" + ingredients := "Ingredient 1\nIngredient 2" + instructions := "Step 1\nStep 2" + tips := "Test Tips" + + result := AddRecipe(name, origin, ingredients, instructions, tips) + uassert.Equal(t, "Recipe added successfully!", result) + uassert.Equal(t, 1, len(recipes)) + recipe := recipes[0] + uassert.Equal(t, name, recipe.Name) + uassert.Equal(t, origin, recipe.Origin) + uassert.Equal(t, ingredients, recipe.Ingredients) + uassert.Equal(t, instructions, recipe.Instructions) + uassert.Equal(t, tips, recipe.Tips) + uassert.Equal(t, user1, recipe.Author) +} + +func TestFollow(t *testing.T) { + // Test user following admin's profile + std.TestSetOrigCaller(user1) + err := Follow(user1) + uassert.NoError(t, err, "regular user should be able to follow admin's profile") + + // Test admin trying to follow themselves + std.TestSetOrigCaller(mainAddr) + err = Follow(mainAddr) + uassert.ErrorIs(t, err, config.ErrorUnauthorized, "admin should not be able to follow their own profile") + + // Test following same address twice + std.TestSetOrigCaller(user1) + err = Follow(user1) + uassert.Error(t, err, "should not be able to follow same address twice") + + // Test multiple users following admin + std.TestSetOrigCaller(user2) + err = Follow(user2) + uassert.NoError(t, err, "another user should be able to follow admin's profile") +} + +func TestUnfollow(t *testing.T) { + // user1 is already follower + // Test successful unfollow + err := Unfollow(user1) + uassert.NoError(t, err) + uassert.False(t, profile.Followers.Has(user1)) + + // Test unfollowing when not following + err = Unfollow(user1) + uassert.Error(t, err) + + // Test unauthorized unfollow + std.TestSetOrigCaller(mainAddr) + err = Unfollow(user1) + uassert.ErrorIs(t, err, config.ErrorUnauthorized) +} + +func TestIsUser(t *testing.T) { + uassert.True(t, isUser(user1), "should recognize normal user") + uassert.False(t, isUser(mainAddr), "should not recognize admin as normal user") +} + +func TestIsAuthorized(t *testing.T) { + uassert.True(t, isAuthorized(mainAddr), "should recognize main address as authorized") + uassert.False(t, isAuthorized(user1), "should not recognize normal user as authorized") + std.TestSetOrigCaller(mainAddr) + backupAddr := testutils.TestAddress("backup") + config.SetBackup(backupAddr) + uassert.True(t, isAuthorized(backupAddr), "should recognize backup address as authorized") +}