diff --git a/.tool-versions b/.tool-versions index 49aa265..8dc011f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -golang 1.24.4 +golang 1.25.9 diff --git a/realm/post.gno b/realm/post.gno index eeb1b2e..9077512 100644 --- a/realm/post.gno +++ b/realm/post.gno @@ -7,6 +7,7 @@ import ( "gno.land/p/moul/txlink/v0" "gno.land/p/nt/bptree/v0" + "gno.land/p/nt/markdown/sanitize/v0" "gno.land/p/nt/ufmt/v0" ) @@ -71,7 +72,7 @@ func (post *Post) GetPostID() PostID { return post.id } -func (post *Post) AddReply(creator address, body string) *Post { +func (post *Post) addReply(creator address, body string) *Post { userPosts := post.userPosts pid := userPosts.incGetPostID() pidkey := postIDKey(pid) @@ -86,7 +87,7 @@ func (post *Post) AddReply(creator address, body string) *Post { return reply } -func (post *Post) AddRepostTo(creator address, comment string, dst *UserPosts) *Post { +func (post *Post) addRepostTo(creator address, comment string, dst *UserPosts) *Post { if !post.IsThread() { panic("cannot repost non-thread post") } @@ -116,7 +117,7 @@ func (post *Post) GetReply(pid PostID) *Post { // If userAddr is already added, do nothing. // If the userAddr is the post's creator, do nothing. (Don't react to one's own posts.) // Return a boolean indicating whether the userAddr was added (false if it was already added). -func (post *Post) AddReaction(userAddr address, reaction Reaction) bool { +func (post *Post) addReaction(userAddr address, reaction Reaction) bool { validateReaction(reaction) if userAddr == post.creator { @@ -136,7 +137,7 @@ func (post *Post) AddReaction(userAddr address, reaction Reaction) bool { // Remove the userAddr from the posts.reactions for reaction. // If userAddr is already removed, do nothing. // Return a boolean indicating whether the userAddr was found and removed. -func (post *Post) RemoveReaction(userAddr address, reaction Reaction) bool { +func (post *Post) removeReaction(userAddr address, reaction Reaction) bool { validateReaction(reaction) if !post.reactions.Has(reactionKey(reaction)) { @@ -170,7 +171,8 @@ func (post *Post) GetSummary() string { } func (post *Post) GetURL() string { - if post.IsThread() { + // Not IsThread(), which is false for a repost even though it is top-level. + if post.threadID == post.id { return post.userPosts.GetURLFromThreadAndReplyID( post.id, 0) } else { @@ -211,10 +213,10 @@ func (post *Post) RenderSummary() string { if thread == nil { return "reposted post does not exist" } - return "Repost: " + post.GetSummary() + "\n\n" + thread.RenderSummary() + return "Repost: " + sanitize.InlineText(post.GetSummary()) + "\n\n" + thread.RenderSummary() } str := "" - str += post.GetSummary() + "\n" + str += sanitize.InlineText(post.GetSummary()) + "\n" str += "\\- " + displayAddressMD(post.creator) + "," str += " [" + post.createdAt.Format("2006-01-02 3:04pm MST") + "](" + post.GetURL() + ")" str += " (" + strconv.Itoa(post.GetReactionCount(Gnod)) + " gnods)" @@ -228,7 +230,7 @@ func (post *Post) RenderPost(indent string, levels int) string { return "nil post" } str := "" - str += indentBody(indent, post.body) + "\n" // TODO: indent body lines. + str += indentBody(indent, sanitize.Block(post.body)) + "\n" // TODO: indent body lines. str += indent + "\\- " + displayAddressMD(post.creator) + ", " str += "[" + post.createdAt.Format("2006-01-02 3:04pm (MST)") + "](" + post.GetURL() + ")" str += " - (" + strconv.Itoa(post.GetReactionCount(Gnod)) + " gnods) - " @@ -238,6 +240,9 @@ func (post *Post) RenderPost(indent string, levels int) string { str += " \\[[repost](" + post.GetRepostFormURL() + ")]" } str += "\n" + // TODO: This renders every reply, recursively for `levels` levels, with no pagination, and + // post bodies have no length limit. Should paginate them, and cap the body length. + // See renderThread in r/gnoland/boards2/v0/render_thread.gno . if levels > 0 { if post.replies.Size() > 0 { post.replies.ReverseIterate("", "", func(key string, value interface{}) bool { @@ -294,6 +299,10 @@ func (post *Post) MarshalJSON() ([]byte, error) { if post.repostUser != "" { json.WriteString(ufmt.Sprintf(`, "repost_user": %s`, strconv.Quote(post.repostUser.String()))) } + // TODO: strconv.Quote is not a JSON encoder. For control characters it emits Go escapes + // like \x01, \a and \v which are invalid JSON, so JSON.parse rejects the whole response. + // Since anyone can reply to any thread, one such reply breaks that thread in the mobile + // app. This goes away when we switch to vm/qeval_json. json.WriteString(ufmt.Sprintf(`, "body": %s}`, strconv.Quote(post.body))) return json.Bytes(), nil diff --git a/realm/public.gno b/realm/public.gno index c4a950f..61c2994 100644 --- a/realm/public.gno +++ b/realm/public.gno @@ -18,9 +18,12 @@ type UserAndPostID struct { // Return the "thread ID" of the new post. // (This is similar to boards.CreateThread, but no message title) func PostMessage(cur realm, body string) PostID { + if !cur.IsCurrent() { + panic("spoofed realm") + } caller := cur.Previous().Address() userPosts := getOrCreateUserPosts(caller, usernameOf(caller)) - thread := userPosts.AddThread(body) + thread := userPosts.addThread(body) return thread.id } @@ -31,6 +34,9 @@ func PostMessage(cur realm, body string) PostID { // Return the new post ID. // (This is similar to boards.CreateReply.) func PostReply(cur realm, userPostsAddr address, threadid, postid PostID, body string) PostID { + if !cur.IsCurrent() { + panic("spoofed realm") + } caller := cur.Previous().Address() userPosts := getUserPosts(userPostsAddr) if userPosts == nil { @@ -41,14 +47,14 @@ func PostReply(cur realm, userPostsAddr address, threadid, postid PostID, body s panic("threadid in user posts does not exist") } if postid == threadid { - reply := thread.AddReply(caller, body) + reply := thread.addReply(caller, body) return reply.id } else { post := thread.GetReply(postid) if post == nil { panic("postid does not exist") } - reply := post.AddReply(caller, body) + reply := post.addReply(caller, body) return reply.id } } @@ -58,6 +64,9 @@ func PostReply(cur realm, userPostsAddr address, threadid, postid PostID, body s // Return the new post ID. // (This is similar to boards.CreateRepost.) func RepostThread(cur realm, userPostsAddr address, threadid PostID, comment string) PostID { + if !cur.IsCurrent() { + panic("spoofed realm") + } caller := cur.Previous().Address() if userPostsAddr == caller { panic("Cannot repost a user's own message") @@ -73,7 +82,7 @@ func RepostThread(cur realm, userPostsAddr address, threadid PostID, comment str if thread == nil { panic("threadid in user posts does not exist") } - repost := thread.AddRepostTo(caller, comment, dstUserPosts) + repost := thread.addRepostTo(caller, comment, dstUserPosts) return repost.id } @@ -119,7 +128,7 @@ func GetJsonTopPostsByID(addrAndIDs []UserAndPostID) string { // If threadID is X and replyID is 0, then return the posts (without replies) in that thread. (Like render args "user/2".) // If threadID is X and replyID is Y, then return the posts in the thread starting with replyID. (Like render args "user/2/5".) // The response includes reposts by this user (only if threadID is 0), but not messages of other -// users that are being followed. (See GetHomePosts.) The response is a JSON string. +// users that are being followed. (See GetJsonHomePosts.) The response is a JSON string. func GetThreadPosts(userPostsAddr address, threadID int, replyID int, startIndex int, endIndex int) string { userPosts := getUserPosts(userPostsAddr) if userPosts == nil { @@ -160,22 +169,21 @@ func RefreshHomePosts(_ realm, userPostsAddr address) int { return userPosts.homePosts.Size() } -// Get the number of posts which GetHomePosts or GetJsonHomePosts will return. +// Get the number of posts which GetJsonHomePosts will return. // The result is something like "(12 int)". // This returns the current count of the home posts (without need to pay gas). To include the // latest followed posts, call RefreshHomePosts. func GetHomePostsCount(userPostsAddr address) int { - return GetHomePosts(userPostsAddr).Size() + return getHomePosts(userPostsAddr).Size() } // Get home posts for a user, which are the user's top-level posts plus all posts of all // users being followed. // The response is a map of postID -> *Post. The bptree.BPTree sorts by the post ID which is // unique for every post and increases in time. -// If you just want the total count, use GetHomePostsCount. -// This returns the current state of the home posts (without need to pay gas). To include the -// latest followed posts, call RefreshHomePosts. -func GetHomePosts(userPostsAddr address) *bptree.BPTree { +// This must not be exported. Another realm holding this pointer could mutate the tree (and +// the posts in it) with this realm's authority. +func getHomePosts(userPostsAddr address) *bptree.BPTree { userPosts := getUserPosts(userPostsAddr) if userPosts == nil { panic("posts for userPostsAddr do not exist") @@ -183,7 +191,7 @@ func GetHomePosts(userPostsAddr address) *bptree.BPTree { return &userPosts.homePosts } -// Get home posts for a user (using GetHomePosts), which are the user's top-level posts plus all +// Get home posts for a user, which are the user's top-level posts plus all // posts of all users being followed. // Limit the response to posts from startIndex up to (not including) endIndex within the home posts. // If you just want the total count, use GetHomePostsCount. @@ -191,7 +199,7 @@ func GetHomePosts(userPostsAddr address) *bptree.BPTree { // This returns the current state of the home posts (without need to pay gas). To include the // latest posts, call RefreshHomePosts. func GetJsonHomePosts(userPostsAddr address, startIndex int, endIndex int) string { - allPosts := GetHomePosts(userPostsAddr) + allPosts := getHomePosts(userPostsAddr) postsJson := "" for i := startIndex; i < endIndex && i < allPosts.Size(); i++ { _, postI := allPosts.GetByIndex(i) @@ -209,8 +217,11 @@ func GetJsonHomePosts(userPostsAddr address, startIndex int, endIndex int) strin return ufmt.Sprintf("{\"n_posts\": %d, \"posts\": [\n %s]}", allPosts.Size(), postsJson) } -// Update the caller to follow the user with followedAddr. See UserPosts.Follow. +// Update the caller to follow the user with followedAddr. See UserPosts.follow. func Follow(cur realm, followedAddr address) PostID { + if !cur.IsCurrent() { + panic("spoofed realm") + } caller := cur.Previous().Address() if followedAddr == caller { panic("you can't follow yourself") @@ -218,11 +229,14 @@ func Follow(cur realm, followedAddr address) PostID { // A user can follow someone before doing any posts, so create the UserPosts if needed. userPosts := getOrCreateUserPosts(caller, usernameOf(caller)) - return userPosts.Follow(followedAddr) + return userPosts.follow(followedAddr) } -// Update the caller to unfollow the user with followedAddr. See UserPosts.Unfollow. +// Update the caller to unfollow the user with followedAddr. See UserPosts.unfollow. func Unfollow(cur realm, followedAddr address) { + if !cur.IsCurrent() { + panic("spoofed realm") + } caller := cur.Previous().Address() userPosts := getUserPosts(caller) if userPosts == nil { @@ -230,7 +244,7 @@ func Unfollow(cur realm, followedAddr address) { return } - userPosts.Unfollow(followedAddr) + userPosts.unfollow(followedAddr) } // Add the reaction by the caller to the post of userPostsAddr, where threadid is the ID @@ -238,8 +252,11 @@ func Unfollow(cur realm, followedAddr address) { // to a top-level post for the threadid, otherwise add the reaction to the postid "sub reply". // (This function's arguments are similar to PostReply.) // The caller must already be registered with /r/gnoland/users/v1 Register. -// Return a boolean indicating whether the userAddr was added. See Post.AddReaction. +// Return a boolean indicating whether the userAddr was added. See Post.addReaction. func AddReaction(cur realm, userPostsAddr address, threadid, postid PostID, reaction Reaction) bool { + if !cur.IsCurrent() { + panic("spoofed realm") + } caller := cur.Previous().Address() userPosts := getUserPosts(userPostsAddr) if userPosts == nil { @@ -250,13 +267,13 @@ func AddReaction(cur realm, userPostsAddr address, threadid, postid PostID, reac panic("threadid in user posts does not exist") } if postid == threadid { - return thread.AddReaction(caller, reaction) + return thread.addReaction(caller, reaction) } else { post := thread.GetReply(postid) if post == nil { panic("postid does not exist") } - return post.AddReaction(caller, reaction) + return post.addReaction(caller, reaction) } } @@ -265,8 +282,11 @@ func AddReaction(cur realm, userPostsAddr address, threadid, postid PostID, reac // from a top-level post for the threadid, otherwise remove the reaction from the postid "sub reply". // (This function's arguments are similar to PostReply.) // The caller must already be registered with /r/gnoland/users/v1 Register. -// Return a boolean indicating whether the userAddr was removed. See Post.RemoveReaction. +// Return a boolean indicating whether the userAddr was removed. See Post.removeReaction. func RemoveReaction(cur realm, userPostsAddr address, threadid, postid PostID, reaction Reaction) bool { + if !cur.IsCurrent() { + panic("spoofed realm") + } caller := cur.Previous().Address() userPosts := getUserPosts(userPostsAddr) if userPosts == nil { @@ -277,13 +297,13 @@ func RemoveReaction(cur realm, userPostsAddr address, threadid, postid PostID, r panic("threadid in user posts does not exist") } if postid == threadid { - return thread.RemoveReaction(caller, reaction) + return thread.removeReaction(caller, reaction) } else { post := thread.GetReply(postid) if post == nil { panic("postid does not exist") } - return post.RemoveReaction(caller, reaction) + return post.removeReaction(caller, reaction) } } diff --git a/realm/render.gno b/realm/render.gno index b9636ef..75cd1d8 100644 --- a/realm/render.gno +++ b/realm/render.gno @@ -4,6 +4,7 @@ import ( "strconv" "strings" + "gno.land/p/nt/markdown/sanitize/v0" "gno.land/r/sys/users" ) @@ -37,7 +38,7 @@ func Render(path string) string { userAddr := resolveAddr(path) userPosts := getUserPosts(userAddr) if userPosts == nil { - return "No posts by: " + path + return "No posts by: " + sanitize.InlineText(path) } return userPosts.RenderUserPosts(false) @@ -45,7 +46,7 @@ func Render(path string) string { userAddr := resolveAddr(parts[0]) userPosts := getUserPosts(userAddr) if userPosts == nil { - return "No posts by: " + parts[0] + return "No posts by: " + sanitize.InlineText(parts[0]) } if parts[1] == "home" { @@ -61,11 +62,11 @@ func Render(path string) string { // /r/berty/social:USER_NAME_OR_ADDR/THREAD_ID pid, err := strconv.Atoi(parts[1]) if err != nil { - return "invalid thread id: " + parts[1] + return "invalid thread id: " + sanitize.InlineText(parts[1]) } thread := userPosts.GetThread(PostID(pid)) if thread == nil { - return "thread does not exist with id: " + parts[1] + return "thread does not exist with id: " + sanitize.InlineText(parts[1]) } return thread.RenderPost("", 5) } @@ -74,26 +75,26 @@ func Render(path string) string { userAddr := resolveAddr(parts[0]) userPosts := getUserPosts(userAddr) if userPosts == nil { - return "No posts by: " + parts[0] + return "No posts by: " + sanitize.InlineText(parts[0]) } pid, err := strconv.Atoi(parts[1]) if err != nil { - return "invalid thread id: " + parts[1] + return "invalid thread id: " + sanitize.InlineText(parts[1]) } thread := userPosts.GetThread(PostID(pid)) if thread == nil { - return "thread does not exist with id: " + parts[1] + return "thread does not exist with id: " + sanitize.InlineText(parts[1]) } rid, err := strconv.Atoi(parts[2]) if err != nil { - return "invalid reply id: " + parts[2] + return "invalid reply id: " + sanitize.InlineText(parts[2]) } reply := thread.GetReply(PostID(rid)) if reply == nil { - return "reply does not exist with id: " + parts[2] + return "reply does not exist with id: " + sanitize.InlineText(parts[2]) } return reply.RenderInner() } else { - return "unrecognized path: " + path + return "unrecognized path: " + sanitize.InlineText(path) } } diff --git a/realm/social_test.gno b/realm/social_test.gno index 4b6fec9..10e788c 100644 --- a/realm/social_test.gno +++ b/realm/social_test.gno @@ -14,14 +14,14 @@ func TestPostLookups(t *testing.T) { if userPosts.GetThread(1) != nil { t.Fatal("unexpected thread in an empty user feed") } - thread := userPosts.AddThread("Hello") + thread := userPosts.addThread("Hello") if userPosts.GetThread(thread.id) != thread { t.Fatal("stored thread was not found") } if thread.GetReply(thread.id+1) != nil { t.Fatal("unexpected reply in an empty thread") } - reply := thread.AddReply(addr, "Reply") + reply := thread.addReply(addr, "Reply") if thread.GetReply(reply.id) != reply { t.Fatal("stored reply was not found") } @@ -29,20 +29,20 @@ func TestPostLookups(t *testing.T) { func TestReactionLookups(t *testing.T) { userPosts := newUserPosts("/r/berty/social:alice", address("alice")) - thread := userPosts.AddThread("Hello") + thread := userPosts.addThread("Hello") if thread.GetReactionCount(Gnod) != 0 { t.Fatal("unexpected reactions on a new post") } - if !thread.AddReaction(address("bob"), Gnod) { + if !thread.addReaction(address("bob"), Gnod) { t.Fatal("first reaction was not added") } - if thread.AddReaction(address("bob"), Gnod) { + if thread.addReaction(address("bob"), Gnod) { t.Fatal("duplicate reaction was added") } if thread.GetReactionCount(Gnod) != 1 { t.Fatal("stored reaction was not counted") } - if !thread.RemoveReaction(address("bob"), Gnod) || thread.GetReactionCount(Gnod) != 0 { + if !thread.removeReaction(address("bob"), Gnod) || thread.GetReactionCount(Gnod) != 0 { t.Fatal("reaction was not removed") } } diff --git a/realm/userposts.gno b/realm/userposts.gno index 204c973..e65e439 100644 --- a/realm/userposts.gno +++ b/realm/userposts.gno @@ -58,7 +58,7 @@ func (userPosts *UserPosts) GetThread(pid PostID) *Post { } // Add a new top-level thread to the userPosts. Return the new Post. -func (userPosts *UserPosts) AddThread(body string) *Post { +func (userPosts *UserPosts) addThread(body string) *Post { pid := userPosts.incGetPostID() pidkey := postIDKey(pid) thread := newPost(userPosts, pid, userPosts.userAddr, body, pid, 0, "") @@ -74,7 +74,7 @@ func (userPosts *UserPosts) AddThread(body string) *Post { // If there is no UserPosts for followedAddr, then do nothing and return 0. (We don't expect // this because this is usually called by clicking on the display page of followedAddr.) // Return the value of startedPostsCtr in the added FollowingInfo. -func (userPosts *UserPosts) Follow(followedAddr address) PostID { +func (userPosts *UserPosts) follow(followedAddr address) PostID { if userPosts.following.Has(followedAddr.String()) { // Already following. return PostID(0) @@ -96,7 +96,7 @@ func (userPosts *UserPosts) Follow(followedAddr address) PostID { // Remove followedAddr from following. // If there is a UserPosts for followedAddr, then remove this user from its followers. // If there is no UserPosts for followedAddr, then do nothing. (We don't expect this usually.) -func (userPosts *UserPosts) Unfollow(followedAddr address) { +func (userPosts *UserPosts) unfollow(followedAddr address) { userPosts.following.Remove(followedAddr.String()) followedUserPosts := getUserPosts(followedAddr)