rob thijssen 91fd03a102
All checks were successful
deploy / build-web (push) Successful in 1m31s
deploy / deploy-web (push) Successful in 4s
deploy / build-api (push) Successful in 6m24s
deploy / deploy-api (push) Successful in 10s
feat(sources): feed discovery + OPML import for the RSS rail
Make adding RSS sources painless. Instead of requiring the exact feed URL,
a user can paste any page — a blog homepage, a YouTube channel, a subreddit,
a Mastodon profile — and the server resolves the concrete feed it advertises.
Plus OPML bulk-import to migrate a reader export or a YouTube subscriptions
list in one shot. No new SourceKind, no migration; this enriches the existing
worker-polled RSS rail.

New crate `newsfeed-fetch` — the one place that does outbound feed HTTP:
- probe(url): normalise (+ Reddit /.rss rewrite) -> fetch -> if it parses as a
  feed, done; else scan the HTML <head> for <link rel=alternate type=rss/atom>,
  resolve the relative href, and validate. Covers YouTube/Mastodon/Substack/
  WordPress/blogs, which all advertise their feed this way.
- parse_opml(): recurses nested outline folders.
- fetch_entries()/entry mapping moved here from the worker so both the API
  (discovery) and worker (polling) share a single HTTP+feed-rs path.

- core: add the FeedProbe port (kept I/O-free; adapter lives in newsfeed-fetch).
- api: AppState carries Arc<dyn FeedProbe>; POST /v1/sources resolves a pasted
  homepage to its feed; add POST /v1/sources/discover (preview) and
  /v1/sources/import (OPML, deduped by URL, per-feed failures collected).
- worker: delegate to newsfeed_fetch::fetch_entries; drop the sourcing/ module.
- web: Sources page gains paste-URL + "Find feed" preview, OPML file import
  with a summary, and a "polled Nm ago" hint per source; new client methods
  and regenerated ts-rs bindings.

Verified end-to-end locally: homepage URL -> discovered feed.xml + title;
create stores the resolved URL; OPML import added 1/skipped 1 dup; worker
polled and both items landed in the feed. fmt/clippy/test + web build/lint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
2026-07-08 14:09:21 +03:00

newsfeed

A self-hosted, user-controlled news feed. You decide what surfaces and how strongly — not an opaque engagement algorithm. It's built to replace the feeds you'd otherwise scroll (Google Discover, YouTube subscriptions, socials) with one you own end-to-end.

Content arrives two ways:

  • Algorithmically — the worker polls RSS/Atom sources you configure.
  • Agentically — external workloads POST candidates to an ingest endpoint using a per-user API token.

Everything is then ranked by weights you set: a baseline weight per source and a signed weight per interest (positive to lift, negative to bury), decayed by recency. The scoring is a transparent, deterministic function — see crates/newsfeed-core/src/ranking.rs.

Single-user today, multi-user by construction: every row is owned by a user_id and each user is wholly in control of, and isolated within, their own feed.

Architecture

A Rust cargo workspace (per architecture conventions) plus a Vite/React frontend:

crates/
  newsfeed-entities   domain types + DTOs (no I/O); source of the web's TS bindings
  newsfeed-core       business logic: ranking, auth primitives, ingest, data-access ports
  newsfeed-data       SQLite adapters implementing the core ports (sqlx)
  newsfeed-api        axum REST/JSON daemon  (bin)
  newsfeed-worker     RSS polling + rescoring loop  (bin)
web/                  Vite + React + SWC + TS SPA (responsive, mobile-first)
asset/                deployment artifacts (systemd, firewalld, nginx, config)
script/               infra-setup.sh (one-time host provisioning)
.gitea/workflows/     CI-driven deploy

Shared types. The web app's API types are generated from the Rust newsfeed-entities crate via ts-rs into web/src/api/bindings/. Regenerate with pnpm --dir web gen:types (or cargo test -p newsfeed-entities). Don't hand-edit the bindings.

Deliberate deviations from the house conventions

  • SQLite, not Postgres (generic.md §5 defaults to Postgres). Chosen for this app. Consequence: the api and worker co-locate on one host sharing a single DB file; the worker uses in-process scheduling, not the Postgres FOR UPDATE SKIP LOCKED pattern.
  • Runtime sqlx queries, not the query! macros (generic.md §5). SQLite's dynamic typing makes compile-time query checking low-value and high-friction; runtime queries keep CI database-free. Rationale is documented at the top of crates/newsfeed-data/src/lib.rs.

Build

Prerequisites: a stable Rust toolchain (see rust-toolchain.toml), Node ≥ 20, pnpm.

# backend
cargo build --workspace
cargo test  --workspace
cargo clippy --workspace --all-targets -- -D warnings

# frontend
cd web && pnpm install && pnpm build

Run locally

# 1. API (creates ./data/newsfeed.db, binds 127.0.0.1:22672)
NEWSFEED_DATABASE_PATH=./data/newsfeed.db \
NEWSFEED_BIND=127.0.0.1:22672 \
NEWSFEED_COOKIE_SECURE=false \
  cargo run -p newsfeed-api -- --config /nonexistent

# 2. Worker (same DB file), in another shell
NEWSFEED_DATABASE_PATH=./data/newsfeed.db \
  cargo run -p newsfeed-worker -- --config /nonexistent

# 3. Frontend dev server (proxies /v1 and /health to :22672)
cd web && pnpm dev

Config layers defaults → TOML file (--config) → NEWSFEED_* env. Passing a non-existent --config path just uses defaults + env, which is convenient for dev.

Try the flow

# register + login (cookie jar)
curl -c cj -X POST localhost:22672/v1/auth/register -H content-type:application/json \
  -d '{"username":"me","email":"me@example.com","password":"hunter2hunter2"}'
curl -c cj -X POST localhost:22672/v1/auth/login -H content-type:application/json \
  -d '{"identifier":"me","password":"hunter2hunter2"}'

# weight an interest, mint an ingest token
curl -b cj -X PUT  localhost:22672/v1/interests -H content-type:application/json -d '{"label":"rust","weight":0.9}'
TOKEN=$(curl -b cj -X POST localhost:22672/v1/tokens -H content-type:application/json -d '{"name":"agent"}' | jq -r .secret)

# an agent pushes a candidate; read the ranked feed
curl -X POST localhost:22672/v1/ingest/candidates -H "authorization: Bearer $TOKEN" \
  -H content-type:application/json -d '{"external_id":"a1","title":"Rust 2.0 released"}'
curl -b cj localhost:22672/v1/feed

API surface (/v1)

Method Path Auth Purpose
POST /auth/register create account
POST /auth/login open session (sets cookie)
POST /auth/logout cookie end session
GET /auth/me cookie current user
GET /feed cookie ranked, keyset-paginated feed
POST /feed/signals cookie record view/click/save/dismiss
GET/POST /sources · DELETE /sources/{id} cookie manage sources
GET/PUT /interests · DELETE /interests/{id} cookie manage weightings
GET/POST /tokens · DELETE /tokens/{id} cookie manage ingest tokens
POST /ingest/candidates bearer token submit a candidate (idempotent per external_id)
GET /health liveness/readiness

Deploy

CI-driven via Gitea Actions (.gitea/workflows/deploy.yml), following deployment-gitea-actions.md. Topology:

  • api + worker → slartibartfast.kosherinata.internal (share /var/lib/newsfeed/newsfeed.db)
  • SPA → oolon.kosherinata.internal — nginx serves /var/www/newsfeed and reverse-proxies /v1 + /health to the API. TLS terminates here; the API speaks plain HTTP behind firewalld on the mesh.

One-time per host: ./script/infra-setup.sh (creates the gitea_ci deploy user, scoped sudoers, the newsfeed service account, directories, the newsfeed.internal TLS cert + renewal, and the nginx vhost). Thereafter every push to main builds static musl binaries + the SPA bundle and rsyncs them to the targets.

Mesh users reach https://newsfeed.internal; public access is at https://rob.fyi. infra-setup.sh provisions both vhosts on oolon — the internal one (internal-CA cert) and the public one (Let's Encrypt via certbot + Cloudflare DNS-01, gated on the cert existing). The public cert needs the Cloudflare API token at /root/.certbot-internal on oolon; you still add the Cloudflare A record (rob.fyi → kosherinata WAN, unproxied) and the OPNsense :443 forward separately.

For the workspace-wide architectural conventions this project inherits, see the architecture repo.

Description
No description provided
Readme 327 KiB
Languages
Rust 70%
TypeScript 18.7%
Shell 7.3%
CSS 3.5%
JavaScript 0.3%
Other 0.2%