README
- Mention social feed in the one-line description and feature bullets
- Add relay + feed endpoint tables to the API overview (was
previously empty on messaging)
- List media/ package in the repo structure
docs/api/
- New docs/api/feed.md: full reference for /feed/publish, fetch,
stats, view, author, timeline, trending, foryou, hashtag; all
on-chain CREATE_POST / DELETE_POST / FOLLOW / LIKE payloads;
fee economics; server-side scrubbing contract.
- docs/api/relay.md rewritten: /relay/broadcast is now the primary
E2E path with a complete envelope schema; /relay/send kept but
flagged ⚠ NOT E2E; DELETE /relay/inbox/{id} documented with the
new Ed25519 signed-auth body.
- docs/api/README.md index: added feed.md row.
docs/architecture.md
- L2 Transport layer description updated to include the feed
mailbox alongside the 1:1 relay mailbox.
- New "Социальная лента (v2.0.0)" section right after the 1:1
message flow: ASCII diagram of publish + on-chain commit +
timeline fetch, economic summary, metadata-scrub summary.
docs/node/README.md
- Removed stale chan:/chan-member: keys from the BadgerDB schema
reference; replaced with the v2.0.0 feed keys (post:,
postbyauthor:, follow:, followin:, like:, likecount:).
docs/update-system.md
- Example features[] array updated to match the actual node output
(channels_v1 removed, feed_v2 / media_scrub / relay_broadcast added).
Node feature flags
- api_well_known_version.go: dropped channels_v1 tag (the
/api/channels/:id endpoint was removed in the feed refactor);
added feed_v2, media_scrub, relay_broadcast so clients can
feature-detect the v2.0.0 surface.
- Comment example updated channels_v2/v1 → feed_v3/v2.
Client
- CLIENT_REQUIRED_FEATURES expanded to include the v2.0.0 feature
flags the client now depends on (feed_v2, media_scrub,
relay_broadcast); checkNodeVersion() will flag older nodes as
unsupported and surface an upgrade prompt.
All 7 Go test packages green; tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
Markdown
280 lines
11 KiB
Markdown
# Feed API (v2.0.0)
|
||
|
||
REST API для социальной ленты: публикация постов, лента подписок,
|
||
рекомендации, трендинг, хэштеги. Введено в **v2.0.0**, заменило
|
||
channel-модель.
|
||
|
||
## Архитектура
|
||
|
||
Лента устроена гибридно — **метаданные on-chain**, **тела постов
|
||
off-chain** в relay feed-mailbox:
|
||
|
||
```
|
||
┌─────────────────────┐ ┌──────────────────────────┐
|
||
│ on-chain state │ │ feed mailbox (BadgerDB)│
|
||
│ │ │ │
|
||
│ post:<id> → │ │ feedpost:<id> → │
|
||
│ {author, size, │ POST │ {content, attachment, │
|
||
│ content_hash, │ /feed/publish │ hashtags, type, ts} │
|
||
│ hosting_relay, │───────────────────►│ │
|
||
│ reply_to?, │ │ feedview:<id> → │
|
||
│ deleted?} │ │ uint64 (counter) │
|
||
│ │ │ │
|
||
│ follow:<A>:<B> │ │ feedtag:<tag>:<ts>:<id> │
|
||
│ like:<post>:<liker>│ │ (hashtag index) │
|
||
│ likecount:<post> │ │ │
|
||
└─────────────────────┘ └──────────────────────────┘
|
||
```
|
||
|
||
Разделение решает два противоречия: on-chain-состояние обеспечивает
|
||
provable авторство + экономические стимулы (оплата за байт); off-chain
|
||
тела позволяют хранить мегабайты картинок без раздувания истории
|
||
блоков. Hosting relay получает плату автора (через CREATE_POST tx);
|
||
другие ноды могут реплицировать через gossipsub (роадмап).
|
||
|
||
## Оплата постов
|
||
|
||
Автор поста платит `BasePostFee + Size × PostByteFee` µT за публикацию.
|
||
Полный fee кредитуется на баланс hosting-релея.
|
||
|
||
| Константа | Значение | Примечание |
|
||
|-----------|----------|-----------|
|
||
| `BasePostFee` | 1 000 µT (0.001 T) | Совпадает с MinFee (block-validation floor) |
|
||
| `PostByteFee` | 1 µT/byte | Размер = контент + attachment + ~128 B метаданных |
|
||
| `MaxPostSize` | 256 KiB | Hard cap, больше — 413 на `/feed/publish` |
|
||
|
||
Пример:
|
||
- 200-байтовый текстовый пост → ~1 328 µT (~0.0013 T)
|
||
- Текст + 30 KiB WebP-картинка → ~31 848 µT (~0.032 T)
|
||
- Видео 200 KiB → ~205 128 µT (~0.21 T)
|
||
|
||
## Server-side metadata scrubbing
|
||
|
||
**Обязательная** для всех аттачментов на `/feed/publish`:
|
||
|
||
- **Изображения** (JPEG/PNG/GIF/WebP): decode → downscale до 1080 px
|
||
→ re-encode как JPEG Q=75 через stdlib. EXIF/GPS/ICC/XMP/MakerNote
|
||
стираются by construction (stdlib JPEG encoder не пишет их).
|
||
- **Видео/аудио**: переадресуется во внешний **FFmpeg-сайдкар**
|
||
(контейнер `docker/media-sidecar/`). Если сайдкар не настроен через
|
||
`DCHAIN_MEDIA_SIDECAR_URL` и оператор не выставил
|
||
`DCHAIN_ALLOW_UNSCRUBBED_VIDEO`, видео-аплоад отклоняется с 503.
|
||
- **MIME mismatch**: если magic-байты не соответствуют заявленному
|
||
`Content-Type` — 400. Защита от PDF'а, замаскированного под картинку.
|
||
|
||
## Endpoints
|
||
|
||
### `POST /feed/publish`
|
||
|
||
Загружает тело поста, запускает скраббинг, сохраняет в feed-mailbox,
|
||
возвращает метаданные для последующей on-chain CREATE_POST tx.
|
||
|
||
**Request:**
|
||
```json
|
||
{
|
||
"post_id": "<hex 16 B>",
|
||
"author": "<ed25519_hex>",
|
||
"content": "Text of the post",
|
||
"content_type": "text/plain",
|
||
"attachment_b64": "<base64 image/video/audio>",
|
||
"attachment_mime": "image/jpeg",
|
||
"reply_to": "<optional parent post_id>",
|
||
"quote_of": "<optional quoted post_id>",
|
||
"sig": "<base64 Ed25519 sig>",
|
||
"ts": 1710000000
|
||
}
|
||
```
|
||
|
||
`sig = Ed25519.Sign(author_priv, "publish:<post_id>:<raw_content_hash_hex>:<ts>")`
|
||
|
||
где `raw_content_hash = sha256(content + raw_attachment_bytes)` —
|
||
клиентский хэш **до** серверного скраба.
|
||
|
||
`ts` в пределах ±5 минут от серверного времени (anti-replay).
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"post_id": "<echo>",
|
||
"hosting_relay": "<ed25519_hex of this node>",
|
||
"content_hash": "<hex sha256 of post-scrub bytes>",
|
||
"size": 1234,
|
||
"hashtags": ["golang", "dchain"],
|
||
"estimated_fee_ut": 2234
|
||
}
|
||
```
|
||
|
||
Клиент затем собирает CREATE_POST tx с этими полями и сабмитит на
|
||
`/api/tx`. См. `buildCreatePostTx` в клиентской библиотеке.
|
||
|
||
### `GET /feed/post/{id}`
|
||
|
||
Тело поста со статами.
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"post_id": "...",
|
||
"author": "...",
|
||
"content": "...",
|
||
"content_type": "text/plain",
|
||
"hashtags": ["golang"],
|
||
"created_at": 1710000000,
|
||
"reply_to": "",
|
||
"quote_of": "",
|
||
"attachment": "<base64 bytes>",
|
||
"attachment_mime": "image/jpeg"
|
||
}
|
||
```
|
||
|
||
**Errors:** 404 если не найден; 410 если on-chain soft-deleted.
|
||
|
||
### `GET /feed/post/{id}/attachment`
|
||
|
||
Сырые байты аттачмента с корректным `Content-Type`. Используется
|
||
нативным image-loader'ом клиента:
|
||
|
||
```html
|
||
<img src="http://node/feed/post/abc/attachment" />
|
||
```
|
||
|
||
`Cache-Control: public, max-age=3600, immutable` — content-addressed
|
||
ресурс, кэш безопасен.
|
||
|
||
### `POST /feed/post/{id}/view`
|
||
|
||
Инкремент off-chain счётчика просмотров. Fire-and-forget, не требует
|
||
подписи (per-view tx был бы нереалистичен).
|
||
|
||
**Response:** `{"post_id": "...", "views": 42}`
|
||
|
||
### `GET /feed/post/{id}/stats?me=<pub>`
|
||
|
||
Агрегат для отображения. `me` опционален — если передан, в ответе
|
||
будет `liked_by_me`.
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"post_id": "...",
|
||
"views": 127,
|
||
"likes": 42,
|
||
"liked_by_me": true
|
||
}
|
||
```
|
||
|
||
### `GET /feed/author/{pub}?limit=N&before=<ts>`
|
||
|
||
Посты конкретного автора, newest-first.
|
||
|
||
Пагинация: передавайте `before=<created_at>` самого старого уже
|
||
загруженного поста, чтобы получить следующую страницу.
|
||
|
||
**Response:**
|
||
```json
|
||
{"author": "<pub>", "count": 20, "posts": [FeedPostItem]}
|
||
```
|
||
|
||
### `GET /feed/timeline?follower=<pub>&limit=N&before=<ts>`
|
||
|
||
Лента подписок: merged посты всех, на кого подписан follower, newest-
|
||
first. Требует, чтобы on-chain FOLLOW-транзакции уже скоммитились.
|
||
|
||
Пагинация через `before` идентично `/feed/author`.
|
||
|
||
### `GET /feed/trending?window=24&limit=N`
|
||
|
||
Топ постов за последние N часов, ранжированы по `likes × 3 + views`.
|
||
|
||
`window` — часы (1..168), по умолчанию 24.
|
||
|
||
Пагинация **не поддерживается** — это топ-срез, не список.
|
||
|
||
### `GET /feed/foryou?pub=<pub>&limit=N`
|
||
|
||
Рекомендации. Простая эвристика v2.0.0:
|
||
|
||
1. Кандидаты: посты последних 48 часов
|
||
2. Исключить: авторы, на которых уже подписан; уже лайкнутые; свои
|
||
3. Ранжировать: `likes × 3 + views + 1` (seed чтобы свежее вылезало)
|
||
|
||
Никаких ML — база для `v2.2.0 Feed algorithm` (half-life decay,
|
||
mutual-follow boost, hashtag-affinity).
|
||
|
||
### `GET /feed/hashtag/{tag}?limit=N`
|
||
|
||
Посты, помеченные хэштегом `#tag`. Tag case-insensitive.
|
||
Хэштеги авто-индексируются на `/feed/publish` из текста (regex
|
||
`#[A-Za-z0-9_\p{L}]{1,40}`, dedup, cap 8 per post).
|
||
|
||
## On-chain события
|
||
|
||
Клиент сабмитит эти транзакции через обычный `/api/tx` endpoint.
|
||
|
||
### `CREATE_POST`
|
||
```json
|
||
{
|
||
"type": "CREATE_POST",
|
||
"fee": <estimated_fee_ut>,
|
||
"payload": {
|
||
"post_id": "...",
|
||
"content_hash": "<base64 32-byte sha256>",
|
||
"size": 1234,
|
||
"hosting_relay": "<ed25519_hex>",
|
||
"reply_to": "",
|
||
"quote_of": ""
|
||
}
|
||
}
|
||
```
|
||
|
||
Валидации on-chain: `size > 0 && size <= MaxPostSize`,
|
||
`fee >= BasePostFee + size × PostByteFee`, `reply_to` и `quote_of`
|
||
взаимно исключают друг друга. Дубликат `post_id` отклоняется.
|
||
|
||
### `DELETE_POST`
|
||
```json
|
||
{"type": "DELETE_POST", "fee": 1000, "payload": {"post_id": "..."}}
|
||
```
|
||
Только автор может удалить. Soft-delete (post остаётся в chain, но
|
||
помечен `deleted=true`; читатели получают 410).
|
||
|
||
### `FOLLOW` / `UNFOLLOW`
|
||
```json
|
||
{"type": "FOLLOW", "to": "<target_ed25519>", "fee": 1000, "payload": {}}
|
||
```
|
||
Двусторонний on-chain индекс: `follow:<A>:<B>` + `followin:<B>:<A>`.
|
||
`UNFOLLOW` зеркально удаляет оба ключа.
|
||
|
||
### `LIKE_POST` / `UNLIKE_POST`
|
||
```json
|
||
{"type": "LIKE_POST", "fee": 1000, "payload": {"post_id": "..."}}
|
||
```
|
||
Дубль-лайк отклоняется. Кэшированный counter `likecount:<post>`
|
||
хранит O(1) счётчик.
|
||
|
||
## Pricing / economics (резюме)
|
||
|
||
| Операция | Cost | Куда уходит |
|
||
|----------|------|-------------|
|
||
| `CREATE_POST` | 1000 + size×1 µT | Hosting relay |
|
||
| `DELETE_POST` | 1000 µT | Block validator |
|
||
| `FOLLOW` / `UNFOLLOW` | 1000 µT | Block validator |
|
||
| `LIKE_POST` / `UNLIKE_POST` | 1000 µT | Block validator |
|
||
| `/feed/post/{id}/view` | 0 | Off-chain counter |
|
||
|
||
Причина такого разделения: основные бизнес-действия (публикация) едут
|
||
на «хостера контента», а мелкие социальные tx (лайк/подписка) — на
|
||
валидатора сети, как обычные tx.
|
||
|
||
## Клиентские вспомогательные функции
|
||
|
||
В `client-app/lib/feed.ts`:
|
||
- `publishPost(params)` — подпись + POST /feed/publish
|
||
- `publishAndCommit(params)` — publish + CREATE_POST tx в одну операцию
|
||
- `fetchTimeline`, `fetchForYou`, `fetchTrending`, `fetchAuthorPosts`,
|
||
`fetchHashtag`, `fetchPost`, `fetchStats`, `bumpView`
|
||
- `buildCreatePostTx`, `buildDeletePostTx`, `buildFollowTx`,
|
||
`buildUnfollowTx`, `buildLikePostTx`, `buildUnlikePostTx`
|
||
- `likePost`, `unlikePost`, `followUser`, `unfollowUser`, `deletePost`
|
||
(build + submitTx bundled)
|