feat(feed): relay body storage + HTTP endpoints (Phase B of v2.0.0)

Phase A (the previous commit) added the on-chain foundations. Phase B
is the off-chain layer: post bodies live in a BadgerDB-backed feed
mailbox, and a full HTTP surface makes the feed usable from clients.

New components

  relay/feed_mailbox.go (+ tests)
    - FeedPost: body + content-type + attachment + hashtags + thread refs
    - Store / Get / Delete with TTL-bounded eviction (30 days default)
    - View counter (IncrementView / ViewCount) — off-chain because one
      tx per view would be nonsense
    - Hashtag inverted index: auto-extracts #tokens from content on
      Store, lowercased + deduped + capped at 8/post
    - Author chrono index: PostsByAuthor returns newest-first IDs
    - RecentPostIDs: scan-by-age helper used by trending/foryou

  node/api_feed.go
    POST /feed/publish           — author-signed body upload, returns
                                   post_id + content_hash + size +
                                   hashtags + estimated fee for the
                                   follow-up on-chain CREATE_POST tx
    GET  /feed/post/{id}         — fetch body (respects on-chain soft
                                   delete, returns 410 when deleted)
    GET  /feed/post/{id}/stats   — {views, likes, liked_by_me?}
    POST /feed/post/{id}/view    — bump the counter
    GET  /feed/author/{pub}      — chain-authoritative post list
                                   enriched with body + stats
    GET  /feed/timeline          — merged feed from people the user
                                   follows (reads chain.Following,
                                   fetches each author's recent posts)
    GET  /feed/trending          — top-scored posts in last 24h
                                   (score = likes × 3 + views)
    GET  /feed/foryou            — simple recommendations: recent posts
                                   minus authors the user already
                                   follows, already-liked posts, and
                                   own posts; ranked by engagement
    GET  /feed/hashtag/{tag}     — posts tagged with the given #tag

  cmd/node/main.go wiring
    - --feed-db flag (DCHAIN_FEED_DB) + --feed-ttl-days (DCHAIN_FEED_TTL_DAYS)
    - Opens FeedMailbox + registers FeedRoutes alongside RelayRoutes
    - Threads chain.Post / LikeCount / HasLiked / PostsByAuthor / Following
      into FeedConfig so HTTP handlers can merge on-chain metadata with
      off-chain body+stats.

Auth & safety
  - POST /feed/publish: Ed25519 signature over "publish:<post_id>:
    <content_sha256_hex>:<ts>"; ±5-minute skew window for anti-replay.
  - content_hash binds body to the on-chain tx — you can't publish
    body-A off-chain and commit hash-of-body-B on-chain.
  - Writes wrapped in withSubmitTxGuards (rate-limit + size cap), reads
    in withReadLimit — same guards as /relay.

Trending / recommendations
  - V1 heuristic (likes × 3 + views) + time window. Documented as
    v2.2.0 "Feed algorithm" candidate for a proper ranking layer
    (half-life decay, follow-of-follow boost, hashtag collaborative).

Tests
  - Store round-trip, size enforcement, hashtag indexing (case-insensitive
    + dedup), view counter increments, author chrono order, delete
    cleans all indices, RecentPostIDs time-window filter.
  - Full go test ./... is green (blockchain + consensus + identity +
    relay + vm all pass).

Next (Phase C): client Feed tab — composer, timeline, post detail,
profile follow, For You + Trending screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 18:52:22 +03:00
parent 88848efa63
commit 126658f294
4 changed files with 1305 additions and 0 deletions

198
relay/feed_mailbox_test.go Normal file
View File

@@ -0,0 +1,198 @@
package relay
import (
"os"
"testing"
"time"
)
func newTestFeedMailbox(t *testing.T) *FeedMailbox {
t.Helper()
dir, err := os.MkdirTemp("", "dchain-feedtest-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
fm, err := OpenFeedMailbox(dir, 24*time.Hour)
if err != nil {
_ = os.RemoveAll(dir)
t.Fatalf("OpenFeedMailbox: %v", err)
}
t.Cleanup(func() {
_ = fm.Close()
for i := 0; i < 20; i++ {
if err := os.RemoveAll(dir); err == nil {
return
}
time.Sleep(10 * time.Millisecond)
}
})
return fm
}
// TestFeedMailboxStoreAndGet: store round-trips content + metadata.
func TestFeedMailboxStoreAndGet(t *testing.T) {
fm := newTestFeedMailbox(t)
post := &FeedPost{
PostID: "p1",
Author: "authorhex",
Content: "Hello #world from #dchain",
}
tags, err := fm.Store(post, 12345)
if err != nil {
t.Fatalf("Store: %v", err)
}
wantTags := []string{"world", "dchain"}
if len(tags) != len(wantTags) {
t.Fatalf("Store returned %v, want %v", tags, wantTags)
}
for i := range wantTags {
if tags[i] != wantTags[i] {
t.Errorf("Store tag[%d]: got %q, want %q", i, tags[i], wantTags[i])
}
}
got, err := fm.Get("p1")
if err != nil || got == nil {
t.Fatalf("Get: got %v err=%v", got, err)
}
if got.Content != post.Content {
t.Errorf("content: got %q, want %q", got.Content, post.Content)
}
if got.CreatedAt != 12345 {
t.Errorf("created_at: got %d, want 12345", got.CreatedAt)
}
if len(got.Hashtags) != 2 {
t.Errorf("hashtags: got %v, want [world dchain]", got.Hashtags)
}
}
// TestFeedMailboxTooLarge: rejects over-quota content.
func TestFeedMailboxTooLarge(t *testing.T) {
fm := newTestFeedMailbox(t)
big := make([]byte, MaxPostBodySize+1)
post := &FeedPost{
PostID: "big1",
Author: "a",
Attachment: big,
}
if _, err := fm.Store(post, 0); err != ErrPostTooLarge {
t.Fatalf("Store huge post: got %v, want ErrPostTooLarge", err)
}
}
// TestFeedMailboxHashtagIndex: hashtags are searchable + dedup + case-normalised.
func TestFeedMailboxHashtagIndex(t *testing.T) {
fm := newTestFeedMailbox(t)
p1 := &FeedPost{PostID: "p1", Author: "a", Content: "post about #Go"}
p2 := &FeedPost{PostID: "p2", Author: "b", Content: "Also #go programming"}
p3 := &FeedPost{PostID: "p3", Author: "a", Content: "#Rust too"}
if _, err := fm.Store(p1, 1000); err != nil {
t.Fatal(err)
}
if _, err := fm.Store(p2, 2000); err != nil {
t.Fatal(err)
}
if _, err := fm.Store(p3, 3000); err != nil {
t.Fatal(err)
}
// #go is case-insensitive, should return both posts newest-first.
ids, err := fm.PostsByHashtag("#Go", 10)
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 || ids[0] != "p2" || ids[1] != "p1" {
t.Errorf("PostsByHashtag(Go): got %v, want [p2 p1]", ids)
}
ids, _ = fm.PostsByHashtag("rust", 10)
if len(ids) != 1 || ids[0] != "p3" {
t.Errorf("PostsByHashtag(rust): got %v, want [p3]", ids)
}
}
// TestFeedMailboxViewCounter: increments + reads.
func TestFeedMailboxViewCounter(t *testing.T) {
fm := newTestFeedMailbox(t)
fm.Store(&FeedPost{PostID: "p", Author: "a", Content: "hi"}, 10)
for i := 1; i <= 5; i++ {
n, err := fm.IncrementView("p")
if err != nil {
t.Fatal(err)
}
if n != uint64(i) {
t.Errorf("IncrementView #%d: got %d, want %d", i, n, i)
}
}
if n, _ := fm.ViewCount("p"); n != 5 {
t.Errorf("ViewCount: got %d, want 5", n)
}
}
// TestFeedMailboxByAuthor: author chrono index returns newest first.
func TestFeedMailboxByAuthor(t *testing.T) {
fm := newTestFeedMailbox(t)
fm.Store(&FeedPost{PostID: "old", Author: "a", Content: "one"}, 100)
fm.Store(&FeedPost{PostID: "new", Author: "a", Content: "two"}, 500)
fm.Store(&FeedPost{PostID: "mid", Author: "a", Content: "three"}, 300)
fm.Store(&FeedPost{PostID: "other", Author: "b", Content: "four"}, 400)
ids, err := fm.PostsByAuthor("a", 10)
if err != nil {
t.Fatal(err)
}
want := []string{"new", "mid", "old"}
if len(ids) != len(want) {
t.Fatalf("len: got %d, want %d (%v)", len(ids), len(want), ids)
}
for i := range want {
if ids[i] != want[i] {
t.Errorf("pos %d: got %q want %q", i, ids[i], want[i])
}
}
}
// TestFeedMailboxDelete: removes body + indices.
func TestFeedMailboxDelete(t *testing.T) {
fm := newTestFeedMailbox(t)
fm.Store(&FeedPost{PostID: "x", Author: "a", Content: "doomed #go"}, 100)
if err := fm.Delete("x"); err != nil {
t.Fatalf("Delete: %v", err)
}
if got, _ := fm.Get("x"); got != nil {
t.Errorf("Get after delete: got %v, want nil", got)
}
if ids, _ := fm.PostsByHashtag("go", 10); len(ids) != 0 {
t.Errorf("hashtag index: got %v, want []", ids)
}
if ids, _ := fm.PostsByAuthor("a", 10); len(ids) != 0 {
t.Errorf("author index: got %v, want []", ids)
}
}
// TestFeedMailboxRecentIDs: filters by window, sorts newest first.
func TestFeedMailboxRecentIDs(t *testing.T) {
fm := newTestFeedMailbox(t)
now := time.Now().Unix()
// p1 1 hour old, p2 5 hours old, p3 50 hours old.
fm.Store(&FeedPost{PostID: "p1", Author: "a", Content: "a"}, now-3600)
fm.Store(&FeedPost{PostID: "p2", Author: "b", Content: "b"}, now-5*3600)
fm.Store(&FeedPost{PostID: "p3", Author: "c", Content: "c"}, now-50*3600)
// 6-hour window: p1 and p2 only.
ids, err := fm.RecentPostIDs(6*3600, 100)
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 {
t.Errorf("RecentPostIDs(6h): got %v, want 2 posts", ids)
}
// Newest first.
if ids[0] != "p1" {
t.Errorf("first post: got %s, want p1", ids[0])
}
}