diff --git a/README.md b/README.md index bf35c7c..e34c18b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ ## Содержание - [Быстрый старт](#быстрый-старт) +- [Поднятие ноды — пошагово](#поднятие-ноды--пошагово) - [Продакшен деплой](#продакшен-деплой) - [Архитектура](#архитектура) - [REST / WebSocket API](#rest--websocket-api) @@ -66,6 +67,160 @@ curl -s http://localhost:8080/api/well-known-version | jq . 3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md). +## Поднятие ноды — пошагово + +Ниже — полный минимум для двух сценариев, которые покрывают 99% случаев: +**первая нода сети** (genesis) и **присоединение к существующей сети**. +Все флаги читаются также из соответствующего `DCHAIN_*` env-var (CLI > env > default). + +### Шаг 1. Ключи + +```bash +# Ключ identity ноды (Ed25519 — подпись блоков + tx) +./client keygen --out keys/node.json +# relay-ключ (X25519 — E2E-mailbox) создаётся нодой сам при первом старте, +# но можно задать путь заранее через --relay-key. +``` + +### Шаг 2a. Первая нода (genesis) + +Поднимает новую сеть с одним валидатором. `--genesis=true` **только** для самой первой ноды и **только один раз** — если блок 0 уже есть в `--db`, флаг игнорируется. + +```bash +./node \ + --genesis=true \ + --key=keys/node.json \ + --db=./chaindata \ + --mailbox-db=./mailboxdata \ + --feed-db=./feeddata \ + --listen=/ip4/0.0.0.0/tcp/4001 \ + --announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \ + --stats-addr=:8080 \ + --register-relay=true \ + --relay-fee=1000 +``` + +`--announce` **обязателен** для любой ноды смотрящей в интернет (VPS / внешний IP / Docker с проброшенным портом). Без него libp2p пытается UPnP/NAT-PMP и чаще всего промахивается. + +### Шаг 2b. Вторая и последующие ноды + +Нужен **один** из двух способов узнать первую ноду. Второй удобнее. + +**Через HTTP URL живой ноды** (рекомендуется — нода сама заберёт multiaddr через `/api/network-info`, проверит genesis_hash и синхронизирует цепь): + +```bash +./node \ + --join=https://first-node.example.com \ + --key=keys/node.json \ + --db=./chaindata \ + --mailbox-db=./mailboxdata \ + --feed-db=./feeddata \ + --listen=/ip4/0.0.0.0/tcp/4001 \ + --announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \ + --stats-addr=:8080 \ + --register-relay=true \ + --relay-fee=1000 +``` + +**Через libp2p multiaddr** (если есть прямой мульти-адрес): + +```bash +./node \ + --peers=/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW... \ + # остальные флаги как выше +``` + +**Автоприсоединение к validator set** происходит не само: после того как нода синхронизируется, действующий validator должен вызвать `client add-validator --target --cosigs ...` (multi-sig admit). До этого новая нода живёт как **observer** — читает и гоняет tx, но не голосует. Запустить ноду **явно** как observer (никогда не проситься в validator set): `--observer=true`. + +### Все флаги `node` + +CLI / env / default. Группы: + +**Storage** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--db` | `DCHAIN_DB` | `./chaindata` | BadgerDB блокчейна | +| `--mailbox-db` | `DCHAIN_MAILBOX_DB` | `./mailboxdata` | E2E-конверты 1:1 чатов | +| `--feed-db` | `DCHAIN_FEED_DB` | `./feeddata` | Тела постов ленты (off-chain) | +| `--feed-ttl-days` | `DCHAIN_FEED_TTL_DAYS` | `30` | Через сколько дней тела постов auto-evict'ятся (метаданные on-chain остаются вечно) | + +**Identity** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--key` | `DCHAIN_KEY` | `./node.json` | Ed25519 ключ ноды | +| `--relay-key` | `DCHAIN_RELAY_KEY` | `./relay.json` | X25519 ключ для relay-mailbox (создастся сам) | +| `--wallet` | `DCHAIN_WALLET` | — | Отдельный payout-кошелёк (опционально) | +| `--wallet-pass` | `DCHAIN_WALLET_PASS` | — | Парольная фраза для wallet-файла | + +**Network** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--listen` | `DCHAIN_LISTEN` | `/ip4/0.0.0.0/tcp/4001` | libp2p listen multiaddr | +| `--announce` | `DCHAIN_ANNOUNCE` | — | Multiaddr который нода рассказывает пирам (обязателен на VPS/внешнем IP) | +| `--peers` | `DCHAIN_PEERS` | — | Bootstrap multiaddrs, comma-separated | +| `--join` | `DCHAIN_JOIN` | — | HTTP URL живой ноды для авто-дискавери — получает peers и genesis_hash | +| `--allow-genesis-mismatch` | — | `false` | Отключить защиту, падающую при расхождении локального и seed'ового genesis (только для явной миграции) | + +**Consensus & role** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--genesis` | `DCHAIN_GENESIS` | `false` | Создать блок 0 (только для самой первой ноды сети) | +| `--validators` | `DCHAIN_VALIDATORS` | — | Исходный validator set (CSV pub-keys) — применяется только при genesis | +| `--observer` | `DCHAIN_OBSERVER` | `false` | Observer-режим: синхронизируется и отдаёт API, но не голосует и не предлагает блоки | +| `--heartbeat` | `DCHAIN_HEARTBEAT` | `true` | Периодический HEARTBEAT-tx (нужен для liveness-детекции валидаторов) | + +**Relay / mailbox** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--register-relay` | `DCHAIN_REGISTER_RELAY` | `false` | Отправить `REGISTER_RELAY` tx на старте (объявить ноду публичным relay'ем) | +| `--relay-fee` | `DCHAIN_RELAY_FEE` | `1000` | Плата за доставку одного сообщения в µT (1000 = 0.001 T). `0` = бесплатный relay | + +**Media scrubber (feed)** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--media-sidecar-url` | `DCHAIN_MEDIA_SIDECAR_URL` | — | URL FFmpeg-сайдкара для видео-скраба. Пустой = только картинки | +| `--allow-unscrubbed-video` | `DCHAIN_ALLOW_UNSCRUBBED_VIDEO` | `false` | Принимать видео **без** серверного скраба (опасно — EXIF/GPS/автор-теги останутся) | + +**HTTP API** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--stats-addr` | `DCHAIN_STATS_ADDR` | `:8080` | Адрес HTTP/WS сервера | +| `--api-token` | `DCHAIN_API_TOKEN` | — | Bearer-токен для submit tx. Пустой = публичная нода | +| `--api-private` | `DCHAIN_API_PRIVATE` | `false` | Требовать токен также на чтение | +| `--disable-ui` | `DCHAIN_DISABLE_UI` | `false` | Отключить HTML-explorer (JSON API остаётся) | +| `--disable-swagger` | `DCHAIN_DISABLE_SWAGGER` | `false` | Отключить `/swagger*` | + +**Resource caps** (новое в v2.1.0) +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--max-cpu` | `DCHAIN_MAX_CPU` | `0` | Сколько CPU-ядер Go-runtime'у (`GOMAXPROCS`). `0` = все | +| `--max-ram-mb` | `DCHAIN_MAX_RAM_MB` | `0` | Soft-лимит Go-хипа в MiB (`GOMEMLIMIT`). `0` = без лимита. *Не OOM-kill'ит — усиливает GC при приближении* | +| `--feed-disk-limit-mb` | `DCHAIN_FEED_DISK_LIMIT_MB` | `0` | Жёсткая квота на feed-БД. При превышении `/feed/publish` отвечает 507. Существующие посты продолжают отдаваться | +| `--chain-disk-limit-mb` | `DCHAIN_CHAIN_DISK_LIMIT_MB` | `0` | Advisory-квота на блокчейн-БД. Превышение → `WARN` в лог раз в минуту (жёстко не отказываем — сломали бы консенсус) | + +Для реального sandboxing (hard-kill при OOM, hard CPU throttling) используйте `docker run --cpus --memory` или systemd `CPUQuota` / `MemoryMax` поверх этих флагов. + +**Update / versioning** +| Флаг | Env | Default | Назначение | +|------|-----|---------|-----------| +| `--update-source-url` | `DCHAIN_UPDATE_SOURCE_URL` | — | Gitea `/api/v1/repos/{owner}/{repo}/releases/latest` для `/api/update-check` | +| `--update-source-token` | `DCHAIN_UPDATE_SOURCE_TOKEN` | — | PAT для приватного репо | +| `--log-format` | `DCHAIN_LOG_FORMAT` | `text` | `text` (human) или `json` (Loki/ELK) | +| `--governance-contract` | `DCHAIN_GOVERNANCE_CONTRACT` | — | ID governance-контракта для динамических параметров | +| `--version` | — | — | Печатает версию и выходит | + +### Минимальные чек-листы + +**Первая нода (открытая):** `--genesis=true` + `--key` + `--announce` на внешний IP + `--stats-addr` + опционально `--register-relay=true --relay-fee=...` чтобы сразу монетизировать relay-трафик. + +**Joiner:** `--join=` + `--key` + `--announce` + `--stats-addr`. После синка попросите действующего валидатора поднять `add-validator` (иначе остаётесь observer'ом до принятия — это нормально и безопасно). + +**Приватная/домашняя нода** без публичного эксплорера: добавьте `--api-token=`, `--api-private=true`, `--disable-ui=true`, `--disable-swagger=true`. Clients передают `Authorization: Bearer `. + +**Слабое железо:** `--max-cpu=2 --max-ram-mb=1024 --feed-disk-limit-mb=2048 --chain-disk-limit-mb=10240`. + +Docker-обёртка с теми же флагами — в [`deploy/single/README.md`](deploy/single/README.md). + ## Продакшен деплой Два варианта, по масштабу. diff --git a/client-app/app/(app)/_layout.tsx b/client-app/app/(app)/_layout.tsx index bda7cd5..ab6b95a 100644 --- a/client-app/app/(app)/_layout.tsx +++ b/client-app/app/(app)/_layout.tsx @@ -25,6 +25,7 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox'; import { getWSClient } from '@/lib/ws'; import { NavBar } from '@/components/NavBar'; import { AnimatedSlot } from '@/components/AnimatedSlot'; +import { saveContact } from '@/lib/storage'; export default function AppLayout() { const keyFile = useStore(s => s.keyFile); @@ -49,6 +50,23 @@ export default function AppLayout() { useNotifications(); // permission + tap-handler useGlobalInbox(); // global inbox listener → notifications on new peer msg + // Ensure the Saved Messages (self-chat) contact exists as soon as the user + // is signed in, so it shows up in the chat list without any prior action. + const contacts = useStore(s => s.contacts); + const upsertContact = useStore(s => s.upsertContact); + useEffect(() => { + if (!keyFile) return; + if (contacts.some(c => c.address === keyFile.pub_key)) return; + const saved = { + address: keyFile.pub_key, + x25519Pub: keyFile.x25519_pub, + alias: 'Saved Messages', + addedAt: Date.now(), + }; + upsertContact(saved); + saveContact(saved).catch(() => { /* best-effort — re-added next boot anyway */ }); + }, [keyFile, contacts, upsertContact]); + useEffect(() => { const ws = getWSClient(); if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key }); diff --git a/client-app/app/(app)/chats/[id].tsx b/client-app/app/(app)/chats/[id].tsx index 518cd48..075079c 100644 --- a/client-app/app/(app)/chats/[id].tsx +++ b/client-app/app/(app)/chats/[id].tsx @@ -63,6 +63,24 @@ export default function ChatScreen() { clearContactNotifications(contactAddress); }, [contactAddress, clearUnread]); + const upsertContact = useStore(s => s.upsertContact); + const isSavedMessages = !!keyFile && contactAddress === keyFile.pub_key; + + // Auto-materialise the Saved Messages contact the first time the user + // opens chat-with-self. The contact is stored locally only — no on-chain + // CONTACT_REQUEST needed, since both ends are the same key pair. + useEffect(() => { + if (!isSavedMessages || !keyFile) return; + const existing = contacts.find(c => c.address === keyFile.pub_key); + if (existing) return; + upsertContact({ + address: keyFile.pub_key, + x25519Pub: keyFile.x25519_pub, + alias: 'Saved Messages', + addedAt: Date.now(), + }); + }, [isSavedMessages, keyFile, contacts, upsertContact]); + const contact = contacts.find(c => c.address === contactAddress); const chatMsgs = messages[contactAddress ?? ''] ?? []; const listRef = useRef(null); @@ -137,9 +155,11 @@ export default function ChatScreen() { }); }, [contactAddress, setMsgs]); - const name = contact?.username - ? `@${contact.username}` - : contact?.alias ?? shortAddr(contactAddress ?? ''); + const name = isSavedMessages + ? 'Saved Messages' + : contact?.username + ? `@${contact.username}` + : contact?.alias ?? shortAddr(contactAddress ?? ''); // ── Compose actions ──────────────────────────────────────────────────── const cancelCompose = useCallback(() => { @@ -172,7 +192,7 @@ export default function ChatScreen() { const hasText = !!actualText.trim(); const hasAttach = !!actualAttach; if (!hasText && !hasAttach) return; - if (!contact.x25519Pub) { + if (!isSavedMessages && !contact.x25519Pub) { Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.'); return; } @@ -188,7 +208,10 @@ export default function ChatScreen() { setSending(true); try { - if (hasText) { + // Saved Messages short-circuits the relay entirely — the message never + // leaves the device, so no encryption/fee/network round-trip is needed. + // Regular chats still go through the NaCl + relay pipeline below. + if (hasText && !isSavedMessages) { const { nonce, ciphertext } = encryptMessage( actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, ); @@ -224,7 +247,7 @@ export default function ChatScreen() { setSending(false); } }, [ - text, keyFile, contact, composeMode, chatMsgs, + text, keyFile, contact, composeMode, chatMsgs, isSavedMessages, setMsgs, cancelCompose, appendMsg, pendingAttach, ]); @@ -411,7 +434,7 @@ export default function ChatScreen() { hitSlop={4} style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }} > - + )} - {!peerTyping && !contact?.x25519Pub && ( + {!peerTyping && !isSavedMessages && !contact?.x25519Pub && ( waiting for key @@ -447,37 +470,49 @@ export default function ChatScreen() { с "scroll position at bottom" без ручного scrollToEnd, и новые сообщения (добавляемые в начало reversed-массива) появляются внизу естественно. Никаких jerk'ов при открытии. */} - r.kind === 'sep' ? r.id : r.msg.id} - renderItem={renderRow} - contentContainerStyle={{ paddingVertical: 10 }} - showsVerticalScrollIndicator={false} - // Lazy render: only mount ~1.5 screens of bubbles initially, - // render further batches as the user scrolls older. Keeps - // initial paint fast on chats with thousands of messages. - initialNumToRender={25} - maxToRenderPerBatch={12} - windowSize={10} - removeClippedSubviews - ListEmptyComponent={() => ( - - - - Say hi to {name} - - - Your messages are end-to-end encrypted. - - - )} - /> + {rows.length === 0 ? ( + // Empty state is rendered as a plain View instead of + // ListEmptyComponent on an inverted FlatList — the previous + // `transform: [{ scaleY: -1 }]` un-flip trick was rendering + // text mirrored on some Android builds (RTL-aware layout), + // giving us the "say hi…" backwards bug. + + + + {isSavedMessages ? 'Notes to self' : `Say hi to ${name}`} + + + {isSavedMessages + ? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.' + : 'Your messages are end-to-end encrypted.'} + + + ) : ( + r.kind === 'sep' ? r.id : r.msg.id} + renderItem={renderRow} + contentContainerStyle={{ paddingVertical: 10 }} + showsVerticalScrollIndicator={false} + // Lazy render: only mount ~1.5 screens of bubbles initially, + // render further batches as the user scrolls older. Keeps + // initial paint fast on chats with thousands of messages. + initialNumToRender={25} + maxToRenderPerBatch={12} + windowSize={10} + removeClippedSubviews + /> + )} {/* Composer — floating, прибит к низу. */} diff --git a/client-app/app/(app)/chats/index.tsx b/client-app/app/(app)/chats/index.tsx index 272c5c0..bbf1560 100644 --- a/client-app/app/(app)/chats/index.tsx +++ b/client-app/app/(app)/chats/index.tsx @@ -28,6 +28,7 @@ export default function ChatsScreen() { const insets = useSafeAreaInsets(); const contacts = useStore(s => s.contacts); const messages = useStore(s => s.messages); + const keyFile = useStore(s => s.keyFile); // Статус подключения: online / connecting / offline. // Название шапки и цвет pip'а на аватаре зависят от него. @@ -48,9 +49,14 @@ export default function ChatsScreen() { return msgs && msgs.length ? msgs[msgs.length - 1] : null; }; - // Сортировка по последней активности. + // Сортировка по последней активности. Saved Messages (self-chat) всегда + // закреплён сверху — это "Избранное", бессмысленно конкурировать с ним + // по recency'и обычным чатам. + const selfAddr = keyFile?.pub_key; const sorted = useMemo(() => { - return [...contacts] + const saved = selfAddr ? contacts.find(c => c.address === selfAddr) : undefined; + const rest = contacts + .filter(c => c.address !== selfAddr) .map(c => ({ c, last: lastOf(c) })) .sort((a, b) => { const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000; @@ -58,7 +64,8 @@ export default function ChatsScreen() { return kb - ka; }) .map(x => x.c); - }, [contacts, messages]); + return saved ? [saved, ...rest] : rest; + }, [contacts, messages, selfAddr]); return ( @@ -72,6 +79,7 @@ export default function ChatsScreen() { router.push(`/(app)/chats/${item.address}` as never)} /> )} diff --git a/client-app/app/(app)/compose.tsx b/client-app/app/(app)/compose.tsx index b7f8e53..cb97ded 100644 --- a/client-app/app/(app)/compose.tsx +++ b/client-app/app/(app)/compose.tsx @@ -42,7 +42,18 @@ import { safeBack } from '@/lib/utils'; const MAX_CONTENT_LENGTH = 4000; const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize const IMAGE_MAX_DIM = 1080; -const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable +// Match the server scrubber's JPEG quality (media/scrub.go:ImageJPEGQuality +// = 75). If the client re-encodes at a LOWER quality the server re-encode +// at 75 inflates the bytes, often 2-3× — so a 60 KiB upload silently blows +// past MaxPostSize = 256 KiB mid-flight and `/feed/publish` rejects with +// "post body exceeds maximum allowed size". Using the same Q for both +// passes keeps the final footprint ~the same as what the user sees in +// the composer. +const IMAGE_QUALITY = 0.75; +// Safety margin on the pre-upload check: the server pass is near-idempotent +// at matching Q but not exactly — reserve ~8 KiB for JPEG header / metadata +// scaffolding differences so we don't flirt with the hard cap. +const IMAGE_BUDGET_BYTES = MAX_POST_BYTES - 8 * 1024; interface Attachment { uri: string; @@ -131,10 +142,10 @@ export default function ComposeScreen() { }); const bytes = base64ToBytes(b64); - if (bytes.length > MAX_POST_BYTES - 512) { + if (bytes.length > IMAGE_BUDGET_BYTES) { Alert.alert( 'Image too large', - `Image is ${Math.round(bytes.length / 1024)} KB but the limit is ${MAX_POST_BYTES / 1024} KB. Try picking a smaller one.`, + `Image is ${Math.round(bytes.length / 1024)} KB but the post limit is ${MAX_POST_BYTES / 1024} KB (after server re-encode). Try a smaller picture.`, ); return; } diff --git a/client-app/app/(app)/feed/author/[pub].tsx b/client-app/app/(app)/feed/author/[pub].tsx new file mode 100644 index 0000000..524c452 --- /dev/null +++ b/client-app/app/(app)/feed/author/[pub].tsx @@ -0,0 +1,249 @@ +/** + * Author wall — timeline of every post by a single author, newest first. + * + * Route: /(app)/feed/author/[pub] + * + * Entry points: + * - Profile screen "View posts" button. + * - Tapping the author name/avatar inside a PostCard. + * + * Backend: GET /feed/author/{pub}?limit=N[&before=ts] + * — chain-authoritative, returns FeedPostItem[] ordered newest-first. + * + * Pagination: infinite-scroll via onEndReached → appends the next page + * anchored on the oldest timestamp we've seen. Safe to over-fetch because + * the relay caps `limit`. + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, Text, FlatList, RefreshControl, ActivityIndicator, Pressable, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Header } from '@/components/Header'; +import { IconButton } from '@/components/IconButton'; +import { Avatar } from '@/components/Avatar'; +import { PostCard, PostSeparator } from '@/components/feed/PostCard'; +import { useStore } from '@/lib/store'; +import { fetchAuthorPosts, fetchStats, type FeedPostItem } from '@/lib/feed'; +import { getIdentity, type IdentityInfo } from '@/lib/api'; +import { safeBack } from '@/lib/utils'; + +const PAGE = 30; + +function shortAddr(a: string, n = 6): string { + if (!a) return '—'; + return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; +} + +export default function AuthorWallScreen() { + const insets = useSafeAreaInsets(); + const { pub } = useLocalSearchParams<{ pub: string }>(); + const keyFile = useStore(s => s.keyFile); + const contacts = useStore(s => s.contacts); + + const contact = contacts.find(c => c.address === pub); + const isMe = !!keyFile && keyFile.pub_key === pub; + + const [posts, setPosts] = useState([]); + const [likedSet, setLikedSet] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [exhausted, setExhausted] = useState(false); + const [identity, setIdentity] = useState(null); + + const seq = useRef(0); + + // Identity — for the header's username / avatar seed. Best-effort; the + // screen still works without it. + useEffect(() => { + if (!pub) return; + let cancelled = false; + getIdentity(pub).then(id => { if (!cancelled) setIdentity(id); }).catch(() => {}); + return () => { cancelled = true; }; + }, [pub]); + + const loadLikedFor = useCallback(async (items: FeedPostItem[]) => { + if (!keyFile) return new Set(); + const liked = new Set(); + for (const p of items) { + const s = await fetchStats(p.post_id, keyFile.pub_key); + if (s?.liked_by_me) liked.add(p.post_id); + } + return liked; + }, [keyFile]); + + const load = useCallback(async (isRefresh = false) => { + if (!pub) return; + if (isRefresh) setRefreshing(true); + else setLoading(true); + + const id = ++seq.current; + try { + const items = await fetchAuthorPosts(pub, { limit: PAGE }); + if (id !== seq.current) return; + setPosts(items); + setExhausted(items.length < PAGE); + const liked = await loadLikedFor(items); + if (id !== seq.current) return; + setLikedSet(liked); + } catch { + if (id !== seq.current) return; + setPosts([]); + setExhausted(true); + } finally { + if (id !== seq.current) return; + setLoading(false); + setRefreshing(false); + } + }, [pub, loadLikedFor]); + + useEffect(() => { load(false); }, [load]); + + const loadMore = useCallback(async () => { + if (!pub || loadingMore || exhausted || loading) return; + const oldest = posts[posts.length - 1]; + if (!oldest) return; + setLoadingMore(true); + try { + const more = await fetchAuthorPosts(pub, { limit: PAGE, before: oldest.created_at }); + // De-dup by post_id — defensive against boundary overlap. + const known = new Set(posts.map(p => p.post_id)); + const fresh = more.filter(p => !known.has(p.post_id)); + if (fresh.length === 0) { setExhausted(true); return; } + setPosts(prev => [...prev, ...fresh]); + if (more.length < PAGE) setExhausted(true); + const liked = await loadLikedFor(fresh); + setLikedSet(set => { + const next = new Set(set); + liked.forEach(v => next.add(v)); + return next; + }); + } catch { + // Swallow — user can pull-to-refresh to recover. + } finally { + setLoadingMore(false); + } + }, [pub, posts, loadingMore, exhausted, loading, loadLikedFor]); + + const onStatsChanged = useCallback(async (postID: string) => { + if (!keyFile) return; + const s = await fetchStats(postID, keyFile.pub_key); + if (!s) return; + setPosts(ps => ps.map(p => p.post_id === postID + ? { ...p, likes: s.likes, views: s.views } : p)); + setLikedSet(set => { + const next = new Set(set); + if (s.liked_by_me) next.add(postID); else next.delete(postID); + return next; + }); + }, [keyFile]); + + // "Saved Messages" is a messaging-app label and has no place on a public + // wall — for self we fall back to the real handle (@username if claimed, + // else short-addr), same as any other author. + const displayName = isMe + ? (identity?.nickname ? `@${identity.nickname}` : 'You') + : contact?.username + ? `@${contact.username}` + : (contact?.alias && contact.alias !== 'Saved Messages') + ? contact.alias + : (identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6)); + + const handle = identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6); + + return ( + +
safeBack()} />} + title={ + pub && router.push(`/(app)/profile/${pub}` as never)} + hitSlop={4} + style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }} + > + + + + {displayName} + + + {handle !== displayName ? handle : 'Wall'} + + + + } + /> + + p.post_id} + renderItem={({ item }) => ( + + )} + ItemSeparatorComponent={PostSeparator} + initialNumToRender={10} + maxToRenderPerBatch={8} + windowSize={7} + removeClippedSubviews + onEndReachedThreshold={0.6} + onEndReached={loadMore} + refreshControl={ + load(true)} + tintColor="#1d9bf0" + /> + } + ListFooterComponent={ + loadingMore ? ( + + + + ) : null + } + ListEmptyComponent={ + loading ? ( + + + + ) : ( + + + + {isMe ? "You haven't posted yet" : 'No posts yet'} + + + {isMe + ? 'Tap the compose button on the feed tab to publish your first post.' + : 'This user hasn\'t published any posts on this chain.'} + + + ) + } + contentContainerStyle={ + posts.length === 0 + ? { flexGrow: 1 } + : { paddingTop: 8, paddingBottom: 24 } + } + /> + + ); +} diff --git a/client-app/app/(app)/new-contact.tsx b/client-app/app/(app)/new-contact.tsx index d2b88f4..406ca65 100644 --- a/client-app/app/(app)/new-contact.tsx +++ b/client-app/app/(app)/new-contact.tsx @@ -64,16 +64,17 @@ export default function NewContactScreen() { if (!addr) { setError(`@${name} is not registered on this chain`); return; } address = addr; } - // Block self-lookup — can't message yourself, and the on-chain - // CONTACT_REQUEST tx would go through but serve no purpose. + // Self-lookup: skip the contact-request dance entirely and jump straight + // to Saved Messages (self-chat). No CONTACT_REQUEST tx is needed — the + // chat-with-self flow is purely local storage. if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) { - setError("That's you. You can't send a contact request to yourself."); + router.replace(`/(app)/chats/${keyFile.pub_key}` as never); return; } const identity = await getIdentity(address); const resolvedAddr = identity?.pub_key ?? address; if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) { - setError("That's you. You can't send a contact request to yourself."); + router.replace(`/(app)/chats/${keyFile.pub_key}` as never); return; } setResolved({ diff --git a/client-app/app/(app)/profile/[address].tsx b/client-app/app/(app)/profile/[address].tsx index a2f86b3..d671540 100644 --- a/client-app/app/(app)/profile/[address].tsx +++ b/client-app/app/(app)/profile/[address].tsx @@ -13,7 +13,7 @@ * push stack, so tapping Back returns the user to whatever screen * pushed them here (feed card tap, chat header tap, etc.). */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, ScrollView, Pressable, ActivityIndicator, } from 'react-native'; @@ -27,8 +27,11 @@ import { Avatar } from '@/components/Avatar'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; import { followUser, unfollowUser } from '@/lib/feed'; -import { humanizeTxError } from '@/lib/api'; -import { safeBack } from '@/lib/utils'; +import { + humanizeTxError, getBalance, getIdentity, getRelayFor, + type IdentityInfo, type RegisteredRelayInfo, +} from '@/lib/api'; +import { safeBack, formatAmount } from '@/lib/utils'; function shortAddr(a: string, n = 10): string { if (!a) return '—'; @@ -46,10 +49,35 @@ export default function ProfileScreen() { const [followingBusy, setFollowingBusy] = useState(false); const [copied, setCopied] = useState(false); + // On-chain enrichment — fetched once per address mount. + const [balanceUT, setBalanceUT] = useState(null); + const [identity, setIdentity] = useState(null); + const [relay, setRelay] = useState(null); + const [loadingChain, setLoadingChain] = useState(true); + const isMe = !!keyFile && keyFile.pub_key === address; - const displayName = contact?.username - ? `@${contact.username}` - : contact?.alias ?? (isMe ? 'You' : shortAddr(address ?? '', 6)); + + useEffect(() => { + if (!address) return; + let cancelled = false; + setLoadingChain(true); + Promise.all([ + getBalance(address).catch(() => 0), + getIdentity(address).catch(() => null), + getRelayFor(address).catch(() => null), + ]).then(([bal, id, rel]) => { + if (cancelled) return; + setBalanceUT(bal); + setIdentity(id); + setRelay(rel); + }).finally(() => { if (!cancelled) setLoadingChain(false); }); + return () => { cancelled = true; }; + }, [address]); + const displayName = isMe + ? 'Saved Messages' + : contact?.username + ? `@${contact.username}` + : contact?.alias ?? shortAddr(address ?? '', 6); const copyAddress = async () => { if (!address) return; @@ -94,7 +122,7 @@ export default function ProfileScreen() { {/* ── Hero: avatar + Follow button ──────────────────────────── */} - + {!isMe ? ( ) : ( router.push('/(app)/settings' as never)} + onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)} style={({ pressed }) => ({ - paddingHorizontal: 18, paddingVertical: 9, + paddingHorizontal: 16, paddingVertical: 9, borderRadius: 999, + flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: pressed ? '#1a1a1a' : '#111111', borderWidth: 1, borderColor: '#1f1f1f', })} > + - Edit + Saved Messages )} @@ -159,13 +189,14 @@ export default function ProfileScreen() { )} - {/* Open chat — single CTA, full width, icon inline with text. - Only when we know this is a contact (direct chat exists). */} - {!isMe && contact && ( + {/* Action row — View posts is universal (anyone can have a wall, + even non-contacts). Open chat appears alongside only when this + address is already a direct-chat contact. */} + address && router.push(`/(app)/feed/author/${address}` as never)} style={({ pressed }) => ({ - marginTop: 14, + flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', @@ -176,12 +207,34 @@ export default function ProfileScreen() { borderWidth: 1, borderColor: '#1f1f1f', })} > - + - Open chat + View posts - )} + + {!isMe && contact && ( + ({ + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingVertical: 11, + borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + + Open chat + + + )} + {/* ── Info card ───────────────────────────────────────────────── */} + {/* Username — shown if the on-chain identity record has one. + Different from contact.username (which may be a local alias). */} + {identity?.nickname ? ( + <> + + + + ) : null} + + {/* DC address — the human-readable form of the pub key. */} + {identity?.address ? ( + <> + + + + ) : null} + + {/* Balance — always shown once fetched. */} + + + + {/* Relay node — shown only if this address is a registered relay. */} + {relay && ( + <> + + + {relay.last_heartbeat ? ( + <> + + + + ) : null} + + )} + {/* Encryption status */} {contact && ( <> diff --git a/client-app/components/Avatar.tsx b/client-app/components/Avatar.tsx index 9186203..e6c707a 100644 --- a/client-app/components/Avatar.tsx +++ b/client-app/components/Avatar.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; import { View, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; export interface AvatarProps { /** Имя / @username — берём первый символ для placeholder. */ @@ -18,6 +19,11 @@ export interface AvatarProps { dotColor?: string; /** Класс для обёртки (position: relative кадр). */ className?: string; + /** + * Saved Messages variant — blue circle with a bookmark glyph, Telegram-style. + * When set, `name`/`address` are ignored for the visual. + */ + saved?: boolean; } /** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */ @@ -28,10 +34,10 @@ function pickBg(seed: string): string { return shades[h % shades.length]; } -export function Avatar({ name, address, size = 48, dotColor, className }: AvatarProps) { +export function Avatar({ name, address, size = 48, dotColor, className, saved }: AvatarProps) { const seed = (name ?? address ?? '?').replace(/^@/, ''); const initial = seed.charAt(0).toUpperCase() || '?'; - const bg = pickBg(seed); + const bg = saved ? '#1d9bf0' : pickBg(seed); return ( @@ -45,16 +51,20 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar justifyContent: 'center', }} > - - {initial} - + {saved ? ( + + ) : ( + + {initial} + + )} {dotColor && ( void; + /** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */ + saved?: boolean; } -export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) { - const name = displayName(c); +export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) { + const name = saved ? 'Saved Messages' : displayName(c); const last = lastMessage; // Визуальный маркер типа чата. @@ -92,7 +94,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) { name={name} address={c.address} size={44} - dotColor={c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined} + saved={saved} + dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined} /> @@ -143,9 +146,11 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) { > {last ? lastPreview(last) - : c.x25519Pub - ? 'Tap to start encrypted chat' - : 'Waiting for identity…'} + : saved + ? 'Your personal notes & files' + : c.x25519Pub + ? 'Tap to start encrypted chat' + : 'Waiting for identity…'} {unread !== null && ( diff --git a/client-app/components/feed/PostCard.tsx b/client-app/components/feed/PostCard.tsx index b7b9548..bd774fe 100644 --- a/client-app/components/feed/PostCard.tsx +++ b/client-app/components/feed/PostCard.tsx @@ -80,11 +80,16 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: // Find a display-friendly name for the author. If it's a known contact // with @username, use that; otherwise short-addr. + // + // `mine` takes precedence over the contact lookup: our own pub key has + // a self-contact entry with alias "Saved Messages" (that's how the + // self-chat tile is rendered), but that label is wrong in the feed — + // posts there should read as "You", not as a messaging-app affordance. const displayName = useMemo(() => { + if (mine) return 'You'; const c = contacts.find(x => x.address === post.author); if (c?.username) return `@${c.username}`; if (c?.alias) return c.alias; - if (mine) return 'You'; return shortAddr(post.author); }, [contacts, post.author, mine]); diff --git a/client-app/lib/api.ts b/client-app/lib/api.ts index 76dbe1b..38e4532 100644 --- a/client-app/lib/api.ts +++ b/client-app/lib/api.ts @@ -367,6 +367,39 @@ export interface IdentityInfo { registered: boolean; } +/** + * Relay registration info for a node pub key, as returned by + * /api/relays (which comes back as an array of RegisteredRelayInfo). + * We don't wrap the individual lookup on the server — just filter the + * full list client-side. It's bounded (N nodes in the network) and + * cached heavily enough that this is cheaper than a new endpoint. + */ +export interface RegisteredRelayInfo { + pub_key: string; + address: string; + relay: { + x25519_pub_key: string; + fee_per_msg_ut: number; + multiaddr?: string; + }; + last_heartbeat?: number; // unix seconds +} + +/** GET /api/relays — all relay nodes registered on-chain. */ +export async function getRelays(): Promise { + try { + return await get('/api/relays'); + } catch { + return []; + } +} + +/** Find relay entry for a specific pub key. null if the address isn't a relay. */ +export async function getRelayFor(pubKey: string): Promise { + const all = await getRelays(); + return all.find(r => r.pub_key === pubKey) ?? null; +} + /** Fetch identity info for any pubkey or DC address. Returns null on 404. */ export async function getIdentity(pubkeyOrAddr: string): Promise { try { diff --git a/cmd/node/main.go b/cmd/node/main.go index 70f602c..0fbf8d2 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -29,6 +29,8 @@ import ( "os" "os/signal" "path/filepath" + "runtime" + "runtime/debug" "strings" "sync" "syscall" @@ -114,6 +116,16 @@ func main() { // only for intentional migrations (e.g. importing data from another chain // into this network) — very dangerous. allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.") + // ── Resource caps ─────────────────────────────────────────────────────── + // All four accept 0 meaning "no limit". Enforcement model: + // * CPU — runtime.GOMAXPROCS(n): Go runtime won't use more than n OS threads for Go code. + // * RAM — debug.SetMemoryLimit: soft limit, the GC works harder as the heap approaches it. + // * Feed disk — hard refuse of new post bodies once the cap is crossed (existing posts keep serving). + // * Chain disk — warn-only periodic check; we can't hard-reject new blocks without breaking consensus. + maxCPU := flag.Int("max-cpu", int(envUint64Or("DCHAIN_MAX_CPU", 0)), "max CPU cores the node may use (GOMAXPROCS). 0 = all (env: DCHAIN_MAX_CPU)") + maxRAMMB := flag.Uint64("max-ram-mb", envUint64Or("DCHAIN_MAX_RAM_MB", 0), "soft Go heap limit in MiB (GOMEMLIMIT). 0 = unlimited (env: DCHAIN_MAX_RAM_MB)") + feedDiskMB := flag.Uint64("feed-disk-limit-mb", envUint64Or("DCHAIN_FEED_DISK_LIMIT_MB", 0), "disk quota for post bodies in MiB; new posts are refused with 507 once crossed. 0 = unlimited (env: DCHAIN_FEED_DISK_LIMIT_MB)") + chainDiskMB := flag.Uint64("chain-disk-limit-mb", envUint64Or("DCHAIN_CHAIN_DISK_LIMIT_MB", 0), "advisory disk cap for the chain DB dir in MiB; exceeding it logs a loud WARN every minute. 0 = unlimited (env: DCHAIN_CHAIN_DISK_LIMIT_MB)") showVersion := flag.Bool("version", false, "print version info and exit") flag.Parse() @@ -128,6 +140,10 @@ func main() { // so subsequent logs inherit the format. setupLogging(*logFormat) + // Apply CPU / RAM caps before anything else spins up so the runtime + // picks them up at first goroutine/heap allocation. + applyResourceCaps(*maxCPU, *maxRAMMB) + // Wire API access-control. A non-empty token gates writes; adding // --api-private also gates reads. Logged up-front so the operator // sees what mode they're in. @@ -641,12 +657,24 @@ func main() { // --- Feed mailbox (social-feed post bodies, v2.0.0) --- feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour - feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL) + feedQuotaBytes := int64(*feedDiskMB) * 1024 * 1024 + feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL, feedQuotaBytes) if err != nil { log.Fatalf("[NODE] feed mailbox: %v", err) } defer feedMailbox.Close() - log.Printf("[NODE] feed mailbox: %s (TTL %d days)", *feedDB, *feedTTLDays) + if feedQuotaBytes > 0 { + log.Printf("[NODE] feed mailbox: %s (TTL %d days, disk quota %d MiB)", *feedDB, *feedTTLDays, *feedDiskMB) + } else { + log.Printf("[NODE] feed mailbox: %s (TTL %d days, no disk quota)", *feedDB, *feedTTLDays) + } + + // Advisory chain-disk watcher. We can't refuse new blocks (consensus + // would stall), so instead we walk the chain DB dir every minute and + // log a loud WARN if the operator's budget is exceeded. Zero = disabled. + if *chainDiskMB > 0 { + go watchChainDisk(*dbPath, int64(*chainDiskMB)*1024*1024) + } // Push-notify bus consumers whenever a fresh envelope lands in the // mailbox. Clients subscribed to `inbox:` (via WS) get the @@ -1472,6 +1500,61 @@ func shortKeys(keys []string) []string { // "text" (default) is handler-default human-readable format, same as bare // log.Printf. "json" emits one JSON object per line with `time/level/msg` // + any key=value attrs — what Loki/ELK ingest natively. +// applyResourceCaps wires the --max-cpu and --max-ram-mb flags into the Go +// runtime. Both are soft-ish: CPU clamps GOMAXPROCS (Go scheduler won't use +// more OS threads for Go code, though blocking syscalls can still spawn +// more); RAM sets GOMEMLIMIT (the GC tightens its collection schedule as +// the heap approaches the cap but cannot *force* a kernel OOM-free). Use +// container limits (cgroup / Docker --memory / --cpus) alongside these +// for a real ceiling — this is "please play nice", not "hard sandbox". +func applyResourceCaps(maxCPU int, maxRAMMB uint64) { + if maxCPU > 0 { + prev := runtime.GOMAXPROCS(maxCPU) + log.Printf("[NODE] CPU cap: GOMAXPROCS %d → %d", prev, maxCPU) + } + if maxRAMMB > 0 { + bytes := int64(maxRAMMB) * 1024 * 1024 + debug.SetMemoryLimit(bytes) + log.Printf("[NODE] RAM cap: GOMEMLIMIT = %d MiB (soft, GC-enforced)", maxRAMMB) + } +} + +// watchChainDisk periodically walks the chain BadgerDB directory and logs +// a WARN line whenever its size exceeds `limitBytes`. Runs forever — the +// process lifetime bounds it. We deliberately do *not* stop block +// production when the cap is crossed: a validator that refuses to apply +// blocks stalls consensus for everyone on the chain, which is worse than +// using more disk than the operator wanted. Treat this as a monitoring +// signal, e.g. feed it to Prometheus via an alertmanager scrape. +func watchChainDisk(dir string, limitBytes int64) { + tick := time.NewTicker(60 * time.Second) + defer tick.Stop() + for ; ; <-tick.C { + used := dirSize(dir) + if used > limitBytes { + log.Printf("[NODE] WARN chain disk over quota: %d MiB used > %d MiB limit at %s", + used>>20, limitBytes>>20, dir) + } + } +} + +// dirSize returns the total byte size of all regular files under root, +// recursively. Errors on individual entries are ignored — this is an +// advisory metric, not a filesystem audit. +func dirSize(root string) int64 { + var total int64 + _ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if !info.IsDir() { + total += info.Size() + } + return nil + }) + return total +} + func setupLogging(format string) { var handler slog.Handler switch strings.ToLower(format) { diff --git a/node/api_feed.go b/node/api_feed.go index 39d0494..956e787 100644 --- a/node/api_feed.go +++ b/node/api_feed.go @@ -34,6 +34,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -249,10 +250,16 @@ func feedPublish(cfg FeedConfig) http.HandlerFunc { } hashtags, err := cfg.Mailbox.Store(post, req.Ts) if err != nil { - if err == relay.ErrPostTooLarge { + if errors.Is(err, relay.ErrPostTooLarge) { jsonErr(w, err, 413) return } + if errors.Is(err, relay.ErrFeedQuotaExceeded) { + // 507 Insufficient Storage — the client should try + // another relay (or wait for TTL-driven eviction here). + jsonErr(w, err, 507) + return + } jsonErr(w, err, 500) return } diff --git a/node/feed_e2e_test.go b/node/feed_e2e_test.go index 90bb577..7f41fd7 100644 --- a/node/feed_e2e_test.go +++ b/node/feed_e2e_test.go @@ -72,7 +72,7 @@ func newFeedHarness(t *testing.T) *feedHarness { if err != nil { t.Fatalf("NewChain: %v", err) } - fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour) + fm, err := relay.OpenFeedMailbox(feedDir, 24*time.Hour, 0) if err != nil { t.Fatalf("OpenFeedMailbox: %v", err) } diff --git a/node/feed_twonode_test.go b/node/feed_twonode_test.go index 11c8a89..8cd154e 100644 --- a/node/feed_twonode_test.go +++ b/node/feed_twonode_test.go @@ -81,11 +81,11 @@ func newTwoNodeHarness(t *testing.T) *twoNodeHarness { if err != nil { t.Fatalf("chain B: %v", err) } - h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour) + h.aMailbox, err = relay.OpenFeedMailbox(h.aFeedDir, 24*time.Hour, 0) if err != nil { t.Fatalf("feed A: %v", err) } - h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour) + h.bMailbox, err = relay.OpenFeedMailbox(h.bFeedDir, 24*time.Hour, 0) if err != nil { t.Fatalf("feed B: %v", err) } diff --git a/relay/feed_mailbox.go b/relay/feed_mailbox.go index 9079b45..b7ba02c 100644 --- a/relay/feed_mailbox.go +++ b/relay/feed_mailbox.go @@ -97,24 +97,35 @@ type FeedPost struct { // ErrPostTooLarge is returned by Store when the post body exceeds MaxPostBodySize. var ErrPostTooLarge = errors.New("post body exceeds maximum allowed size") +// ErrFeedQuotaExceeded is returned by Store when the on-disk footprint +// (LSM + value log) plus the incoming post would exceed the operator-set +// disk quota. Ops set this via --feed-disk-limit-mb. Zero = unlimited. +var ErrFeedQuotaExceeded = errors.New("feed mailbox disk quota exceeded") + // FeedMailbox stores feed post bodies. type FeedMailbox struct { - db *badger.DB - ttl time.Duration + db *badger.DB + ttl time.Duration + quotaBytes int64 // 0 = unlimited } // NewFeedMailbox wraps an already-open Badger DB. TTL controls how long // post bodies live before auto-eviction (on-chain metadata persists -// forever independently). -func NewFeedMailbox(db *badger.DB, ttl time.Duration) *FeedMailbox { +// forever independently). quotaBytes caps the on-disk footprint; 0 or +// negative means unlimited. +func NewFeedMailbox(db *badger.DB, ttl time.Duration, quotaBytes int64) *FeedMailbox { if ttl <= 0 { ttl = time.Duration(FeedPostDefaultTTLDays) * 24 * time.Hour } - return &FeedMailbox{db: db, ttl: ttl} + if quotaBytes < 0 { + quotaBytes = 0 + } + return &FeedMailbox{db: db, ttl: ttl, quotaBytes: quotaBytes} } // OpenFeedMailbox opens (or creates) a dedicated BadgerDB at dbPath. -func OpenFeedMailbox(dbPath string, ttl time.Duration) (*FeedMailbox, error) { +// quotaBytes caps the total on-disk footprint (LSM + vlog); 0 = unlimited. +func OpenFeedMailbox(dbPath string, ttl time.Duration, quotaBytes int64) (*FeedMailbox, error) { opts := badger.DefaultOptions(dbPath). WithLogger(nil). WithValueLogFileSize(128 << 20). @@ -124,9 +135,19 @@ func OpenFeedMailbox(dbPath string, ttl time.Duration) (*FeedMailbox, error) { if err != nil { return nil, fmt.Errorf("open feed mailbox db: %w", err) } - return NewFeedMailbox(db, ttl), nil + return NewFeedMailbox(db, ttl, quotaBytes), nil } +// DiskUsage returns the current on-disk footprint (LSM + value log) in +// bytes. Cheap — Badger tracks these counters internally. +func (fm *FeedMailbox) DiskUsage() int64 { + lsm, vlog := fm.db.Size() + return lsm + vlog +} + +// Quota returns the configured disk quota in bytes. 0 = unlimited. +func (fm *FeedMailbox) Quota() int64 { return fm.quotaBytes } + // Close releases the underlying Badger handle. func (fm *FeedMailbox) Close() error { return fm.db.Close() } @@ -139,7 +160,23 @@ func (fm *FeedMailbox) Close() error { return fm.db.Close() } func (fm *FeedMailbox) Store(post *FeedPost, createdAt int64) ([]string, error) { size := estimatePostSize(post) if size > MaxPostBodySize { - return nil, ErrPostTooLarge + // Wrap the sentinel so the HTTP layer can still errors.Is() on it + // while the operator / client sees the actual offending numbers. + // This catches the common case where the client's pre-scrub + // estimate is below the cap but the server re-encode (quality=75 + // JPEG) inflates past it. + return nil, fmt.Errorf("%w: size %d > max %d (after server scrub)", + ErrPostTooLarge, size, MaxPostBodySize) + } + // Disk quota: refuse new bodies once we're already over the cap. + // `size` is a post-body estimate, not the exact BadgerDB write-amp + // cost; we accept that slack — the goal is a coarse guard-rail so + // an operator's disk doesn't blow up unnoticed. Exceeding nodes + // still serve existing posts; only new Store() calls are refused. + if fm.quotaBytes > 0 { + if fm.DiskUsage()+int64(size) > fm.quotaBytes { + return nil, ErrFeedQuotaExceeded + } } post.CreatedAt = createdAt diff --git a/relay/feed_mailbox_test.go b/relay/feed_mailbox_test.go index da0625d..9a9d4f5 100644 --- a/relay/feed_mailbox_test.go +++ b/relay/feed_mailbox_test.go @@ -1,6 +1,7 @@ package relay import ( + "errors" "os" "testing" "time" @@ -12,7 +13,7 @@ func newTestFeedMailbox(t *testing.T) *FeedMailbox { if err != nil { t.Fatalf("MkdirTemp: %v", err) } - fm, err := OpenFeedMailbox(dir, 24*time.Hour) + fm, err := OpenFeedMailbox(dir, 24*time.Hour, 0) if err != nil { _ = os.RemoveAll(dir) t.Fatalf("OpenFeedMailbox: %v", err) @@ -75,7 +76,7 @@ func TestFeedMailboxTooLarge(t *testing.T) { Author: "a", Attachment: big, } - if _, err := fm.Store(post, 0); err != ErrPostTooLarge { + if _, err := fm.Store(post, 0); !errors.Is(err, ErrPostTooLarge) { t.Fatalf("Store huge post: got %v, want ErrPostTooLarge", err) } }