feat: scaffold newsfeed — user-controlled news feed
A self-hosted feed where the user owns the ranking: per-source and signed per-interest weights, decayed by recency, via a transparent deterministic scorer. Content is sourced algorithmically (worker RSS/Atom polling) and agentically (per-user API tokens POSTing candidates to the ingest endpoint). Single-user today, multi-user by construction (every row keyed on user_id). Rust cargo workspace + Vite/React/SWC/TS SPA: - newsfeed-entities: DTOs (ts-rs bindings -> web/src/api/bindings) - newsfeed-core: ranking, auth primitives, ingest, data-access ports - newsfeed-data: SQLite adapters (sqlx, runtime queries) - newsfeed-api: axum REST/JSON daemon - newsfeed-worker: RSS polling + rescoring loop - web: responsive, mobile-first SPA (React Query, generated types) Deploy (Gitea Actions, build static musl + SPA, rsync as gitea_ci): api+worker -> slartibartfast, SPA -> oolon (nginx serves + proxies /v1). Deliberate deviations from house conventions (documented in CLAUDE.md/readme): - SQLite instead of Postgres; api+worker co-locate sharing one DB file. - Runtime sqlx queries instead of query! macros (SQLite dynamic typing; keeps CI database-free). Verified end-to-end: auth, token ingest, interest-weighted ranking, signals, pagination (curl + browser); cargo fmt/clippy -D/test and pnpm build/lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Generated TypeScript bindings from `newsfeed-entities` land directly in the web app.
|
||||||
|
# Regenerate with: cargo test -p newsfeed-entities
|
||||||
|
# `relative = true` resolves the path against this config file's parent (the repo root).
|
||||||
|
[env]
|
||||||
|
TS_RS_EXPORT_DIR = { value = "web/src/api/bindings", relative = true }
|
||||||
159
.gitea/workflows/deploy.yml
Normal file
159
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# CI-driven deploy for newsfeed (architecture deployment-gitea-actions.md).
|
||||||
|
#
|
||||||
|
# The workflow is the source of infra truth — hosts/ports/paths live here, not in a
|
||||||
|
# manifest. Build-and-rsync model: build static musl binaries + the SPA bundle, then
|
||||||
|
# rsync straight to the targets as the scoped `gitea_ci` user.
|
||||||
|
#
|
||||||
|
# api + worker -> slartibartfast.kosherinata.internal (share one SQLite file)
|
||||||
|
# web (SPA) -> oolon.kosherinata.internal (nginx serves + proxies /v1)
|
||||||
|
#
|
||||||
|
# One-time host prep (gitea_ci user, sudoers, service account, dirs, cert, vhost) is done
|
||||||
|
# by script/infra-setup.sh. Only secret required here: RSYNC_SSH_KEY (the runner's private
|
||||||
|
# key). newsfeed has no app secrets — SQLite has no password and tokens are hashed at rest.
|
||||||
|
|
||||||
|
name: deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
API_HOST: slartibartfast.kosherinata.internal
|
||||||
|
WEB_HOST: oolon.kosherinata.internal
|
||||||
|
API_PORT: "8081"
|
||||||
|
TARGET: x86_64-unknown-linux-musl
|
||||||
|
SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# Toolchain runner: rustup with the musl target + musl-gcc, node+pnpm baked into the
|
||||||
|
# image (deployment-gitea-actions.md §5). Adjust the label to a registered runner.
|
||||||
|
runs-on: fedora-43
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# --- lint/test gate: a broken commit never deploys ---
|
||||||
|
- name: fmt
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
- name: clippy
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
- name: test
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
# --- build static binaries ---
|
||||||
|
- name: build binaries
|
||||||
|
run: |
|
||||||
|
rustup target add "$TARGET"
|
||||||
|
cargo build --release --target "$TARGET" -p newsfeed-api -p newsfeed-worker
|
||||||
|
mkdir -p dist/bin
|
||||||
|
cp "target/$TARGET/release/newsfeed-api" dist/bin/
|
||||||
|
cp "target/$TARGET/release/newsfeed-worker" dist/bin/
|
||||||
|
|
||||||
|
# --- build SPA bundle ---
|
||||||
|
- name: build web
|
||||||
|
working-directory: web
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: binaries
|
||||||
|
path: dist/bin/
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: web
|
||||||
|
path: web/dist/
|
||||||
|
|
||||||
|
deploy-api:
|
||||||
|
needs: build
|
||||||
|
runs-on: fedora-43
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: binaries
|
||||||
|
path: bin
|
||||||
|
- name: write ssh key
|
||||||
|
run: |
|
||||||
|
install -d -m 0700 ~/.ssh
|
||||||
|
echo "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_deploy
|
||||||
|
chmod 0600 ~/.ssh/id_deploy
|
||||||
|
- name: reachability check
|
||||||
|
run: ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$API_HOST" hostname -f
|
||||||
|
|
||||||
|
- name: rsync binaries
|
||||||
|
run: |
|
||||||
|
chmod +x bin/*
|
||||||
|
for b in newsfeed-api newsfeed-worker; do
|
||||||
|
rsync -az -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \
|
||||||
|
--rsync-path='sudo rsync' --chown=root:root --chmod=0755 --mkpath \
|
||||||
|
"bin/$b" gitea_ci@"$API_HOST":/usr/local/bin/"$b"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: rsync config
|
||||||
|
run: |
|
||||||
|
rsync -az -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \
|
||||||
|
--rsync-path='sudo rsync' --chown=root:newsfeed --chmod=0640 --mkpath \
|
||||||
|
asset/config/config.toml.tmpl gitea_ci@"$API_HOST":/etc/newsfeed/config.toml
|
||||||
|
rsync -az -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \
|
||||||
|
--rsync-path='sudo rsync' --chown=root:newsfeed --chmod=0640 --mkpath \
|
||||||
|
asset/config/worker.toml.tmpl gitea_ci@"$API_HOST":/etc/newsfeed/worker.toml
|
||||||
|
|
||||||
|
- name: apply system state
|
||||||
|
run: |
|
||||||
|
ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$API_HOST" '
|
||||||
|
sudo restorecon -R /usr/local/bin/newsfeed-api /usr/local/bin/newsfeed-worker /etc/newsfeed /var/lib/newsfeed
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart newsfeed-api.service
|
||||||
|
sudo systemctl restart newsfeed-worker.service
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: health probe
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
code=$(curl -fsS -o /dev/null -w '%{http_code}' "http://$API_HOST:$API_PORT/health") && [ "$code" = 200 ] && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "api did not become healthy" >&2; exit 1
|
||||||
|
|
||||||
|
- name: capture journal
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$API_HOST" \
|
||||||
|
'journalctl -u newsfeed-api.service -u newsfeed-worker.service -n 80 --no-pager' || true
|
||||||
|
|
||||||
|
deploy-web:
|
||||||
|
needs: build
|
||||||
|
runs-on: fedora-43
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: web
|
||||||
|
path: dist
|
||||||
|
- name: write ssh key
|
||||||
|
run: |
|
||||||
|
install -d -m 0700 ~/.ssh
|
||||||
|
echo "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_deploy
|
||||||
|
chmod 0600 ~/.ssh/id_deploy
|
||||||
|
- name: reachability check
|
||||||
|
run: ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$WEB_HOST" hostname -f
|
||||||
|
|
||||||
|
- name: rsync SPA
|
||||||
|
run: |
|
||||||
|
rsync -az --delete -e "ssh $SSH_OPTS -i ~/.ssh/id_deploy" \
|
||||||
|
--rsync-path='sudo rsync' --chown=nginx:nginx --mkpath \
|
||||||
|
dist/ gitea_ci@"$WEB_HOST":/var/www/newsfeed/
|
||||||
|
|
||||||
|
- name: relabel + reload nginx
|
||||||
|
run: |
|
||||||
|
ssh $SSH_OPTS -i ~/.ssh/id_deploy gitea_ci@"$WEB_HOST" '
|
||||||
|
sudo restorecon -R /var/www/newsfeed
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx.service
|
||||||
|
'
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# rust
|
||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# local dev database (sqlx offline cache in .sqlx/ IS committed)
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
/data
|
||||||
|
|
||||||
|
# env / secrets — never commit rendered config
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# node
|
||||||
|
node_modules
|
||||||
|
web/dist
|
||||||
|
web/.vite
|
||||||
|
|
||||||
|
# editor / os
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# CLAUDE.md — newsfeed
|
||||||
|
|
||||||
|
Agent-facing notes for working in this repo. Read the workspace-wide conventions in
|
||||||
|
`~/git/architecture/*.md` first; this file records only what's specific to newsfeed,
|
||||||
|
especially the deliberate deviations.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A self-hosted, user-controlled news feed. The core idea: the user owns the ranking. Never
|
||||||
|
introduce an implicit/opaque signal that overrides the user's explicit weights. Signals
|
||||||
|
(view/click/save/dismiss) may *inform* future ranking but must remain subordinate to the
|
||||||
|
per-source and per-interest weights the user sets.
|
||||||
|
|
||||||
|
## Layout & boundaries (strict)
|
||||||
|
|
||||||
|
- `newsfeed-entities` — types/DTOs only, no I/O. Wire DTOs derive `ts_rs::TS` with
|
||||||
|
`#[ts(export)]`; `cargo test -p newsfeed-entities` regenerates `web/src/api/bindings/`
|
||||||
|
(path set by `.cargo/config.toml`'s `TS_RS_EXPORT_DIR`). Don't hand-edit bindings.
|
||||||
|
- `newsfeed-core` — business logic + data-access **ports** (traits). Pure where possible
|
||||||
|
(ranking, auth hashing). No DB or network calls.
|
||||||
|
- `newsfeed-data` — SQLite **adapters** implementing the core ports.
|
||||||
|
- `newsfeed-api` / `newsfeed-worker` — thin binaries; wire config/logging/signals and the
|
||||||
|
concrete store. No business logic that could live in a library crate.
|
||||||
|
|
||||||
|
New types → entities. New logic → core. New I/O → data. Add a port to
|
||||||
|
`core::ports`, implement it in `data::store`.
|
||||||
|
|
||||||
|
## Deliberate deviations from `generic.md`
|
||||||
|
|
||||||
|
1. **SQLite instead of Postgres (§5).** Explicit project choice. Because SQLite is
|
||||||
|
single-file/single-host, **api and worker co-locate** and share `/var/lib/newsfeed/newsfeed.db`.
|
||||||
|
There is no central DB cluster, no mTLS DB auth, no `pg_ident` mapping. The worker does
|
||||||
|
in-process scheduling — the `FOR UPDATE SKIP LOCKED` guidance does not apply.
|
||||||
|
2. **Runtime sqlx queries, not `query!` macros (§5).** SQLite's dynamic typing makes
|
||||||
|
compile-time checking low-value and forces a live DB or fiddly offline cache into CI.
|
||||||
|
We use `sqlx::query`/`query_as` with `FromRow` row structs (see `data/src/rows.rs`) and
|
||||||
|
map to entities explicitly. No `.sqlx/` cache; CI builds need no database.
|
||||||
|
|
||||||
|
Both are documented at their site (`data/src/lib.rs` header, `readme.md`) — keep them in
|
||||||
|
sync if you change the approach.
|
||||||
|
|
||||||
|
## Datastore conventions
|
||||||
|
|
||||||
|
TEXT UUIDs (hyphenated), RFC3339 timestamps, INTEGER booleans, JSON-in-TEXT for small
|
||||||
|
collections (`tags`, `media`). Migrations are immutable once committed (§5): add a new
|
||||||
|
`crates/newsfeed-data/migrations/NNNN_*.sql`, never edit a landed one. `RETURNING` and
|
||||||
|
`ON CONFLICT` are used, so the target needs SQLite ≥ 3.35.
|
||||||
|
|
||||||
|
## Auth model
|
||||||
|
|
||||||
|
- Humans: Argon2id password hash + opaque session cookie (`nf_session`), hash stored.
|
||||||
|
- Producers: per-user bearer API tokens (`nf_<prefix>_<random>`), SHA-256 hashed at rest,
|
||||||
|
shown once. Ingest is attributed to the token's owner.
|
||||||
|
Crypto/token logic lives in `core::auth`; the flow orchestration in `core::service`.
|
||||||
|
|
||||||
|
## Deploy topology
|
||||||
|
|
||||||
|
api + worker → `slartibartfast.kosherinata.internal`; SPA → `oolon.kosherinata.internal`
|
||||||
|
(nginx serves + proxies `/v1` to the API over the mesh; TLS terminates at oolon). API
|
||||||
|
port 8081, plain HTTP behind firewalld. See `.gitea/workflows/deploy.yml` (infra truth)
|
||||||
|
and `script/infra-setup.sh` (one-time host prep). `Type=exec`, not `notify`, because axum
|
||||||
|
doesn't `sd_notify`.
|
||||||
|
|
||||||
|
## Before you commit
|
||||||
|
|
||||||
|
- `cargo fmt --all` · `cargo clippy --workspace --all-targets -- -D warnings` · `cargo test --workspace`
|
||||||
|
- `pnpm --dir web build` (tsc typecheck + vite) · `pnpm --dir web lint`
|
||||||
|
- If you touched an entity DTO, regenerate bindings and include them in the commit.
|
||||||
|
- Conventional Commits; scope = crate/area (`feat(api):`, `fix(core):`, …).
|
||||||
3312
Cargo.lock
generated
Normal file
3312
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = ["crates/*"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
authors = ["Rob Thijssen <rob@example>"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# internal crates
|
||||||
|
newsfeed-entities = { path = "crates/newsfeed-entities", version = "=0.1.0" }
|
||||||
|
newsfeed-core = { path = "crates/newsfeed-core", version = "=0.1.0" }
|
||||||
|
newsfeed-data = { path = "crates/newsfeed-data", version = "=0.1.0" }
|
||||||
|
|
||||||
|
# async runtime + web
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
axum = { version = "0.8", features = ["macros", "ws"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip"] }
|
||||||
|
|
||||||
|
# data
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = [
|
||||||
|
"sqlite",
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"macros",
|
||||||
|
"migrate",
|
||||||
|
"chrono",
|
||||||
|
"uuid",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# serde / types
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
ts-rs = { version = "10", features = ["chrono-impl", "uuid-impl", "serde-json-impl"] }
|
||||||
|
|
||||||
|
# traits
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# errors / logging / config
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
figment = { version = "0.10", features = ["toml", "env"] }
|
||||||
|
|
||||||
|
# auth / crypto
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# worker: feed sourcing
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "json"] }
|
||||||
|
feed-rs = "2"
|
||||||
|
|
||||||
|
# cli niceties
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
20
asset/config/config.toml.tmpl
Normal file
20
asset/config/config.toml.tmpl
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# newsfeed-api configuration (production). Rendered to /etc/newsfeed/config.toml.
|
||||||
|
#
|
||||||
|
# This file contains no secrets — newsfeed uses SQLite (no DB password), and session
|
||||||
|
# cookies / API tokens are random values hashed at rest (no signing key). It is therefore
|
||||||
|
# committed as concrete values, not {{PLACEHOLDER}} templates. Env (NEWSFEED_*) still
|
||||||
|
# overrides any value at runtime.
|
||||||
|
|
||||||
|
# Bind the mesh-routable address so the oolon proxy can reach it. firewalld
|
||||||
|
# (newsfeed-api service) bounds who may connect; TLS terminates at oolon.
|
||||||
|
bind = "0.0.0.0:8081"
|
||||||
|
|
||||||
|
database_path = "/var/lib/newsfeed/newsfeed.db"
|
||||||
|
session_ttl_days = 30
|
||||||
|
max_db_connections = 5
|
||||||
|
|
||||||
|
# Same-origin in production (oolon serves the SPA and proxies the API), so no CORS.
|
||||||
|
cors_origins = []
|
||||||
|
|
||||||
|
# Cookies are only sent over HTTPS (the browser talks to oolon over TLS).
|
||||||
|
cookie_secure = true
|
||||||
16
asset/config/worker.toml.tmpl
Normal file
16
asset/config/worker.toml.tmpl
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# newsfeed-worker configuration (production). Rendered to /etc/newsfeed/worker.toml.
|
||||||
|
# No secrets (see config.toml.tmpl).
|
||||||
|
|
||||||
|
database_path = "/var/lib/newsfeed/newsfeed.db"
|
||||||
|
max_db_connections = 2
|
||||||
|
|
||||||
|
# Poll cadence.
|
||||||
|
tick_secs = 60
|
||||||
|
# Don't re-poll the same RSS source more often than this.
|
||||||
|
source_min_interval_secs = 900
|
||||||
|
# Sources polled per cycle, and items rescored per user per cycle.
|
||||||
|
batch = 20
|
||||||
|
rescore_limit = 500
|
||||||
|
|
||||||
|
http_timeout_secs = 20
|
||||||
|
user_agent = "newsfeed-worker (+https://git.lair.cafe/grenade/newsfeed)"
|
||||||
7
asset/firewalld/newsfeed-api.xml
Normal file
7
asset/firewalld/newsfeed-api.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<service>
|
||||||
|
<short>newsfeed-api</short>
|
||||||
|
<description>newsfeed REST/JSON API. Reached over the WireGuard mesh by the oolon edge
|
||||||
|
proxy, which terminates TLS and reverse-proxies to this port.</description>
|
||||||
|
<port protocol="tcp" port="8081"/>
|
||||||
|
</service>
|
||||||
53
asset/nginx/newsfeed.internal.conf
Normal file
53
asset/nginx/newsfeed.internal.conf
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Mesh-only vhost for newsfeed, fronted by the oolon edge proxy (kosherinata site).
|
||||||
|
# Cert: internal `lair` CA, minted by infra-setup.sh and renewed by step@newsfeed.timer
|
||||||
|
# (see architecture internal-tls.md). Serves the static SPA and reverse-proxies the API
|
||||||
|
# to the newsfeed-api daemon on slartibartfast over the WireGuard mesh.
|
||||||
|
#
|
||||||
|
# Enable with a relative symlink into sites-enabled (reverse-proxies.md §4):
|
||||||
|
# ln -sf ../sites-available/newsfeed.internal.conf /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
upstream newsfeed_api {
|
||||||
|
server slartibartfast.kosherinata.internal:8081;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name newsfeed.internal;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/tls/cert/newsfeed.internal.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/tls/key/newsfeed.internal.pem;
|
||||||
|
ssl_protocols TLSv1.3;
|
||||||
|
ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem;
|
||||||
|
|
||||||
|
root /var/www/newsfeed;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API + health probe → newsfeed-api on slartibartfast.
|
||||||
|
location /v1/ {
|
||||||
|
proxy_pass http://newsfeed_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# For future WebSocket streaming endpoints, add the standard Upgrade/Connection
|
||||||
|
# map here (define `$connection_upgrade` once in http context).
|
||||||
|
}
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://newsfeed_api;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static SPA with client-side routing fallback.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Long-cache the fingerprinted asset bundle.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
asset/nginx/newsfeed.public.conf
Normal file
45
asset/nginx/newsfeed.public.conf
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Public (WAN) vhost for newsfeed, fronted by the oolon edge proxy.
|
||||||
|
#
|
||||||
|
# Provision the Let's Encrypt cert for rob.fyi (architecture external-tls.md — certbot,
|
||||||
|
# Cloudflare DNS-01, ECDSA) BEFORE enabling this vhost: `nginx -t` fails on a missing
|
||||||
|
# ssl_certificate, so only symlink this into sites-enabled once the cert exists
|
||||||
|
# (reverse-proxies.md §4).
|
||||||
|
#
|
||||||
|
# Mesh clients cannot use this name (the public-name hairpin gotcha, reverse-proxies.md
|
||||||
|
# §2) — they use newsfeed.internal instead. Both vhosts share the /var/www/newsfeed
|
||||||
|
# webroot and the newsfeed_api upstream defined in newsfeed.internal.conf.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name rob.fyi;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/rob.fyi/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/rob.fyi/privkey.pem;
|
||||||
|
# Classical curves for WAN clients that don't speak post-quantum yet (generic.md §11).
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
root /var/www/newsfeed;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /v1/ {
|
||||||
|
proxy_pass http://newsfeed_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://newsfeed_api;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
asset/systemd/newsfeed-api.service
Normal file
38
asset/systemd/newsfeed-api.service
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=newsfeed REST/JSON API
|
||||||
|
Documentation=https://git.lair.cafe/grenade/newsfeed
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Type=exec (not notify): axum does not sd_notify READY=1, so notify would block until
|
||||||
|
# TimeoutStartSec. exec treats the unit as started once the binary execs successfully.
|
||||||
|
Type=exec
|
||||||
|
User=newsfeed
|
||||||
|
Group=newsfeed
|
||||||
|
ExecStart=/usr/local/bin/newsfeed-api --config /etc/newsfeed/config.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
# Hardening (architecture generic.md §8). Relax individually only if a feature needs it.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
|
||||||
|
# The SQLite database and WAL live here; shared read/write with newsfeed-worker.
|
||||||
|
ReadWritePaths=/var/lib/newsfeed
|
||||||
|
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
36
asset/systemd/newsfeed-worker.service
Normal file
36
asset/systemd/newsfeed-worker.service
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=newsfeed content-sourcing worker (RSS/Atom polling + rescoring)
|
||||||
|
Documentation=https://git.lair.cafe/grenade/newsfeed
|
||||||
|
After=network-online.target newsfeed-api.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=newsfeed
|
||||||
|
Group=newsfeed
|
||||||
|
ExecStart=/usr/local/bin/newsfeed-worker --config /etc/newsfeed/worker.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening (architecture generic.md §8).
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
|
||||||
|
# Same database file as the api (co-located: SQLite is single-host).
|
||||||
|
ReadWritePaths=/var/lib/newsfeed
|
||||||
|
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
2
asset/systemd/newsfeed.sysusers.conf
Normal file
2
asset/systemd/newsfeed.sysusers.conf
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#Type Name ID GECOS Home directory Shell
|
||||||
|
u newsfeed - "newsfeed service account" /var/lib/newsfeed /usr/sbin/nologin
|
||||||
34
crates/newsfeed-api/Cargo.toml
Normal file
34
crates/newsfeed-api/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[package]
|
||||||
|
name = "newsfeed-api"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "REST/JSON API daemon for newsfeed (axum)."
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "newsfeed-api"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
newsfeed-entities.workspace = true
|
||||||
|
newsfeed-core.workspace = true
|
||||||
|
newsfeed-data.workspace = true
|
||||||
|
|
||||||
|
tokio.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
thiserror.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
figment.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
98
crates/newsfeed-api/src/auth.rs
Normal file
98
crates/newsfeed-api/src/auth.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! Authentication extractors and cookie helpers.
|
||||||
|
//!
|
||||||
|
//! [`CurrentUser`] authenticates a browser via the `nf_session` cookie; [`ApiPrincipal`]
|
||||||
|
//! authenticates an agentic/algorithmic producer via a `Bearer` API token. Both resolve
|
||||||
|
//! through `newsfeed-core::service`, so the crypto and lookup live in one place.
|
||||||
|
|
||||||
|
use axum::extract::FromRef;
|
||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::http::header::{AUTHORIZATION, COOKIE};
|
||||||
|
use axum::http::request::Parts;
|
||||||
|
|
||||||
|
use newsfeed_core::service;
|
||||||
|
use newsfeed_entities::auth::ApiTokenInfo;
|
||||||
|
use newsfeed_entities::user::User;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Name of the session cookie.
|
||||||
|
pub const SESSION_COOKIE: &str = "nf_session";
|
||||||
|
|
||||||
|
/// Build a `Set-Cookie` header value for a freshly minted session.
|
||||||
|
pub fn session_cookie(value: &str, ttl_days: i64, secure: bool) -> String {
|
||||||
|
let max_age = ttl_days.max(0) * 24 * 60 * 60;
|
||||||
|
let mut c =
|
||||||
|
format!("{SESSION_COOKIE}={value}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}");
|
||||||
|
if secure {
|
||||||
|
c.push_str("; Secure");
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `Set-Cookie` header value that clears the session cookie.
|
||||||
|
pub fn clear_session_cookie(secure: bool) -> String {
|
||||||
|
let mut c = format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0");
|
||||||
|
if secure {
|
||||||
|
c.push_str("; Secure");
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the value of a named cookie from a `Cookie` header.
|
||||||
|
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
|
||||||
|
let header = parts.headers.get(COOKIE)?.to_str().ok()?;
|
||||||
|
header.split(';').find_map(|pair| {
|
||||||
|
let (k, v) = pair.trim().split_once('=')?;
|
||||||
|
(k == name).then_some(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a `Bearer` token from the `Authorization` header.
|
||||||
|
fn bearer(parts: &Parts) -> Option<&str> {
|
||||||
|
let header = parts.headers.get(AUTHORIZATION)?.to_str().ok()?;
|
||||||
|
header.strip_prefix("Bearer ").map(str::trim)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authenticated human user, resolved from the session cookie.
|
||||||
|
pub struct CurrentUser(pub User);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for CurrentUser
|
||||||
|
where
|
||||||
|
AppState: FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let app = AppState::from_ref(state);
|
||||||
|
let value = cookie_value(parts, SESSION_COOKIE).ok_or_else(ApiError::unauthorized)?;
|
||||||
|
let user = service::authenticate_session(app.store.as_ref(), value).await?;
|
||||||
|
Ok(CurrentUser(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authenticated API-token principal (agentic/algorithmic producer).
|
||||||
|
pub struct ApiPrincipal(pub ApiTokenInfo);
|
||||||
|
|
||||||
|
impl ApiPrincipal {
|
||||||
|
/// The user the token belongs to.
|
||||||
|
pub fn user_id(&self) -> uuid::Uuid {
|
||||||
|
self.0.user_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for ApiPrincipal
|
||||||
|
where
|
||||||
|
AppState: FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let app = AppState::from_ref(state);
|
||||||
|
let token = bearer(parts).ok_or_else(ApiError::unauthorized)?;
|
||||||
|
let info = service::authenticate_token(app.store.as_ref(), token).await?;
|
||||||
|
Ok(ApiPrincipal(info))
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/newsfeed-api/src/config.rs
Normal file
59
crates/newsfeed-api/src/config.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//! Layered configuration: built-in defaults → optional TOML file → environment
|
||||||
|
//! (`NEWSFEED_*`). Matches the figment-style layering in architecture `generic.md` §3.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use figment::Figment;
|
||||||
|
use figment::providers::{Env, Format, Serialized, Toml};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// API daemon configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Address to bind the HTTP listener. TLS terminates upstream at the site nginx.
|
||||||
|
pub bind: SocketAddr,
|
||||||
|
/// Path to the SQLite database file (shared with the worker on the same host).
|
||||||
|
pub database_path: PathBuf,
|
||||||
|
/// Session lifetime in days.
|
||||||
|
pub session_ttl_days: i64,
|
||||||
|
/// Maximum SQLite connections in the pool.
|
||||||
|
pub max_db_connections: u32,
|
||||||
|
/// Allowed CORS origins. Empty means same-origin only (the production shape, where
|
||||||
|
/// nginx serves the SPA and proxies the API under one origin).
|
||||||
|
pub cors_origins: Vec<String>,
|
||||||
|
/// Set `Secure` on the session cookie. True in production (HTTPS via nginx); can be
|
||||||
|
/// disabled for plain-HTTP local development.
|
||||||
|
pub cookie_secure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bind: "127.0.0.1:8081".parse().expect("valid default bind addr"),
|
||||||
|
database_path: PathBuf::from("/var/lib/newsfeed/newsfeed.db"),
|
||||||
|
session_ttl_days: 30,
|
||||||
|
max_db_connections: 5,
|
||||||
|
cors_origins: Vec::new(),
|
||||||
|
cookie_secure: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Load configuration, layering an optional TOML file and the environment over the
|
||||||
|
/// defaults. Env vars are `NEWSFEED_` prefixed (e.g. `NEWSFEED_BIND=0.0.0.0:8081`).
|
||||||
|
pub fn load(file: Option<&Path>) -> anyhow::Result<Self> {
|
||||||
|
let mut fig = Figment::from(Serialized::defaults(Config::default()));
|
||||||
|
if let Some(path) = file {
|
||||||
|
fig = fig.merge(Toml::file(path));
|
||||||
|
}
|
||||||
|
let cfg: Config = fig.merge(Env::prefixed("NEWSFEED_")).extract()?;
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session lifetime as a `chrono::Duration`.
|
||||||
|
pub fn session_ttl(&self) -> chrono::Duration {
|
||||||
|
chrono::Duration::days(self.session_ttl_days)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
crates/newsfeed-api/src/error.rs
Normal file
81
crates/newsfeed-api/src/error.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//! HTTP error type. Maps [`CoreError`] and domain validation onto status codes and a
|
||||||
|
//! small JSON error envelope.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreError;
|
||||||
|
|
||||||
|
/// An API error carrying an HTTP status and a client-safe message.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub status: StatusCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unauthorized() -> Self {
|
||||||
|
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found() -> Self {
|
||||||
|
Self::new(StatusCode::NOT_FOUND, "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::BAD_REQUEST, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorBody {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
// Log server-side faults; client errors are self-explanatory.
|
||||||
|
if self.status.is_server_error() {
|
||||||
|
tracing::error!(status = %self.status, message = %self.message, "request failed");
|
||||||
|
}
|
||||||
|
(
|
||||||
|
self.status,
|
||||||
|
Json(ErrorBody {
|
||||||
|
error: self.message,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CoreError> for ApiError {
|
||||||
|
fn from(e: CoreError) -> Self {
|
||||||
|
match e {
|
||||||
|
CoreError::Domain(d) => ApiError::bad_request(d.to_string()),
|
||||||
|
CoreError::NotFound => ApiError::not_found(),
|
||||||
|
CoreError::Conflict(m) => ApiError::new(StatusCode::CONFLICT, m),
|
||||||
|
CoreError::Unauthorized => ApiError::unauthorized(),
|
||||||
|
CoreError::Crypto(_) | CoreError::Storage(_) => {
|
||||||
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<newsfeed_entities::Error> for ApiError {
|
||||||
|
fn from(e: newsfeed_entities::Error) -> Self {
|
||||||
|
ApiError::bad_request(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenient result alias for handlers.
|
||||||
|
pub type ApiResult<T> = Result<T, ApiError>;
|
||||||
100
crates/newsfeed-api/src/main.rs
Normal file
100
crates/newsfeed-api/src/main.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
//! newsfeed REST/JSON API daemon.
|
||||||
|
//!
|
||||||
|
//! Thin binary: parse CLI/config, initialise tracing, open the SQLite store (shared with
|
||||||
|
//! the worker on the same host), build the axum router, and serve until SIGTERM.
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod routes;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use clap::Parser;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::signal;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "newsfeed-api",
|
||||||
|
version,
|
||||||
|
about = "newsfeed REST/JSON API daemon"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to a TOML config file. Env (`NEWSFEED_*`) overrides file values.
|
||||||
|
#[arg(long, default_value = "/etc/newsfeed/config.toml")]
|
||||||
|
config: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config_file = cli.config.exists().then_some(cli.config.as_path());
|
||||||
|
let config = Config::load(config_file).context("loading configuration")?;
|
||||||
|
tracing::info!(bind = %config.bind, db = %config.database_path.display(), "starting newsfeed-api");
|
||||||
|
|
||||||
|
if let Some(parent) = config.database_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
let store = newsfeed_data::connect(&config.database_path, config.max_db_connections)
|
||||||
|
.await
|
||||||
|
.context("opening database")?;
|
||||||
|
|
||||||
|
let bind = config.bind;
|
||||||
|
let state = AppState::new(store, config);
|
||||||
|
let app = routes::router(state);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(bind)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding {bind}"))?;
|
||||||
|
tracing::info!(%bind, "listening");
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
.context("server error")?;
|
||||||
|
tracing::info!("shut down cleanly");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON logs when running under journald (`JOURNAL_STREAM` set), pretty logs on a TTY.
|
||||||
|
fn init_tracing() {
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let registry = tracing_subscriber::registry().with(filter);
|
||||||
|
if std::env::var_os("JOURNAL_STREAM").is_some() {
|
||||||
|
registry
|
||||||
|
.with(fmt::layer().json().flatten_event(true))
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
registry.with(fmt::layer()).init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve on SIGTERM (systemd) or Ctrl-C.
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
signal::ctrl_c().await.expect("install Ctrl-C handler");
|
||||||
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
tracing::info!("shutdown signal received");
|
||||||
|
}
|
||||||
83
crates/newsfeed-api/src/routes/auth_routes.rs
Normal file
83
crates/newsfeed-api/src/routes/auth_routes.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//! Registration, login, logout, and the current-principal endpoint.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderMap, StatusCode, header};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
use newsfeed_core::service;
|
||||||
|
use newsfeed_entities::auth::Me;
|
||||||
|
use newsfeed_entities::user::{LoginRequest, RegisterRequest, User};
|
||||||
|
|
||||||
|
use crate::auth::{CurrentUser, clear_session_cookie, session_cookie};
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// `POST /v1/auth/register` — create an account. Does not log the user in.
|
||||||
|
pub async fn register(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Json(req): Json<RegisterRequest>,
|
||||||
|
) -> ApiResult<impl IntoResponse> {
|
||||||
|
let user = service::register(app.store.as_ref(), &req).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(to_me(user))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/auth/login` — authenticate and open a session, setting the cookie.
|
||||||
|
pub async fn login(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> ApiResult<impl IntoResponse> {
|
||||||
|
let outcome = service::login(app.store.as_ref(), &req, app.config.session_ttl()).await?;
|
||||||
|
let cookie = session_cookie(
|
||||||
|
&outcome.session_cookie,
|
||||||
|
app.config.session_ttl_days,
|
||||||
|
app.config.cookie_secure,
|
||||||
|
);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
cookie.parse().expect("valid cookie header"),
|
||||||
|
);
|
||||||
|
Ok((headers, Json(to_me(outcome.user))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/auth/logout` — end the current session and clear the cookie.
|
||||||
|
pub async fn logout(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> ApiResult<impl IntoResponse> {
|
||||||
|
if let Some(value) = session_cookie_value(&headers) {
|
||||||
|
service::logout(app.store.as_ref(), &value).await?;
|
||||||
|
}
|
||||||
|
let mut out = HeaderMap::new();
|
||||||
|
out.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
clear_session_cookie(app.config.cookie_secure)
|
||||||
|
.parse()
|
||||||
|
.expect("valid cookie header"),
|
||||||
|
);
|
||||||
|
Ok((StatusCode::NO_CONTENT, out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/auth/me` — the authenticated user.
|
||||||
|
pub async fn me(CurrentUser(user): CurrentUser) -> Json<Me> {
|
||||||
|
Json(to_me(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_me(user: User) -> Me {
|
||||||
|
Me {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the raw session cookie value from request headers (logout, before the session is
|
||||||
|
/// resolved).
|
||||||
|
fn session_cookie_value(headers: &HeaderMap) -> Option<String> {
|
||||||
|
let header = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||||
|
header.split(';').find_map(|pair| {
|
||||||
|
let (k, v) = pair.trim().split_once('=')?;
|
||||||
|
(k == crate::auth::SESSION_COOKIE).then(|| v.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
77
crates/newsfeed-api/src/routes/feed.rs
Normal file
77
crates/newsfeed-api/src/routes/feed.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//! The feed read endpoint and interaction signals.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use newsfeed_core::ports::ItemStore;
|
||||||
|
use newsfeed_entities::feed::{FeedPage, FeedQuery, Signal, SignalAction};
|
||||||
|
use newsfeed_entities::item::ItemState;
|
||||||
|
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT: i64 = 50;
|
||||||
|
const MAX_LIMIT: i64 = 100;
|
||||||
|
|
||||||
|
/// `GET /v1/feed` — a ranked, keyset-paginated page of the user's feed.
|
||||||
|
pub async fn get_feed(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Query(q): Query<FeedQuery>,
|
||||||
|
) -> ApiResult<Json<FeedPage>> {
|
||||||
|
let limit = q
|
||||||
|
.limit
|
||||||
|
.map(|l| l as i64)
|
||||||
|
.unwrap_or(DEFAULT_LIMIT)
|
||||||
|
.clamp(1, MAX_LIMIT);
|
||||||
|
|
||||||
|
// Fetch one extra to determine whether a further page exists.
|
||||||
|
let mut items = app
|
||||||
|
.store
|
||||||
|
.feed_page(user.id, limit + 1, q.cursor.as_deref(), q.include_saved)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let next_cursor = if items.len() as i64 > limit {
|
||||||
|
items.truncate(limit as usize);
|
||||||
|
items.last().map(|it| format!("{}:{}", it.score, it.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(FeedPage { items, next_cursor }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/feed/signals` — record an interaction. Save/Dismiss also move the item's
|
||||||
|
/// lifecycle state; View/Click are recorded for future ranking feedback.
|
||||||
|
pub async fn post_signal(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Json(sig): Json<Signal>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
// The item must belong to the caller.
|
||||||
|
if app.store.get_item(user.id, sig.item_id).await?.is_none() {
|
||||||
|
return Err(ApiError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.store
|
||||||
|
.record_signal(user.id, sig.item_id, sig.action)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match sig.action {
|
||||||
|
SignalAction::Save => {
|
||||||
|
app.store
|
||||||
|
.set_item_state(user.id, sig.item_id, ItemState::Saved)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
SignalAction::Dismiss => {
|
||||||
|
app.store
|
||||||
|
.set_item_state(user.id, sig.item_id, ItemState::Dismissed)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
SignalAction::View | SignalAction::Click => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
15
crates/newsfeed-api/src/routes/health.rs
Normal file
15
crates/newsfeed-api/src/routes/health.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//! Liveness/readiness probe for systemd and the nginx upstream check.
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// `GET /health` — 200 when the database is reachable, 503 otherwise.
|
||||||
|
pub async fn health(State(app): State<AppState>) -> StatusCode {
|
||||||
|
if app.store.ping().await {
|
||||||
|
StatusCode::OK
|
||||||
|
} else {
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/newsfeed-api/src/routes/ingest.rs
Normal file
29
crates/newsfeed-api/src/routes/ingest.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//! The inbound curation endpoint. Agentic and algorithmic producers authenticate with a
|
||||||
|
//! per-user bearer API token and POST content candidates; each is normalised, scored
|
||||||
|
//! against the owning user's interests, and promoted into their feed.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use newsfeed_core::ingest;
|
||||||
|
use newsfeed_core::ports::InterestStore;
|
||||||
|
use newsfeed_entities::item::{CandidateSubmission, ContentItem};
|
||||||
|
|
||||||
|
use crate::auth::ApiPrincipal;
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// `POST /v1/ingest/candidates` — submit a candidate for curation into the token owner's
|
||||||
|
/// feed. Re-submitting an already-seen `external_id` returns the stored item unchanged
|
||||||
|
/// (idempotent), so producers can safely retry.
|
||||||
|
pub async fn submit(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Json(sub): Json<CandidateSubmission>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<ContentItem>)> {
|
||||||
|
let user_id = principal.user_id();
|
||||||
|
let interests = app.store.list_interests(user_id).await?;
|
||||||
|
let item = ingest::ingest_submission(app.store.as_ref(), user_id, &interests, sub).await?;
|
||||||
|
Ok((StatusCode::ACCEPTED, Json(item)))
|
||||||
|
}
|
||||||
41
crates/newsfeed-api/src/routes/interests.rs
Normal file
41
crates/newsfeed-api/src/routes/interests.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//! The user-controlled interest weightings that drive ranking.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::ports::InterestStore;
|
||||||
|
use newsfeed_entities::interest::{Interest, UpsertInterest};
|
||||||
|
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// `GET /v1/interests` — the user's interests, strongest first.
|
||||||
|
pub async fn list(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<Interest>>> {
|
||||||
|
Ok(Json(app.store.list_interests(user.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /v1/interests` — create or update an interest by label.
|
||||||
|
pub async fn upsert(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Json(body): Json<UpsertInterest>,
|
||||||
|
) -> ApiResult<Json<Interest>> {
|
||||||
|
body.validate()?;
|
||||||
|
Ok(Json(app.store.upsert_interest(user.id, &body).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /v1/interests/{id}` — remove an interest.
|
||||||
|
pub async fn remove(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
app.store.delete_interest(user.id, id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
74
crates/newsfeed-api/src/routes/mod.rs
Normal file
74
crates/newsfeed-api/src/routes/mod.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//! Router assembly. Public API is versioned under `/v1` from day one; `/health` is
|
||||||
|
//! unversioned for infrastructure probes.
|
||||||
|
|
||||||
|
mod auth_routes;
|
||||||
|
mod feed;
|
||||||
|
mod health;
|
||||||
|
mod ingest;
|
||||||
|
mod interests;
|
||||||
|
mod sources;
|
||||||
|
mod tokens;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::http::{Method, header};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Build the full application router.
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
let cors = build_cors(&state);
|
||||||
|
|
||||||
|
let v1 = Router::new()
|
||||||
|
// auth
|
||||||
|
.route("/auth/register", post(auth_routes::register))
|
||||||
|
.route("/auth/login", post(auth_routes::login))
|
||||||
|
.route("/auth/logout", post(auth_routes::logout))
|
||||||
|
.route("/auth/me", get(auth_routes::me))
|
||||||
|
// feed
|
||||||
|
.route("/feed", get(feed::get_feed))
|
||||||
|
.route("/feed/signals", post(feed::post_signal))
|
||||||
|
// sources
|
||||||
|
.route("/sources", get(sources::list).post(sources::create))
|
||||||
|
.route("/sources/{id}", delete(sources::remove))
|
||||||
|
// interests
|
||||||
|
.route("/interests", get(interests::list).put(interests::upsert))
|
||||||
|
.route("/interests/{id}", delete(interests::remove))
|
||||||
|
// api tokens
|
||||||
|
.route("/tokens", get(tokens::list).post(tokens::create))
|
||||||
|
.route("/tokens/{id}", delete(tokens::revoke))
|
||||||
|
// ingest (bearer-token authenticated)
|
||||||
|
.route("/ingest/candidates", post(ingest::submit));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health::health))
|
||||||
|
.nest("/v1", v1)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CORS is only needed in development, where the Vite dev server is a different origin
|
||||||
|
/// from the API. In production nginx serves the SPA and proxies the API under one origin,
|
||||||
|
/// so `cors_origins` is empty and no cross-origin headers are emitted.
|
||||||
|
fn build_cors(state: &AppState) -> CorsLayer {
|
||||||
|
if state.config.cors_origins.is_empty() {
|
||||||
|
return CorsLayer::new();
|
||||||
|
}
|
||||||
|
let origins = state
|
||||||
|
.config
|
||||||
|
.cors_origins
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| o.parse().ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
// Credentialed CORS forbids wildcard method/header lists, so enumerate them.
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(origins)
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
||||||
|
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||||
|
.allow_credentials(true)
|
||||||
|
}
|
||||||
42
crates/newsfeed-api/src/routes/sources.rs
Normal file
42
crates/newsfeed-api/src/routes/sources.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
//! CRUD for a user's content sources.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::ports::SourceStore;
|
||||||
|
use newsfeed_entities::source::{NewSource, Source};
|
||||||
|
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// `GET /v1/sources` — the user's sources.
|
||||||
|
pub async fn list(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<Source>>> {
|
||||||
|
Ok(Json(app.store.list_sources(user.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/sources` — add a source.
|
||||||
|
pub async fn create(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Json(new): Json<NewSource>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<Source>)> {
|
||||||
|
new.validate()?;
|
||||||
|
let source = app.store.create_source(user.id, &new).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(source)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /v1/sources/{id}` — remove a source.
|
||||||
|
pub async fn remove(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
app.store.delete_source(user.id, id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
45
crates/newsfeed-api/src/routes/tokens.rs
Normal file
45
crates/newsfeed-api/src/routes/tokens.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! Per-user API tokens used by agentic/algorithmic producers to POST candidates.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::ports::TokenStore;
|
||||||
|
use newsfeed_core::service;
|
||||||
|
use newsfeed_entities::auth::{ApiTokenInfo, CreateApiToken, CreatedApiToken};
|
||||||
|
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// `GET /v1/tokens` — list the caller's tokens (metadata only; secrets never returned).
|
||||||
|
pub async fn list(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<ApiTokenInfo>>> {
|
||||||
|
Ok(Json(app.store.list_tokens(user.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/tokens` — mint a token. The full secret is returned exactly once.
|
||||||
|
pub async fn create(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Json(body): Json<CreateApiToken>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<CreatedApiToken>)> {
|
||||||
|
if body.name.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("token name must not be empty"));
|
||||||
|
}
|
||||||
|
let created = service::create_api_token(app.store.as_ref(), user.id, body.name.trim()).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /v1/tokens/{id}` — revoke a token.
|
||||||
|
pub async fn revoke(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
app.store.revoke_token(user.id, id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
24
crates/newsfeed-api/src/state.rs
Normal file
24
crates/newsfeed-api/src/state.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//! Shared application state. The binary wires the concrete SQLite adapter here; handlers
|
||||||
|
//! reach the ports through it. `AppState` is cheap to clone (Arc-wrapped).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use newsfeed_data::SqliteStore;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
/// State shared across all handlers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub store: Arc<SqliteStore>,
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(store: SqliteStore, config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
store: Arc::new(store),
|
||||||
|
config: Arc::new(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
crates/newsfeed-core/Cargo.toml
Normal file
23
crates/newsfeed-core/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "newsfeed-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Business logic and data-access ports for newsfeed. No direct I/O."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
newsfeed-entities.workspace = true
|
||||||
|
|
||||||
|
serde.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
|
||||||
|
# pure crypto/compute — no network or DB
|
||||||
|
argon2.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
126
crates/newsfeed-core/src/auth.rs
Normal file
126
crates/newsfeed-core/src/auth.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! Credential primitives: password hashing, opaque session tokens, and API tokens.
|
||||||
|
//!
|
||||||
|
//! Secrets are generated here and only their hashes are handed to the store. Session
|
||||||
|
//! cookies are random and hashed with SHA-256 (they are high-entropy, so a fast hash is
|
||||||
|
//! appropriate); passwords are hashed with Argon2id (slow, salted) because they are
|
||||||
|
//! low-entropy and attacker-guessable.
|
||||||
|
|
||||||
|
use argon2::Argon2;
|
||||||
|
use argon2::password_hash::{
|
||||||
|
PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::error::{CoreError, CoreResult};
|
||||||
|
|
||||||
|
/// Hash a plaintext password with Argon2id and a fresh random salt.
|
||||||
|
pub fn hash_password(plaintext: &str) -> CoreResult<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
Argon2::default()
|
||||||
|
.hash_password(plaintext.as_bytes(), &salt)
|
||||||
|
.map(|h| h.to_string())
|
||||||
|
.map_err(|e| CoreError::Crypto(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a plaintext password against a stored Argon2 PHC hash.
|
||||||
|
pub fn verify_password(plaintext: &str, phc_hash: &str) -> CoreResult<bool> {
|
||||||
|
let parsed = PasswordHash::new(phc_hash).map_err(|e| CoreError::Crypto(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plaintext.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A freshly minted secret: the plaintext to hand to the client exactly once, plus the
|
||||||
|
/// hash to persist.
|
||||||
|
pub struct MintedSecret {
|
||||||
|
/// The value the client keeps (cookie value or bearer token).
|
||||||
|
pub plaintext: String,
|
||||||
|
/// The SHA-256 hash to store and look up by.
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_token(bytes: usize) -> String {
|
||||||
|
let mut buf = vec![0u8; bytes];
|
||||||
|
OsRng.fill_bytes(&mut buf);
|
||||||
|
URL_SAFE_NO_PAD.encode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 hash of an opaque high-entropy secret, hex-encoded. Used for both session
|
||||||
|
/// cookies and API tokens so the store never holds the secret in the clear.
|
||||||
|
pub fn hash_opaque(secret: &str) -> String {
|
||||||
|
let digest = Sha256::digest(secret.as_bytes());
|
||||||
|
hex_encode(&digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new opaque session token (256 bits of entropy).
|
||||||
|
pub fn mint_session_token() -> MintedSecret {
|
||||||
|
let plaintext = random_token(32);
|
||||||
|
let hash = hash_opaque(&plaintext);
|
||||||
|
MintedSecret { plaintext, hash }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newly minted API token, with its display prefix.
|
||||||
|
pub struct MintedApiToken {
|
||||||
|
/// Full bearer token: `nf_<prefix>_<random>`.
|
||||||
|
pub secret: String,
|
||||||
|
/// Short non-secret prefix shown to the user (`nf_<prefix>`).
|
||||||
|
pub prefix: String,
|
||||||
|
/// SHA-256 hash of the full secret, for storage.
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix on every API token. Lets the ingest handler cheaply reject non-tokens.
|
||||||
|
pub const API_TOKEN_PREFIX: &str = "nf";
|
||||||
|
|
||||||
|
/// Generate a new API token of the form `nf_<6hex>_<random>`.
|
||||||
|
pub fn mint_api_token() -> MintedApiToken {
|
||||||
|
let mut id = [0u8; 3];
|
||||||
|
OsRng.fill_bytes(&mut id);
|
||||||
|
let short = hex_encode(&id); // 6 hex chars
|
||||||
|
let prefix = format!("{API_TOKEN_PREFIX}_{short}");
|
||||||
|
let secret = format!("{prefix}_{}", random_token(24));
|
||||||
|
let hash = hash_opaque(&secret);
|
||||||
|
MintedApiToken {
|
||||||
|
secret,
|
||||||
|
prefix,
|
||||||
|
hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let mut s = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
let _ = write!(s, "{b:02x}");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_roundtrip() {
|
||||||
|
let hash = hash_password("correct horse battery staple").unwrap();
|
||||||
|
assert!(verify_password("correct horse battery staple", &hash).unwrap());
|
||||||
|
assert!(!verify_password("wrong", &hash).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opaque_hash_is_stable_and_matches() {
|
||||||
|
let minted = mint_session_token();
|
||||||
|
assert_eq!(minted.hash, hash_opaque(&minted.plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_token_has_prefix() {
|
||||||
|
let t = mint_api_token();
|
||||||
|
assert!(t.secret.starts_with(&t.prefix));
|
||||||
|
assert!(t.prefix.starts_with("nf_"));
|
||||||
|
assert_eq!(t.hash, hash_opaque(&t.secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
crates/newsfeed-core/src/error.rs
Normal file
36
crates/newsfeed-core/src/error.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//! Error type for the core layer. Wraps domain validation errors and the abstract
|
||||||
|
//! failure modes a data adapter can surface, without naming any concrete backend.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors produced by core business logic and its data ports.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CoreError {
|
||||||
|
/// A domain value failed validation.
|
||||||
|
#[error(transparent)]
|
||||||
|
Domain(#[from] newsfeed_entities::Error),
|
||||||
|
|
||||||
|
/// The requested entity does not exist.
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
/// A uniqueness constraint was violated (duplicate username, email, …).
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
/// Authentication failed (bad password, unknown/expired session or token).
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
/// A password/token hashing operation failed.
|
||||||
|
#[error("crypto error: {0}")]
|
||||||
|
Crypto(String),
|
||||||
|
|
||||||
|
/// An adapter (the `data` crate) failed for an implementation-specific reason.
|
||||||
|
/// The concrete error is stringified so `core` needn't depend on any backend.
|
||||||
|
#[error("storage error: {0}")]
|
||||||
|
Storage(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result alias for the core layer.
|
||||||
|
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||||
121
crates/newsfeed-core/src/ingest.rs
Normal file
121
crates/newsfeed-core/src/ingest.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//! Candidate normalisation. Turns an inbound [`CandidateSubmission`] (from an agentic
|
||||||
|
//! producer) or a parsed feed entry into the [`NewCandidate`] shape the store persists.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_entities::interest::Interest;
|
||||||
|
use newsfeed_entities::item::{CandidateSubmission, ContentItem, ItemState, Media};
|
||||||
|
|
||||||
|
use crate::error::CoreResult;
|
||||||
|
use crate::ports::{ItemStore, NewCandidate, SourceStore};
|
||||||
|
use crate::ranking::{self, RankingParams};
|
||||||
|
|
||||||
|
/// Baseline weight applied to candidates whose named source doesn't (yet) exist as a
|
||||||
|
/// configured source for the user. Configured sources carry their own weight.
|
||||||
|
pub const DEFAULT_UNLINKED_WEIGHT: f64 = 0.5;
|
||||||
|
|
||||||
|
/// Normalise an agentic submission for a given user and (optional) resolved source.
|
||||||
|
pub fn candidate_from_submission(
|
||||||
|
user_id: Uuid,
|
||||||
|
source_id: Option<Uuid>,
|
||||||
|
sub: CandidateSubmission,
|
||||||
|
) -> NewCandidate {
|
||||||
|
NewCandidate {
|
||||||
|
user_id,
|
||||||
|
source_id,
|
||||||
|
external_id: sub.external_id,
|
||||||
|
url: sub.url,
|
||||||
|
title: sub.title,
|
||||||
|
summary: sub.summary,
|
||||||
|
author: sub.author,
|
||||||
|
tags: sub.tags,
|
||||||
|
media: sub.media,
|
||||||
|
published_at: sub.published_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fields extracted from a polled feed entry, backend-agnostic so the worker's feed
|
||||||
|
/// parser (`feed-rs`) doesn't leak into `core`.
|
||||||
|
pub struct FeedEntry {
|
||||||
|
pub external_id: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub media: Vec<Media>,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalise a polled feed entry for a user's RSS source.
|
||||||
|
pub fn candidate_from_feed_entry(user_id: Uuid, source_id: Uuid, entry: FeedEntry) -> NewCandidate {
|
||||||
|
NewCandidate {
|
||||||
|
user_id,
|
||||||
|
source_id: Some(source_id),
|
||||||
|
external_id: entry.external_id,
|
||||||
|
url: entry.url,
|
||||||
|
title: entry.title,
|
||||||
|
summary: entry.summary,
|
||||||
|
author: entry.author,
|
||||||
|
tags: entry.tags,
|
||||||
|
media: entry.media,
|
||||||
|
published_at: entry.published_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a normalised candidate and, if it is newly created, score it against the
|
||||||
|
/// user's current interests and promote it into the live feed. Re-submitting an existing
|
||||||
|
/// `(user, external_id)` is a no-op that returns the stored item unchanged. `interests`
|
||||||
|
/// is passed in so the caller can load it once per batch.
|
||||||
|
pub async fn persist_and_rank<S>(
|
||||||
|
store: &S,
|
||||||
|
candidate: NewCandidate,
|
||||||
|
source_weight: f64,
|
||||||
|
interests: &[Interest],
|
||||||
|
) -> CoreResult<ContentItem>
|
||||||
|
where
|
||||||
|
S: ItemStore,
|
||||||
|
{
|
||||||
|
let (mut item, created) = store.upsert_candidate(&candidate).await?;
|
||||||
|
if created {
|
||||||
|
let score = ranking::score_item(
|
||||||
|
&item,
|
||||||
|
source_weight,
|
||||||
|
interests,
|
||||||
|
Utc::now(),
|
||||||
|
&RankingParams::default(),
|
||||||
|
);
|
||||||
|
store.update_score(item.id, score).await?;
|
||||||
|
store
|
||||||
|
.set_item_state(item.user_id, item.id, ItemState::Feed)
|
||||||
|
.await?;
|
||||||
|
item.score = score;
|
||||||
|
item.state = ItemState::Feed;
|
||||||
|
}
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ingest an agentic submission: resolve its named source (if any), normalise, then
|
||||||
|
/// [`persist_and_rank`]. Used by `POST /v1/ingest/candidates`.
|
||||||
|
pub async fn ingest_submission<S>(
|
||||||
|
store: &S,
|
||||||
|
user_id: Uuid,
|
||||||
|
interests: &[Interest],
|
||||||
|
sub: CandidateSubmission,
|
||||||
|
) -> CoreResult<ContentItem>
|
||||||
|
where
|
||||||
|
S: SourceStore + ItemStore,
|
||||||
|
{
|
||||||
|
let source = match &sub.source {
|
||||||
|
Some(name) => store.find_source_by_name(user_id, name).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let source_weight = source
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.weight)
|
||||||
|
.unwrap_or(DEFAULT_UNLINKED_WEIGHT);
|
||||||
|
let source_id = source.as_ref().map(|s| s.id);
|
||||||
|
let candidate = candidate_from_submission(user_id, source_id, sub);
|
||||||
|
persist_and_rank(store, candidate, source_weight, interests).await
|
||||||
|
}
|
||||||
15
crates/newsfeed-core/src/lib.rs
Normal file
15
crates/newsfeed-core/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//! Business logic and data-access ports for newsfeed.
|
||||||
|
//!
|
||||||
|
//! This crate consumes [`newsfeed_entities`] and defines the *ports* (traits in
|
||||||
|
//! [`ports`]) that `newsfeed-data` implements as adapters. It also holds pure compute:
|
||||||
|
//! password/token hashing ([`auth`]), the feed ranker ([`ranking`]), and candidate
|
||||||
|
//! normalisation ([`ingest`]). It performs no I/O of its own.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod error;
|
||||||
|
pub mod ingest;
|
||||||
|
pub mod ports;
|
||||||
|
pub mod ranking;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use error::{CoreError, CoreResult};
|
||||||
156
crates/newsfeed-core/src/ports.rs
Normal file
156
crates/newsfeed-core/src/ports.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! Data-access ports. `newsfeed-data` provides the adapters that implement these; the
|
||||||
|
//! binaries depend only on the [`Store`] aggregate, never on a concrete backend.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_entities::auth::{ApiTokenInfo, Session};
|
||||||
|
use newsfeed_entities::interest::{Interest, UpsertInterest};
|
||||||
|
use newsfeed_entities::item::ContentItem;
|
||||||
|
use newsfeed_entities::source::{NewSource, Source};
|
||||||
|
use newsfeed_entities::user::User;
|
||||||
|
|
||||||
|
use crate::error::CoreResult;
|
||||||
|
|
||||||
|
/// A user together with the password hash needed to authenticate them. Never serialised
|
||||||
|
/// to a client — it exists only to move the hash from the store to [`crate::auth`].
|
||||||
|
pub struct StoredCredential {
|
||||||
|
pub user: User,
|
||||||
|
pub password_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fields required to persist a candidate. Normalised from a submission or a polled feed
|
||||||
|
/// entry by [`crate::ingest`].
|
||||||
|
pub struct NewCandidate {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub source_id: Option<Uuid>,
|
||||||
|
pub external_id: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub media: Vec<newsfeed_entities::item::Media>,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User accounts.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserStore: Send + Sync {
|
||||||
|
async fn create_user(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
email: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> CoreResult<User>;
|
||||||
|
async fn get_user(&self, id: Uuid) -> CoreResult<Option<User>>;
|
||||||
|
/// Look up by username or email, returning the stored password hash for verification.
|
||||||
|
async fn find_credential(&self, identifier: &str) -> CoreResult<Option<StoredCredential>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browser sessions.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SessionStore: Send + Sync {
|
||||||
|
async fn create_session(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> CoreResult<Session>;
|
||||||
|
/// Return the session for this cookie hash iff it exists and has not expired.
|
||||||
|
async fn find_session(&self, token_hash: &str) -> CoreResult<Option<Session>>;
|
||||||
|
async fn delete_session(&self, token_hash: &str) -> CoreResult<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-user API tokens used by agentic/algorithmic ingest producers.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TokenStore: Send + Sync {
|
||||||
|
async fn create_token(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
prefix: &str,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> CoreResult<ApiTokenInfo>;
|
||||||
|
async fn list_tokens(&self, user_id: Uuid) -> CoreResult<Vec<ApiTokenInfo>>;
|
||||||
|
/// Resolve a bearer-token hash to its (non-revoked) owner and record use.
|
||||||
|
async fn authenticate_token(&self, token_hash: &str) -> CoreResult<Option<ApiTokenInfo>>;
|
||||||
|
async fn revoke_token(&self, user_id: Uuid, id: Uuid) -> CoreResult<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content sources.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SourceStore: Send + Sync {
|
||||||
|
async fn create_source(&self, user_id: Uuid, new: &NewSource) -> CoreResult<Source>;
|
||||||
|
async fn list_sources(&self, user_id: Uuid) -> CoreResult<Vec<Source>>;
|
||||||
|
async fn find_source_by_name(&self, user_id: Uuid, name: &str) -> CoreResult<Option<Source>>;
|
||||||
|
async fn delete_source(&self, user_id: Uuid, id: Uuid) -> CoreResult<()>;
|
||||||
|
/// RSS sources whose next poll is due (worker use).
|
||||||
|
async fn due_rss_sources(
|
||||||
|
&self,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
min_interval_secs: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> CoreResult<Vec<Source>>;
|
||||||
|
async fn mark_polled(&self, source_id: Uuid, at: DateTime<Utc>) -> CoreResult<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User interests / weightings.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait InterestStore: Send + Sync {
|
||||||
|
async fn list_interests(&self, user_id: Uuid) -> CoreResult<Vec<Interest>>;
|
||||||
|
async fn upsert_interest(&self, user_id: Uuid, upsert: &UpsertInterest)
|
||||||
|
-> CoreResult<Interest>;
|
||||||
|
async fn delete_interest(&self, user_id: Uuid, id: Uuid) -> CoreResult<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content items and the feed.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ItemStore: Send + Sync {
|
||||||
|
/// Insert a candidate, or return the existing row if `(user_id, external_id)` already
|
||||||
|
/// exists. `true` in the tuple means a new row was created.
|
||||||
|
async fn upsert_candidate(&self, candidate: &NewCandidate) -> CoreResult<(ContentItem, bool)>;
|
||||||
|
async fn get_item(&self, user_id: Uuid, id: Uuid) -> CoreResult<Option<ContentItem>>;
|
||||||
|
async fn set_item_state(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
state: newsfeed_entities::item::ItemState,
|
||||||
|
) -> CoreResult<()>;
|
||||||
|
async fn update_score(&self, item_id: Uuid, score: f64) -> CoreResult<()>;
|
||||||
|
/// A ranked page of the user's feed, ordered by score descending.
|
||||||
|
async fn feed_page(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
include_saved: bool,
|
||||||
|
) -> CoreResult<Vec<ContentItem>>;
|
||||||
|
/// All feed/candidate items for a user, for (re)scoring by the worker.
|
||||||
|
async fn items_for_scoring(&self, user_id: Uuid, limit: i64) -> CoreResult<Vec<ContentItem>>;
|
||||||
|
/// Distinct user ids that own at least one source (worker iteration).
|
||||||
|
async fn user_ids_with_sources(&self) -> CoreResult<Vec<Uuid>>;
|
||||||
|
|
||||||
|
/// Record an interaction signal against an item. This is the feedback the ranker can
|
||||||
|
/// later weigh (still under the user's control — signals inform, they don't override
|
||||||
|
/// the user's explicit weights).
|
||||||
|
async fn record_signal(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
item_id: Uuid,
|
||||||
|
action: newsfeed_entities::feed::SignalAction,
|
||||||
|
) -> CoreResult<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The aggregate every binary depends on. Any type implementing all the ports is a
|
||||||
|
/// `Store`, so the concrete adapter satisfies it automatically.
|
||||||
|
pub trait Store:
|
||||||
|
UserStore + SessionStore + TokenStore + SourceStore + InterestStore + ItemStore
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Store for T where
|
||||||
|
T: UserStore + SessionStore + TokenStore + SourceStore + InterestStore + ItemStore
|
||||||
|
{
|
||||||
|
}
|
||||||
156
crates/newsfeed-core/src/ranking.rs
Normal file
156
crates/newsfeed-core/src/ranking.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! The feed ranker. This is the whole point of newsfeed: the score assigned to an item
|
||||||
|
//! is a transparent, deterministic function of weights the *user* controls — their
|
||||||
|
//! per-source baseline weight and their per-interest weights — decayed by age. No
|
||||||
|
//! opaque engagement model, no third party deciding what surfaces.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use newsfeed_entities::interest::Interest;
|
||||||
|
use newsfeed_entities::item::ContentItem;
|
||||||
|
|
||||||
|
/// Tunable ranking parameters. Defaults are sensible; a future per-user settings row can
|
||||||
|
/// override them so the decay curve itself is user-controlled too.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct RankingParams {
|
||||||
|
/// Hours after which recency weight halves.
|
||||||
|
pub half_life_hours: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RankingParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
half_life_hours: 24.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the relevance component from the user's interests: the sum of the weights of
|
||||||
|
/// every interest whose (lower-cased) label occurs in the item's title, summary or tags.
|
||||||
|
/// Negative-weighted interests subtract, actively burying matching content.
|
||||||
|
pub fn interest_relevance(item: &ContentItem, interests: &[Interest]) -> f64 {
|
||||||
|
if interests.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let haystack = {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push_str(&item.title.to_lowercase());
|
||||||
|
s.push(' ');
|
||||||
|
if let Some(summary) = &item.summary {
|
||||||
|
s.push_str(&summary.to_lowercase());
|
||||||
|
s.push(' ');
|
||||||
|
}
|
||||||
|
for tag in &item.tags {
|
||||||
|
s.push_str(&tag.to_lowercase());
|
||||||
|
s.push(' ');
|
||||||
|
}
|
||||||
|
s
|
||||||
|
};
|
||||||
|
interests
|
||||||
|
.iter()
|
||||||
|
.filter(|i| {
|
||||||
|
let label = i.label.trim().to_lowercase();
|
||||||
|
!label.is_empty() && haystack.contains(&label)
|
||||||
|
})
|
||||||
|
.map(|i| i.weight)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recency multiplier in `(0.0, 1.0]` using exponential decay. Items with no publish
|
||||||
|
/// date are treated as freshly ingested (multiplier `1.0`).
|
||||||
|
pub fn recency_multiplier(
|
||||||
|
published_at: Option<DateTime<Utc>>,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
params: &RankingParams,
|
||||||
|
) -> f64 {
|
||||||
|
let Some(published) = published_at else {
|
||||||
|
return 1.0;
|
||||||
|
};
|
||||||
|
let age_hours = (now - published).num_seconds() as f64 / 3600.0;
|
||||||
|
if age_hours <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
0.5f64.powf(age_hours / params.half_life_hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a single item for a user. `source_weight` is the item's source baseline
|
||||||
|
/// (`0.0..=1.0`); `interests` are the user's weighted interests.
|
||||||
|
///
|
||||||
|
/// `score = (source_weight + interest_relevance) * recency_multiplier`
|
||||||
|
pub fn score_item(
|
||||||
|
item: &ContentItem,
|
||||||
|
source_weight: f64,
|
||||||
|
interests: &[Interest],
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
params: &RankingParams,
|
||||||
|
) -> f64 {
|
||||||
|
let relevance = source_weight + interest_relevance(item, interests);
|
||||||
|
relevance * recency_multiplier(item.published_at, now, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn item(title: &str, published: Option<DateTime<Utc>>) -> ContentItem {
|
||||||
|
ContentItem {
|
||||||
|
id: Uuid::nil(),
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
source_id: None,
|
||||||
|
external_id: "x".into(),
|
||||||
|
url: None,
|
||||||
|
title: title.into(),
|
||||||
|
summary: None,
|
||||||
|
author: None,
|
||||||
|
tags: vec![],
|
||||||
|
media: vec![],
|
||||||
|
published_at: published,
|
||||||
|
ingested_at: Utc::now(),
|
||||||
|
state: newsfeed_entities::item::ItemState::Feed,
|
||||||
|
score: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interest(label: &str, weight: f64) -> Interest {
|
||||||
|
Interest {
|
||||||
|
id: Uuid::nil(),
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
label: label.into(),
|
||||||
|
weight,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positive_interest_raises_score() {
|
||||||
|
let now = Utc.with_ymd_and_hms(2026, 7, 8, 12, 0, 0).unwrap();
|
||||||
|
let it = item("Rust 2.0 released", Some(now));
|
||||||
|
let interests = vec![interest("rust", 0.8)];
|
||||||
|
let with = score_item(&it, 0.5, &interests, now, &RankingParams::default());
|
||||||
|
let without = score_item(&it, 0.5, &[], now, &RankingParams::default());
|
||||||
|
assert!(with > without);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_interest_buries() {
|
||||||
|
let now = Utc.with_ymd_and_hms(2026, 7, 8, 12, 0, 0).unwrap();
|
||||||
|
let it = item("Celebrity gossip roundup", Some(now));
|
||||||
|
let interests = vec![interest("gossip", -0.9)];
|
||||||
|
let score = score_item(&it, 0.5, &interests, now, &RankingParams::default());
|
||||||
|
assert!(score < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn older_items_decay() {
|
||||||
|
let now = Utc.with_ymd_and_hms(2026, 7, 8, 12, 0, 0).unwrap();
|
||||||
|
let fresh = item("news", Some(now));
|
||||||
|
let day_old = item("news", Some(now - chrono::Duration::hours(24)));
|
||||||
|
let p = RankingParams::default();
|
||||||
|
let fresh_score = score_item(&fresh, 1.0, &[], now, &p);
|
||||||
|
let old_score = score_item(&day_old, 1.0, &[], now, &p);
|
||||||
|
assert!(fresh_score > old_score);
|
||||||
|
// one half-life => ~half the score
|
||||||
|
assert!((old_score - 0.5).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
crates/newsfeed-core/src/service.rs
Normal file
131
crates/newsfeed-core/src/service.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
//! Service-layer orchestration that spans the ports: registration, login, and principal
|
||||||
|
//! resolution. The binaries call these so the auth flow lives in one tested place rather
|
||||||
|
//! than being re-implemented per handler.
|
||||||
|
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
|
||||||
|
use newsfeed_entities::auth::{ApiTokenInfo, CreatedApiToken};
|
||||||
|
use newsfeed_entities::user::{LoginRequest, RegisterRequest, User};
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::error::{CoreError, CoreResult};
|
||||||
|
use crate::ports::{InterestStore, ItemStore, SessionStore, SourceStore, TokenStore, UserStore};
|
||||||
|
use crate::ranking::{self, RankingParams};
|
||||||
|
|
||||||
|
/// Register a new user. Validates the request, hashes the password, and persists.
|
||||||
|
pub async fn register(store: &impl UserStore, req: &RegisterRequest) -> CoreResult<User> {
|
||||||
|
req.validate()?;
|
||||||
|
let hash = auth::hash_password(&req.password)?;
|
||||||
|
store
|
||||||
|
.create_user(req.username.trim(), req.email.trim(), &hash)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a successful login: the user plus the opaque session cookie value to
|
||||||
|
/// set (returned in the clear exactly once).
|
||||||
|
pub struct LoggedIn {
|
||||||
|
pub user: User,
|
||||||
|
/// The cookie value to hand to the browser.
|
||||||
|
pub session_cookie: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate a login request and open a session valid for `ttl`.
|
||||||
|
pub async fn login<S>(store: &S, req: &LoginRequest, ttl: Duration) -> CoreResult<LoggedIn>
|
||||||
|
where
|
||||||
|
S: UserStore + SessionStore,
|
||||||
|
{
|
||||||
|
let cred = store
|
||||||
|
.find_credential(req.identifier.trim())
|
||||||
|
.await?
|
||||||
|
.ok_or(CoreError::Unauthorized)?;
|
||||||
|
if !auth::verify_password(&req.password, &cred.password_hash)? {
|
||||||
|
return Err(CoreError::Unauthorized);
|
||||||
|
}
|
||||||
|
let minted = auth::mint_session_token();
|
||||||
|
let expires_at = Utc::now() + ttl;
|
||||||
|
store
|
||||||
|
.create_session(cred.user.id, &minted.hash, expires_at)
|
||||||
|
.await?;
|
||||||
|
Ok(LoggedIn {
|
||||||
|
user: cred.user,
|
||||||
|
session_cookie: minted.plaintext,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a session cookie value to its user, or `Unauthorized`.
|
||||||
|
pub async fn authenticate_session<S>(store: &S, cookie_value: &str) -> CoreResult<User>
|
||||||
|
where
|
||||||
|
S: SessionStore + UserStore,
|
||||||
|
{
|
||||||
|
let hash = auth::hash_opaque(cookie_value);
|
||||||
|
let session = store
|
||||||
|
.find_session(&hash)
|
||||||
|
.await?
|
||||||
|
.ok_or(CoreError::Unauthorized)?;
|
||||||
|
store
|
||||||
|
.get_user(session.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(CoreError::Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log out: delete the session backing this cookie value. Idempotent.
|
||||||
|
pub async fn logout(store: &impl SessionStore, cookie_value: &str) -> CoreResult<()> {
|
||||||
|
store.delete_session(&auth::hash_opaque(cookie_value)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a bearer API token to its owning token record, recording use.
|
||||||
|
pub async fn authenticate_token(store: &impl TokenStore, bearer: &str) -> CoreResult<ApiTokenInfo> {
|
||||||
|
let hash = auth::hash_opaque(bearer);
|
||||||
|
store
|
||||||
|
.authenticate_token(&hash)
|
||||||
|
.await?
|
||||||
|
.ok_or(CoreError::Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute scores for a user's live/candidate items against their current interests
|
||||||
|
/// and per-source weights. Run periodically by the worker so that changing a weight
|
||||||
|
/// re-ranks the existing feed, not just future items. Returns the number rescored.
|
||||||
|
pub async fn rescore_user<S>(store: &S, user_id: uuid::Uuid, limit: i64) -> CoreResult<usize>
|
||||||
|
where
|
||||||
|
S: SourceStore + InterestStore + ItemStore,
|
||||||
|
{
|
||||||
|
let interests = store.list_interests(user_id).await?;
|
||||||
|
let weights: HashMap<uuid::Uuid, f64> = store
|
||||||
|
.list_sources(user_id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| (s.id, s.weight))
|
||||||
|
.collect();
|
||||||
|
let items = store.items_for_scoring(user_id, limit).await?;
|
||||||
|
let now = Utc::now();
|
||||||
|
let params = RankingParams::default();
|
||||||
|
let mut n = 0;
|
||||||
|
for item in items {
|
||||||
|
let source_weight = item
|
||||||
|
.source_id
|
||||||
|
.and_then(|id| weights.get(&id).copied())
|
||||||
|
.unwrap_or(crate::ingest::DEFAULT_UNLINKED_WEIGHT);
|
||||||
|
let score = ranking::score_item(&item, source_weight, &interests, now, ¶ms);
|
||||||
|
store.update_score(item.id, score).await?;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a new API token for a user, returning the secret exactly once.
|
||||||
|
pub async fn create_api_token(
|
||||||
|
store: &impl TokenStore,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
name: &str,
|
||||||
|
) -> CoreResult<CreatedApiToken> {
|
||||||
|
let minted = auth::mint_api_token();
|
||||||
|
let info = store
|
||||||
|
.create_token(user_id, name, &minted.prefix, &minted.hash)
|
||||||
|
.await?;
|
||||||
|
Ok(CreatedApiToken {
|
||||||
|
info,
|
||||||
|
secret: minted.secret,
|
||||||
|
})
|
||||||
|
}
|
||||||
23
crates/newsfeed-data/Cargo.toml
Normal file
23
crates/newsfeed-data/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "newsfeed-data"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "SQLite data-access adapters implementing the newsfeed-core ports."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
newsfeed-entities.workspace = true
|
||||||
|
newsfeed-core.workspace = true
|
||||||
|
|
||||||
|
sqlx.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
92
crates/newsfeed-data/migrations/0001_init.sql
Normal file
92
crates/newsfeed-data/migrations/0001_init.sql
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
-- newsfeed initial schema (SQLite).
|
||||||
|
--
|
||||||
|
-- Conventions for this datastore:
|
||||||
|
-- * ids TEXT — hyphenated UUIDv4
|
||||||
|
-- * timestamps TEXT — RFC3339 / ISO8601 (UTC)
|
||||||
|
-- * booleans INTEGER — 0/1
|
||||||
|
-- * json blobs TEXT — serde_json arrays/objects
|
||||||
|
--
|
||||||
|
-- Every user-owned row carries user_id and cascades on user deletion, because each
|
||||||
|
-- user is wholly in control of — and isolated within — their own feed.
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE api_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE sources (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL, -- 'rss' | 'agentic'
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
weight REAL NOT NULL DEFAULT 1.0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_polled_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sources_user ON sources(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE interests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
weight REAL NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, label)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_interests_user ON interests(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
source_id TEXT REFERENCES sources(id) ON DELETE SET NULL,
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
author TEXT,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]', -- json array of strings
|
||||||
|
media TEXT NOT NULL DEFAULT '[]', -- json array of Media
|
||||||
|
published_at TEXT,
|
||||||
|
ingested_at TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'candidate',
|
||||||
|
score REAL NOT NULL DEFAULT 0.0,
|
||||||
|
UNIQUE(user_id, external_id)
|
||||||
|
);
|
||||||
|
-- Feed reads order by score within a user's live items.
|
||||||
|
CREATE INDEX idx_items_feed ON items(user_id, state, score DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE signals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
action TEXT NOT NULL, -- 'view' | 'click' | 'save' | 'dismiss'
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_signals_item ON signals(item_id);
|
||||||
24
crates/newsfeed-data/src/err.rs
Normal file
24
crates/newsfeed-data/src/err.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//! Maps `sqlx` failures onto the backend-agnostic [`CoreError`] the ports speak.
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreError;
|
||||||
|
|
||||||
|
/// Convert a `sqlx::Error` into a [`CoreError`], distinguishing unique-constraint
|
||||||
|
/// violations (→ [`CoreError::Conflict`]) from everything else (→ storage error).
|
||||||
|
pub fn map(e: sqlx::Error) -> CoreError {
|
||||||
|
if let sqlx::Error::Database(db) = &e {
|
||||||
|
if db.is_unique_violation() {
|
||||||
|
return CoreError::Conflict(db.message().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CoreError::Storage(e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a JSON (de)serialisation failure in the mapping layer into a storage error.
|
||||||
|
pub fn json(e: serde_json::Error) -> CoreError {
|
||||||
|
CoreError::Storage(format!("json: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a malformed stored UUID into a storage error.
|
||||||
|
pub fn uuid(e: uuid::Error) -> CoreError {
|
||||||
|
CoreError::Storage(format!("uuid: {e}"))
|
||||||
|
}
|
||||||
52
crates/newsfeed-data/src/lib.rs
Normal file
52
crates/newsfeed-data/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! SQLite data-access adapters for newsfeed.
|
||||||
|
//!
|
||||||
|
//! [`SqliteStore`] implements every port defined in [`newsfeed_core::ports`]; the
|
||||||
|
//! binaries hold it behind `Arc<dyn Store>`. Ids are TEXT UUIDs, timestamps RFC3339,
|
||||||
|
//! and small collections JSON — see [`rows`] for the mapping layer.
|
||||||
|
//!
|
||||||
|
//! Note: this crate uses runtime `sqlx` queries rather than the compile-time `query!`
|
||||||
|
//! macros. On SQLite (dynamically typed) the macros add little safety while forcing a
|
||||||
|
//! live database or a fiddly offline cache into CI; runtime queries keep CI DB-free.
|
||||||
|
//! This is a deliberate, documented deviation from architecture `generic.md` §5, which
|
||||||
|
//! is written for Postgres.
|
||||||
|
|
||||||
|
mod err;
|
||||||
|
mod rows;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use sqlx::sqlite::{
|
||||||
|
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use store::SqliteStore;
|
||||||
|
|
||||||
|
/// Embedded migrations, run at startup by [`connect`].
|
||||||
|
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||||
|
|
||||||
|
/// Open (creating if absent) the SQLite database at `path`, apply migrations, and return
|
||||||
|
/// a ready [`SqliteStore`]. WAL + `NORMAL` synchronous + `foreign_keys` are enabled;
|
||||||
|
/// these suit a single-host api+worker sharing one file (the SQLite deployment shape).
|
||||||
|
pub async fn connect(path: impl AsRef<Path>, max_connections: u32) -> anyhow::Result<SqliteStore> {
|
||||||
|
let pool = pool(path, max_connections).await?;
|
||||||
|
MIGRATOR.run(&pool).await?;
|
||||||
|
Ok(SqliteStore::new(pool))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a connection pool with the newsfeed pragmas applied to every connection.
|
||||||
|
pub async fn pool(path: impl AsRef<Path>, max_connections: u32) -> anyhow::Result<SqlitePool> {
|
||||||
|
let url = format!("sqlite://{}", path.as_ref().display());
|
||||||
|
let opts = SqliteConnectOptions::from_str(&url)?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.synchronous(SqliteSynchronous::Normal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(std::time::Duration::from_secs(5));
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(max_connections)
|
||||||
|
.connect_with(opts)
|
||||||
|
.await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
197
crates/newsfeed-data/src/rows.rs
Normal file
197
crates/newsfeed-data/src/rows.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
//! `FromRow` structs mirroring the SQLite tables, plus conversions into the entity
|
||||||
|
//! types. DB-native types (TEXT ids, RFC3339 timestamps, JSON blobs) are decoded here so
|
||||||
|
//! the rest of the crate deals only in [`newsfeed_entities`] values.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreResult;
|
||||||
|
use newsfeed_core::ports::StoredCredential;
|
||||||
|
use newsfeed_entities::auth::{ApiTokenInfo, Session};
|
||||||
|
use newsfeed_entities::interest::Interest;
|
||||||
|
use newsfeed_entities::item::{ContentItem, ItemState, Media};
|
||||||
|
use newsfeed_entities::source::{Source, SourceKind};
|
||||||
|
use newsfeed_entities::user::User;
|
||||||
|
|
||||||
|
use crate::err;
|
||||||
|
|
||||||
|
fn parse_id(s: &str) -> CoreResult<Uuid> {
|
||||||
|
Uuid::parse_str(s).map_err(err::uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_state(s: &str) -> ItemState {
|
||||||
|
match s {
|
||||||
|
"feed" => ItemState::Feed,
|
||||||
|
"saved" => ItemState::Saved,
|
||||||
|
"dismissed" => ItemState::Dismissed,
|
||||||
|
_ => ItemState::Candidate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct UserRow {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRow {
|
||||||
|
pub fn into_user(self) -> CoreResult<User> {
|
||||||
|
Ok(User {
|
||||||
|
id: parse_id(&self.id)?,
|
||||||
|
username: self.username,
|
||||||
|
email: self.email,
|
||||||
|
created_at: self.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_credential(self) -> CoreResult<StoredCredential> {
|
||||||
|
let password_hash = self.password_hash.clone();
|
||||||
|
Ok(StoredCredential {
|
||||||
|
user: self.into_user()?,
|
||||||
|
password_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct SessionRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRow {
|
||||||
|
pub fn into_session(self) -> CoreResult<Session> {
|
||||||
|
Ok(Session {
|
||||||
|
id: parse_id(&self.id)?,
|
||||||
|
user_id: parse_id(&self.user_id)?,
|
||||||
|
created_at: self.created_at,
|
||||||
|
expires_at: self.expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct ApiTokenRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub prefix: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiTokenRow {
|
||||||
|
pub fn into_info(self) -> CoreResult<ApiTokenInfo> {
|
||||||
|
Ok(ApiTokenInfo {
|
||||||
|
id: parse_id(&self.id)?,
|
||||||
|
user_id: parse_id(&self.user_id)?,
|
||||||
|
name: self.name,
|
||||||
|
prefix: self.prefix,
|
||||||
|
created_at: self.created_at,
|
||||||
|
last_used_at: self.last_used_at,
|
||||||
|
revoked_at: self.revoked_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct SourceRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub name: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub weight: f64,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub last_polled_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceRow {
|
||||||
|
pub fn into_source(self) -> CoreResult<Source> {
|
||||||
|
Ok(Source {
|
||||||
|
id: parse_id(&self.id)?,
|
||||||
|
user_id: parse_id(&self.user_id)?,
|
||||||
|
kind: SourceKind::parse(&self.kind).map_err(newsfeed_core::error::CoreError::from)?,
|
||||||
|
name: self.name,
|
||||||
|
url: self.url,
|
||||||
|
weight: self.weight,
|
||||||
|
enabled: self.enabled,
|
||||||
|
last_polled_at: self.last_polled_at,
|
||||||
|
created_at: self.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct InterestRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub weight: f64,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterestRow {
|
||||||
|
pub fn into_interest(self) -> CoreResult<Interest> {
|
||||||
|
Ok(Interest {
|
||||||
|
id: parse_id(&self.id)?,
|
||||||
|
user_id: parse_id(&self.user_id)?,
|
||||||
|
label: self.label,
|
||||||
|
weight: self.weight,
|
||||||
|
created_at: self.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct ItemRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub source_id: Option<String>,
|
||||||
|
pub external_id: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub tags: String,
|
||||||
|
pub media: String,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
pub ingested_at: DateTime<Utc>,
|
||||||
|
pub state: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemRow {
|
||||||
|
pub fn into_item(self) -> CoreResult<ContentItem> {
|
||||||
|
let source_id = match self.source_id {
|
||||||
|
Some(s) => Some(parse_id(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let tags: Vec<String> = serde_json::from_str(&self.tags).map_err(err::json)?;
|
||||||
|
let media: Vec<Media> = serde_json::from_str(&self.media).map_err(err::json)?;
|
||||||
|
Ok(ContentItem {
|
||||||
|
id: parse_id(&self.id)?,
|
||||||
|
user_id: parse_id(&self.user_id)?,
|
||||||
|
source_id,
|
||||||
|
external_id: self.external_id,
|
||||||
|
url: self.url,
|
||||||
|
title: self.title,
|
||||||
|
summary: self.summary,
|
||||||
|
author: self.author,
|
||||||
|
tags,
|
||||||
|
media,
|
||||||
|
published_at: self.published_at,
|
||||||
|
ingested_at: self.ingested_at,
|
||||||
|
state: parse_state(&self.state),
|
||||||
|
score: self.score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
63
crates/newsfeed-data/src/store/interests.rs
Normal file
63
crates/newsfeed-data/src/store/interests.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreResult;
|
||||||
|
use newsfeed_core::ports::InterestStore;
|
||||||
|
use newsfeed_entities::interest::{Interest, UpsertInterest};
|
||||||
|
|
||||||
|
use crate::err;
|
||||||
|
use crate::rows::InterestRow;
|
||||||
|
|
||||||
|
use super::SqliteStore;
|
||||||
|
|
||||||
|
const SELECT: &str = "SELECT id, user_id, label, weight, created_at FROM interests";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl InterestStore for SqliteStore {
|
||||||
|
async fn list_interests(&self, user_id: Uuid) -> CoreResult<Vec<Interest>> {
|
||||||
|
let rows: Vec<InterestRow> = sqlx::query_as(&format!(
|
||||||
|
"{SELECT} WHERE user_id = ? ORDER BY weight DESC, label ASC"
|
||||||
|
))
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
rows.into_iter().map(InterestRow::into_interest).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_interest(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
upsert: &UpsertInterest,
|
||||||
|
) -> CoreResult<Interest> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
// On (user_id, label) conflict, update the weight in place and return the row.
|
||||||
|
let row: InterestRow = sqlx::query_as(&format!(
|
||||||
|
"INSERT INTO interests (id, user_id, label, weight, created_at) VALUES (?, ?, ?, ?, ?) \
|
||||||
|
ON CONFLICT(user_id, label) DO UPDATE SET weight = excluded.weight \
|
||||||
|
RETURNING {}",
|
||||||
|
"id, user_id, label, weight, created_at"
|
||||||
|
))
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(upsert.label.trim())
|
||||||
|
.bind(upsert.weight)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
row.into_interest()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_interest(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> {
|
||||||
|
sqlx::query("DELETE FROM interests WHERE id = ? AND user_id = ?")
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
206
crates/newsfeed-data/src/store/items.rs
Normal file
206
crates/newsfeed-data/src/store/items.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::error::{CoreError, CoreResult};
|
||||||
|
use newsfeed_core::ports::{ItemStore, NewCandidate};
|
||||||
|
use newsfeed_entities::item::{ContentItem, ItemState};
|
||||||
|
|
||||||
|
use crate::err;
|
||||||
|
use crate::rows::ItemRow;
|
||||||
|
|
||||||
|
use super::SqliteStore;
|
||||||
|
|
||||||
|
const COLS: &str = "id, user_id, source_id, external_id, url, title, summary, author, tags, media, published_at, ingested_at, state, score";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ItemStore for SqliteStore {
|
||||||
|
async fn upsert_candidate(&self, candidate: &NewCandidate) -> CoreResult<(ContentItem, bool)> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let tags = serde_json::to_string(&candidate.tags).map_err(err::json)?;
|
||||||
|
let media = serde_json::to_string(&candidate.media).map_err(err::json)?;
|
||||||
|
|
||||||
|
// Insert as a candidate; on (user_id, external_id) conflict do nothing. RETURNING
|
||||||
|
// yields a row only when a new row was actually inserted.
|
||||||
|
let inserted: Option<ItemRow> = sqlx::query_as(&format!(
|
||||||
|
"INSERT INTO items ({COLS}) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'candidate', 0.0) \
|
||||||
|
ON CONFLICT(user_id, external_id) DO NOTHING \
|
||||||
|
RETURNING {COLS}"
|
||||||
|
))
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(candidate.user_id.to_string())
|
||||||
|
.bind(candidate.source_id.map(|s| s.to_string()))
|
||||||
|
.bind(&candidate.external_id)
|
||||||
|
.bind(&candidate.url)
|
||||||
|
.bind(&candidate.title)
|
||||||
|
.bind(&candidate.summary)
|
||||||
|
.bind(&candidate.author)
|
||||||
|
.bind(tags)
|
||||||
|
.bind(media)
|
||||||
|
.bind(candidate.published_at)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
|
||||||
|
if let Some(row) = inserted {
|
||||||
|
return Ok((row.into_item()?, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict: return the pre-existing item.
|
||||||
|
let existing: ItemRow = sqlx::query_as(&format!(
|
||||||
|
"SELECT {COLS} FROM items WHERE user_id = ? AND external_id = ?"
|
||||||
|
))
|
||||||
|
.bind(candidate.user_id.to_string())
|
||||||
|
.bind(&candidate.external_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok((existing.into_item()?, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_item(&self, user_id: Uuid, id: Uuid) -> CoreResult<Option<ContentItem>> {
|
||||||
|
let row: Option<ItemRow> = sqlx::query_as(&format!(
|
||||||
|
"SELECT {COLS} FROM items WHERE id = ? AND user_id = ?"
|
||||||
|
))
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
row.map(ItemRow::into_item).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_item_state(&self, user_id: Uuid, id: Uuid, state: ItemState) -> CoreResult<()> {
|
||||||
|
let res = sqlx::query("UPDATE items SET state = ? WHERE id = ? AND user_id = ?")
|
||||||
|
.bind(state.as_str())
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(CoreError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_score(&self, item_id: Uuid, score: f64) -> CoreResult<()> {
|
||||||
|
sqlx::query("UPDATE items SET score = ? WHERE id = ?")
|
||||||
|
.bind(score)
|
||||||
|
.bind(item_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn feed_page(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
include_saved: bool,
|
||||||
|
) -> CoreResult<Vec<ContentItem>> {
|
||||||
|
let states: &[&str] = if include_saved {
|
||||||
|
&["feed", "saved"]
|
||||||
|
} else {
|
||||||
|
&["feed"]
|
||||||
|
};
|
||||||
|
let placeholders = states.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||||
|
|
||||||
|
// Keyset pagination over (score DESC, id DESC).
|
||||||
|
let (cursor_clause, cursor_score, cursor_id) = match cursor {
|
||||||
|
Some(c) => {
|
||||||
|
let (score, id) = parse_cursor(c)?;
|
||||||
|
(
|
||||||
|
" AND (score < ? OR (score = ? AND id < ?))",
|
||||||
|
Some(score),
|
||||||
|
Some(id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => ("", None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {COLS} FROM items WHERE user_id = ? AND state IN ({placeholders}){cursor_clause} \
|
||||||
|
ORDER BY score DESC, id DESC LIMIT ?"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut q = sqlx::query_as::<_, ItemRow>(&sql).bind(user_id.to_string());
|
||||||
|
for s in states {
|
||||||
|
q = q.bind(*s);
|
||||||
|
}
|
||||||
|
if let (Some(score), Some(id)) = (cursor_score, cursor_id) {
|
||||||
|
q = q.bind(score).bind(score).bind(id);
|
||||||
|
}
|
||||||
|
q = q.bind(limit);
|
||||||
|
|
||||||
|
let rows = q.fetch_all(&self.pool).await.map_err(err::map)?;
|
||||||
|
rows.into_iter().map(ItemRow::into_item).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn items_for_scoring(&self, user_id: Uuid, limit: i64) -> CoreResult<Vec<ContentItem>> {
|
||||||
|
let rows: Vec<ItemRow> = sqlx::query_as(&format!(
|
||||||
|
"SELECT {COLS} FROM items WHERE user_id = ? AND state IN ('candidate', 'feed') \
|
||||||
|
ORDER BY ingested_at DESC LIMIT ?"
|
||||||
|
))
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
rows.into_iter().map(ItemRow::into_item).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_ids_with_sources(&self) -> CoreResult<Vec<Uuid>> {
|
||||||
|
let ids: Vec<(String,)> = sqlx::query_as("SELECT DISTINCT user_id FROM sources")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
ids.into_iter()
|
||||||
|
.map(|(s,)| Uuid::parse_str(&s).map_err(err::uuid))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_signal(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
item_id: Uuid,
|
||||||
|
action: newsfeed_entities::feed::SignalAction,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let action = match action {
|
||||||
|
newsfeed_entities::feed::SignalAction::View => "view",
|
||||||
|
newsfeed_entities::feed::SignalAction::Click => "click",
|
||||||
|
newsfeed_entities::feed::SignalAction::Save => "save",
|
||||||
|
newsfeed_entities::feed::SignalAction::Dismiss => "dismiss",
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO signals (id, user_id, item_id, action, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(item_id.to_string())
|
||||||
|
.bind(action)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cursor is `"<score>:<uuid>"`; the id disambiguates equal scores.
|
||||||
|
fn parse_cursor(c: &str) -> CoreResult<(f64, String)> {
|
||||||
|
let (score, id) = c
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| CoreError::Storage("malformed feed cursor".into()))?;
|
||||||
|
let score: f64 = score
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CoreError::Storage("malformed feed cursor score".into()))?;
|
||||||
|
// Validate the id is a uuid but keep the string form for binding.
|
||||||
|
Uuid::parse_str(id).map_err(err::uuid)?;
|
||||||
|
Ok((score, id.to_string()))
|
||||||
|
}
|
||||||
33
crates/newsfeed-data/src/store/mod.rs
Normal file
33
crates/newsfeed-data/src/store/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//! The concrete [`SqliteStore`] and its per-domain port implementations.
|
||||||
|
|
||||||
|
mod interests;
|
||||||
|
mod items;
|
||||||
|
mod sessions;
|
||||||
|
mod sources;
|
||||||
|
mod tokens;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
|
||||||
|
/// SQLite-backed adapter implementing all of the `newsfeed-core` ports.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqliteStore {
|
||||||
|
pub(crate) pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteStore {
|
||||||
|
/// Wrap an existing pool. Prefer [`crate::connect`] which also runs migrations.
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the underlying pool (tests, health checks).
|
||||||
|
pub fn pool(&self) -> &SqlitePool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap readiness check: `true` when the database answers a trivial query.
|
||||||
|
pub async fn ping(&self) -> bool {
|
||||||
|
sqlx::query("SELECT 1").execute(&self.pool).await.is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
crates/newsfeed-data/src/store/sessions.rs
Normal file
65
crates/newsfeed-data/src/store/sessions.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreResult;
|
||||||
|
use newsfeed_core::ports::SessionStore;
|
||||||
|
use newsfeed_entities::auth::Session;
|
||||||
|
|
||||||
|
use crate::err;
|
||||||
|
use crate::rows::SessionRow;
|
||||||
|
|
||||||
|
use super::SqliteStore;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SessionStore for SqliteStore {
|
||||||
|
async fn create_session(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> CoreResult<Session> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(now)
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
|
||||||
|
Ok(Session {
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
created_at: now,
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_session(&self, token_hash: &str) -> CoreResult<Option<Session>> {
|
||||||
|
let row: Option<SessionRow> = sqlx::query_as(
|
||||||
|
"SELECT id, user_id, created_at, expires_at FROM sessions \
|
||||||
|
WHERE token_hash = ? AND expires_at > ?",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
row.map(SessionRow::into_session).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_session(&self, token_hash: &str) -> CoreResult<()> {
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
113
crates/newsfeed-data/src/store/sources.rs
Normal file
113
crates/newsfeed-data/src/store/sources.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreResult;
|
||||||
|
use newsfeed_core::ports::SourceStore;
|
||||||
|
use newsfeed_entities::source::{NewSource, Source};
|
||||||
|
|
||||||
|
use crate::err;
|
||||||
|
use crate::rows::SourceRow;
|
||||||
|
|
||||||
|
use super::SqliteStore;
|
||||||
|
|
||||||
|
const SELECT: &str =
|
||||||
|
"SELECT id, user_id, kind, name, url, weight, enabled, last_polled_at, created_at FROM sources";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SourceStore for SqliteStore {
|
||||||
|
async fn create_source(&self, user_id: Uuid, new: &NewSource) -> CoreResult<Source> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let weight = new.weight.unwrap_or(1.0);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sources (id, user_id, kind, name, url, weight, enabled, created_at) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(new.kind.as_str())
|
||||||
|
.bind(&new.name)
|
||||||
|
.bind(&new.url)
|
||||||
|
.bind(weight)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
|
||||||
|
Ok(Source {
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
kind: new.kind,
|
||||||
|
name: new.name.clone(),
|
||||||
|
url: new.url.clone(),
|
||||||
|
weight,
|
||||||
|
enabled: true,
|
||||||
|
last_polled_at: None,
|
||||||
|
created_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_sources(&self, user_id: Uuid) -> CoreResult<Vec<Source>> {
|
||||||
|
let rows: Vec<SourceRow> = sqlx::query_as(&format!(
|
||||||
|
"{SELECT} WHERE user_id = ? ORDER BY created_at DESC"
|
||||||
|
))
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
rows.into_iter().map(SourceRow::into_source).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_source_by_name(&self, user_id: Uuid, name: &str) -> CoreResult<Option<Source>> {
|
||||||
|
let row: Option<SourceRow> =
|
||||||
|
sqlx::query_as(&format!("{SELECT} WHERE user_id = ? AND name = ?"))
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
row.map(SourceRow::into_source).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_source(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> {
|
||||||
|
sqlx::query("DELETE FROM sources WHERE id = ? AND user_id = ?")
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn due_rss_sources(
|
||||||
|
&self,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
min_interval_secs: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> CoreResult<Vec<Source>> {
|
||||||
|
// Due when never polled, or last poll older than the minimum interval.
|
||||||
|
let cutoff = now - chrono::Duration::seconds(min_interval_secs);
|
||||||
|
let rows: Vec<SourceRow> = sqlx::query_as(&format!(
|
||||||
|
"{SELECT} WHERE kind = 'rss' AND enabled = 1 \
|
||||||
|
AND (last_polled_at IS NULL OR last_polled_at < ?) \
|
||||||
|
ORDER BY last_polled_at ASC NULLS FIRST LIMIT ?"
|
||||||
|
))
|
||||||
|
.bind(cutoff)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
rows.into_iter().map(SourceRow::into_source).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_polled(&self, source_id: Uuid, at: DateTime<Utc>) -> CoreResult<()> {
|
||||||
|
sqlx::query("UPDATE sources SET last_polled_at = ? WHERE id = ?")
|
||||||
|
.bind(at)
|
||||||
|
.bind(source_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
90
crates/newsfeed-data/src/store/tokens.rs
Normal file
90
crates/newsfeed-data/src/store/tokens.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreResult;
|
||||||
|
use newsfeed_core::ports::TokenStore;
|
||||||
|
use newsfeed_entities::auth::ApiTokenInfo;
|
||||||
|
|
||||||
|
use crate::err;
|
||||||
|
use crate::rows::ApiTokenRow;
|
||||||
|
|
||||||
|
use super::SqliteStore;
|
||||||
|
|
||||||
|
const SELECT: &str =
|
||||||
|
"SELECT id, user_id, name, prefix, created_at, last_used_at, revoked_at FROM api_tokens";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TokenStore for SqliteStore {
|
||||||
|
async fn create_token(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
prefix: &str,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> CoreResult<ApiTokenInfo> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO api_tokens (id, user_id, name, prefix, token_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(name)
|
||||||
|
.bind(prefix)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
|
||||||
|
Ok(ApiTokenInfo {
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
prefix: prefix.to_string(),
|
||||||
|
created_at: now,
|
||||||
|
last_used_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_tokens(&self, user_id: Uuid) -> CoreResult<Vec<ApiTokenInfo>> {
|
||||||
|
let rows: Vec<ApiTokenRow> = sqlx::query_as(&format!(
|
||||||
|
"{SELECT} WHERE user_id = ? ORDER BY created_at DESC"
|
||||||
|
))
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
rows.into_iter().map(ApiTokenRow::into_info).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authenticate_token(&self, token_hash: &str) -> CoreResult<Option<ApiTokenInfo>> {
|
||||||
|
let now = Utc::now();
|
||||||
|
// Record use and fetch in one round trip via RETURNING (SQLite ≥ 3.35).
|
||||||
|
let row: Option<ApiTokenRow> = sqlx::query_as(&format!(
|
||||||
|
"UPDATE api_tokens SET last_used_at = ? \
|
||||||
|
WHERE token_hash = ? AND revoked_at IS NULL \
|
||||||
|
RETURNING {}",
|
||||||
|
"id, user_id, name, prefix, created_at, last_used_at, revoked_at"
|
||||||
|
))
|
||||||
|
.bind(now)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
row.map(ApiTokenRow::into_info).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_token(&self, user_id: Uuid, id: Uuid) -> CoreResult<()> {
|
||||||
|
sqlx::query("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL")
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/newsfeed-data/src/store/users.rs
Normal file
67
crates/newsfeed-data/src/store/users.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::error::CoreResult;
|
||||||
|
use newsfeed_core::ports::{StoredCredential, UserStore};
|
||||||
|
use newsfeed_entities::user::User;
|
||||||
|
|
||||||
|
use crate::err;
|
||||||
|
use crate::rows::UserRow;
|
||||||
|
|
||||||
|
use super::SqliteStore;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserStore for SqliteStore {
|
||||||
|
async fn create_user(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
email: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> CoreResult<User> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(username)
|
||||||
|
.bind(email)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
|
||||||
|
Ok(User {
|
||||||
|
id,
|
||||||
|
username: username.to_string(),
|
||||||
|
email: email.to_string(),
|
||||||
|
created_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user(&self, id: Uuid) -> CoreResult<Option<User>> {
|
||||||
|
let row: Option<UserRow> = sqlx::query_as(
|
||||||
|
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
row.map(UserRow::into_user).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_credential(&self, identifier: &str) -> CoreResult<Option<StoredCredential>> {
|
||||||
|
let row: Option<UserRow> = sqlx::query_as(
|
||||||
|
"SELECT id, username, email, password_hash, created_at FROM users \
|
||||||
|
WHERE username = ? OR email = ?",
|
||||||
|
)
|
||||||
|
.bind(identifier)
|
||||||
|
.bind(identifier)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(err::map)?;
|
||||||
|
row.map(UserRow::into_credential).transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/newsfeed-entities/Cargo.toml
Normal file
16
crates/newsfeed-entities/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "newsfeed-entities"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Domain types, DTOs and error enums for newsfeed. No I/O."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
ts-rs.workspace = true
|
||||||
61
crates/newsfeed-entities/src/auth.rs
Normal file
61
crates/newsfeed-entities/src/auth.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Session and API-token DTOs.
|
||||||
|
//!
|
||||||
|
//! Humans authenticate with an opaque session cookie; agentic and algorithmic sources
|
||||||
|
//! authenticate with per-user bearer API tokens. In both cases only a hash of the
|
||||||
|
//! secret is ever persisted — see `newsfeed-core::auth`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// An authenticated browser session, keyed by the hash of an opaque cookie value.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public metadata about an API token. The secret itself is shown exactly once, at
|
||||||
|
/// creation, and is never retrievable afterwards.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ApiTokenInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
/// Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token.
|
||||||
|
pub prefix: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to mint a new API token.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct CreateApiToken {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response returned once when a token is created. `secret` is the full bearer token
|
||||||
|
/// (`nf_<prefix>_<random>`) and is never persisted in the clear.
|
||||||
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct CreatedApiToken {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub info: ApiTokenInfo,
|
||||||
|
/// The full secret. Present only in this response.
|
||||||
|
pub secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current authenticated principal, returned by `GET /v1/auth/me`.
|
||||||
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Me {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
30
crates/newsfeed-entities/src/error.rs
Normal file
30
crates/newsfeed-entities/src/error.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//! Domain-level error type. Validation and invariant failures live here; I/O errors
|
||||||
|
//! belong to the crates that perform the I/O (`newsfeed-data`, the binaries).
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors produced while constructing or validating domain values.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// A user-supplied value failed validation (empty, malformed, out of range).
|
||||||
|
#[error("invalid {field}: {reason}")]
|
||||||
|
Invalid {
|
||||||
|
/// The field that failed validation.
|
||||||
|
field: &'static str,
|
||||||
|
/// Why it failed.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Convenience constructor for [`Error::Invalid`].
|
||||||
|
pub fn invalid(field: &'static str, reason: impl Into<String>) -> Self {
|
||||||
|
Self::Invalid {
|
||||||
|
field,
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result alias for domain operations.
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
54
crates/newsfeed-entities/src/feed.rs
Normal file
54
crates/newsfeed-entities/src/feed.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! Feed query/response DTOs and interaction signals. Signals are the feedback loop:
|
||||||
|
//! what the user does with an item feeds back into future ranking — but only ever
|
||||||
|
//! according to weights the user themselves controls.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::item::ContentItem;
|
||||||
|
|
||||||
|
/// Query parameters for `GET /v1/feed`.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct FeedQuery {
|
||||||
|
/// Max items to return. Defaults applied server-side.
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
/// Opaque cursor for pagination (score+id of the last item seen).
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
/// When true, include saved items; otherwise only live feed items.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_saved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of ranked feed items.
|
||||||
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct FeedPage {
|
||||||
|
pub items: Vec<ContentItem>,
|
||||||
|
/// Cursor to pass back for the next page, or `None` at the end.
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kinds of interaction the user can have with an item.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum SignalAction {
|
||||||
|
/// The item scrolled into view.
|
||||||
|
View,
|
||||||
|
/// The user opened/clicked through.
|
||||||
|
Click,
|
||||||
|
/// The user saved it.
|
||||||
|
Save,
|
||||||
|
/// The user dismissed it.
|
||||||
|
Dismiss,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recorded interaction, posted to `POST /v1/feed/signals`.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Signal {
|
||||||
|
pub item_id: Uuid,
|
||||||
|
pub action: SignalAction,
|
||||||
|
}
|
||||||
45
crates/newsfeed-entities/src/interest.rs
Normal file
45
crates/newsfeed-entities/src/interest.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! Interests: the user-controlled weightings that drive ranking. A user tells the system
|
||||||
|
//! what they care about and how much; the ranker (see `newsfeed-core::ranking`) uses
|
||||||
|
//! these weights — and nothing else's opinion — to score their feed.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// A weighted topic of interest belonging to a single user.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Interest {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
/// Free-text label matched (case-insensitively) against item title/summary/tags.
|
||||||
|
pub label: String,
|
||||||
|
/// How strongly this interest pulls matching items up the feed, `-1.0..=1.0`.
|
||||||
|
/// Negative weights actively bury matching content.
|
||||||
|
pub weight: f64,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create or update an interest.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct UpsertInterest {
|
||||||
|
pub label: String,
|
||||||
|
pub weight: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpsertInterest {
|
||||||
|
/// Validate an interest upsert.
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if self.label.trim().is_empty() {
|
||||||
|
return Err(Error::invalid("label", "must not be empty"));
|
||||||
|
}
|
||||||
|
if !(-1.0..=1.0).contains(&self.weight) {
|
||||||
|
return Err(Error::invalid("weight", "must be within -1.0..=1.0"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
89
crates/newsfeed-entities/src/item.rs
Normal file
89
crates/newsfeed-entities/src/item.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! Content items: the units that flow through curation into a user's feed. An item
|
||||||
|
//! enters as a *candidate* (either polled by the worker or pushed to the ingest
|
||||||
|
//! endpoint), gets scored, and then lives in the feed until the user acts on it.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Lifecycle state of a content item.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum ItemState {
|
||||||
|
/// Newly ingested, awaiting scoring/curation.
|
||||||
|
Candidate,
|
||||||
|
/// Scored and live in the feed.
|
||||||
|
Feed,
|
||||||
|
/// The user explicitly saved/bookmarked it.
|
||||||
|
Saved,
|
||||||
|
/// The user dismissed it; kept for negative signal, hidden from the feed.
|
||||||
|
Dismissed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemState {
|
||||||
|
/// Stable string form used in the database and on the wire.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ItemState::Candidate => "candidate",
|
||||||
|
ItemState::Feed => "feed",
|
||||||
|
ItemState::Saved => "saved",
|
||||||
|
ItemState::Dismissed => "dismissed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A media attachment (image/video/audio) associated with an item.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Media {
|
||||||
|
pub kind: String,
|
||||||
|
pub url: String,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A content item owned by a user.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ContentItem {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub source_id: Option<Uuid>,
|
||||||
|
/// De-duplication key from the origin (feed guid, tweet id, URL, …).
|
||||||
|
pub external_id: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub media: Vec<Media>,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
pub ingested_at: DateTime<Utc>,
|
||||||
|
pub state: ItemState,
|
||||||
|
/// Cached ranking score; recomputed by the ranker as weights/signals change.
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A candidate pushed to `POST /v1/ingest/candidates` by an agentic or algorithmic
|
||||||
|
/// source. The authenticated API token determines the owning user; `source` names the
|
||||||
|
/// producer for attribution and per-source weighting.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct CandidateSubmission {
|
||||||
|
/// Stable de-dup key from the producer. Re-submitting the same key is a no-op.
|
||||||
|
pub external_id: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub media: Vec<Media>,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
/// Optional name of the source that produced this candidate. If it matches an
|
||||||
|
/// existing agentic source for the user it is linked; otherwise ingested unlinked.
|
||||||
|
pub source: Option<String>,
|
||||||
|
}
|
||||||
22
crates/newsfeed-entities/src/lib.rs
Normal file
22
crates/newsfeed-entities/src/lib.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//! Domain types, DTOs and error enums shared across the newsfeed workspace.
|
||||||
|
//!
|
||||||
|
//! This crate is intentionally free of I/O and async-runtime dependencies. Everything
|
||||||
|
//! downstream (`newsfeed-core`, `newsfeed-data`, the binaries and — via generated
|
||||||
|
//! TypeScript bindings — the web frontend) depends on these types.
|
||||||
|
//!
|
||||||
|
//! Wire types derive [`ts_rs::TS`] with `#[ts(export)]` so `cargo test -p
|
||||||
|
//! newsfeed-entities` regenerates the frontend bindings under `web/src/api/bindings/`
|
||||||
|
//! (path set by `TS_RS_EXPORT_DIR` in `.cargo/config.toml`).
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod error;
|
||||||
|
pub mod feed;
|
||||||
|
pub mod interest;
|
||||||
|
pub mod item;
|
||||||
|
pub mod source;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
/// Newtype over a [`uuid::Uuid`] identifier, re-exported for convenience.
|
||||||
|
pub type Id = uuid::Uuid;
|
||||||
92
crates/newsfeed-entities/src/source.rs
Normal file
92
crates/newsfeed-entities/src/source.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! Content sources. A source is where candidate items come from — an RSS/Atom feed the
|
||||||
|
//! worker polls, or an agentic/external producer that pushes candidates to the ingest
|
||||||
|
//! endpoint under a user's API token.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// How a source produces candidates.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum SourceKind {
|
||||||
|
/// An RSS/Atom feed the worker polls on a schedule.
|
||||||
|
Rss,
|
||||||
|
/// A producer that pushes candidates to `POST /v1/ingest/candidates`
|
||||||
|
/// (agentic workloads, scrapers, bespoke algorithms).
|
||||||
|
Agentic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceKind {
|
||||||
|
/// Stable string form used in the database and on the wire.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SourceKind::Rss => "rss",
|
||||||
|
SourceKind::Agentic => "agentic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from the database/wire representation.
|
||||||
|
pub fn parse(s: &str) -> Result<Self> {
|
||||||
|
match s {
|
||||||
|
"rss" => Ok(SourceKind::Rss),
|
||||||
|
"agentic" => Ok(SourceKind::Agentic),
|
||||||
|
other => Err(Error::invalid(
|
||||||
|
"kind",
|
||||||
|
format!("unknown source kind: {other}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user's configured content source.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Source {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub kind: SourceKind,
|
||||||
|
pub name: String,
|
||||||
|
/// Poll URL for [`SourceKind::Rss`]; unused/optional for agentic sources.
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Baseline weight applied to every item from this source, `0.0..=1.0`.
|
||||||
|
pub weight: f64,
|
||||||
|
pub enabled: bool,
|
||||||
|
/// When the worker last polled this source (rss only).
|
||||||
|
pub last_polled_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to create a source.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct NewSource {
|
||||||
|
pub kind: SourceKind,
|
||||||
|
pub name: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Defaults to `1.0` when omitted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub weight: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewSource {
|
||||||
|
/// Validate a create-source request.
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(Error::invalid("name", "must not be empty"));
|
||||||
|
}
|
||||||
|
if self.kind == SourceKind::Rss && self.url.as_deref().unwrap_or("").is_empty() {
|
||||||
|
return Err(Error::invalid("url", "rss sources require a url"));
|
||||||
|
}
|
||||||
|
if let Some(w) = self.weight {
|
||||||
|
if !(0.0..=1.0).contains(&w) {
|
||||||
|
return Err(Error::invalid("weight", "must be within 0.0..=1.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/newsfeed-entities/src/user.rs
Normal file
54
crates/newsfeed-entities/src/user.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! User accounts. Each user is fully in control of their own feed weightings.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// A registered user. The password hash never leaves `newsfeed-data`/`newsfeed-core`;
|
||||||
|
/// it is deliberately absent from this DTO so it can't be serialised to a client.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registration request from the sign-up form.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterRequest {
|
||||||
|
/// Validate the shape of a registration request before it reaches the store.
|
||||||
|
/// Uniqueness is enforced by the database, not here.
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if self.username.trim().len() < 3 {
|
||||||
|
return Err(Error::invalid("username", "must be at least 3 characters"));
|
||||||
|
}
|
||||||
|
if !self.email.contains('@') {
|
||||||
|
return Err(Error::invalid("email", "must contain '@'"));
|
||||||
|
}
|
||||||
|
if self.password.len() < 8 {
|
||||||
|
return Err(Error::invalid("password", "must be at least 8 characters"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login request.
|
||||||
|
#[derive(Debug, Clone, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
/// Username or email.
|
||||||
|
pub identifier: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
31
crates/newsfeed-worker/Cargo.toml
Normal file
31
crates/newsfeed-worker/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "newsfeed-worker"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Algorithmic content-sourcing worker for newsfeed (RSS/Atom polling)."
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "newsfeed-worker"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
newsfeed-entities.workspace = true
|
||||||
|
newsfeed-core.workspace = true
|
||||||
|
newsfeed-data.workspace = true
|
||||||
|
|
||||||
|
tokio.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
feed-rs.workspace = true
|
||||||
|
|
||||||
|
serde.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
figment.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
55
crates/newsfeed-worker/src/config.rs
Normal file
55
crates/newsfeed-worker/src/config.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//! Worker configuration (figment-layered: defaults → TOML → `NEWSFEED_*` env).
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use figment::Figment;
|
||||||
|
use figment::providers::{Env, Format, Serialized, Toml};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Worker configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// SQLite database file, shared with the api on the same host.
|
||||||
|
pub database_path: PathBuf,
|
||||||
|
/// SQLite pool size for the worker.
|
||||||
|
pub max_db_connections: u32,
|
||||||
|
/// Seconds to sleep between poll cycles.
|
||||||
|
pub tick_secs: u64,
|
||||||
|
/// Minimum seconds between polls of the same RSS source.
|
||||||
|
pub source_min_interval_secs: i64,
|
||||||
|
/// Max sources polled per cycle.
|
||||||
|
pub batch: i64,
|
||||||
|
/// Max items rescored per user per cycle.
|
||||||
|
pub rescore_limit: i64,
|
||||||
|
/// HTTP request timeout when fetching feeds, in seconds.
|
||||||
|
pub http_timeout_secs: u64,
|
||||||
|
/// User-Agent sent when fetching feeds.
|
||||||
|
pub user_agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
database_path: PathBuf::from("/var/lib/newsfeed/newsfeed.db"),
|
||||||
|
max_db_connections: 2,
|
||||||
|
tick_secs: 60,
|
||||||
|
source_min_interval_secs: 900,
|
||||||
|
batch: 20,
|
||||||
|
rescore_limit: 500,
|
||||||
|
http_timeout_secs: 20,
|
||||||
|
user_agent: concat!("newsfeed-worker/", env!("CARGO_PKG_VERSION")).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Load configuration, layering an optional TOML file and the environment over the
|
||||||
|
/// defaults.
|
||||||
|
pub fn load(file: Option<&Path>) -> anyhow::Result<Self> {
|
||||||
|
let mut fig = Figment::from(Serialized::defaults(Config::default()));
|
||||||
|
if let Some(path) = file {
|
||||||
|
fig = fig.merge(Toml::file(path));
|
||||||
|
}
|
||||||
|
Ok(fig.merge(Env::prefixed("NEWSFEED_")).extract()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
168
crates/newsfeed-worker/src/main.rs
Normal file
168
crates/newsfeed-worker/src/main.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! newsfeed algorithmic-sourcing worker.
|
||||||
|
//!
|
||||||
|
//! Co-located with the api on the same host (they share one SQLite file). Each cycle it
|
||||||
|
//! polls due RSS/Atom sources, normalises and scores new entries into their owners'
|
||||||
|
//! feeds, then rescores existing items so weight changes re-rank the feed. Idempotent:
|
||||||
|
//! re-seeing an item's `external_id` is a no-op.
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod sourcing;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use chrono::Utc;
|
||||||
|
use clap::Parser;
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::signal;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use newsfeed_core::ports::{InterestStore, ItemStore, SourceStore};
|
||||||
|
use newsfeed_core::{ingest, service};
|
||||||
|
use newsfeed_data::SqliteStore;
|
||||||
|
use newsfeed_entities::interest::Interest;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::sourcing::rss;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "newsfeed-worker",
|
||||||
|
version,
|
||||||
|
about = "newsfeed content-sourcing worker"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to a TOML config file. Env (`NEWSFEED_*`) overrides file values.
|
||||||
|
#[arg(long, default_value = "/etc/newsfeed/worker.toml")]
|
||||||
|
config: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config_file = cli.config.exists().then_some(cli.config.as_path());
|
||||||
|
let cfg = Config::load(config_file).context("loading configuration")?;
|
||||||
|
tracing::info!(db = %cfg.database_path.display(), tick_secs = cfg.tick_secs, "starting newsfeed-worker");
|
||||||
|
|
||||||
|
let store = newsfeed_data::connect(&cfg.database_path, cfg.max_db_connections)
|
||||||
|
.await
|
||||||
|
.context("opening database")?;
|
||||||
|
let client = Client::builder()
|
||||||
|
.user_agent(&cfg.user_agent)
|
||||||
|
.timeout(Duration::from_secs(cfg.http_timeout_secs))
|
||||||
|
.build()
|
||||||
|
.context("building HTTP client")?;
|
||||||
|
|
||||||
|
let mut ticker = tokio::time::interval(Duration::from_secs(cfg.tick_secs));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = ticker.tick() => {
|
||||||
|
if let Err(e) = run_cycle(&store, &client, &cfg).await {
|
||||||
|
tracing::error!(error = %e, "poll cycle failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = shutdown_signal() => {
|
||||||
|
tracing::info!("shutdown signal received, exiting");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One poll + rescore cycle.
|
||||||
|
async fn run_cycle(store: &SqliteStore, client: &Client, cfg: &Config) -> anyhow::Result<()> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let due = store
|
||||||
|
.due_rss_sources(now, cfg.source_min_interval_secs, cfg.batch)
|
||||||
|
.await?;
|
||||||
|
tracing::debug!(count = due.len(), "polling due sources");
|
||||||
|
|
||||||
|
let mut interests_cache: HashMap<Uuid, Vec<Interest>> = HashMap::new();
|
||||||
|
|
||||||
|
for source in due {
|
||||||
|
let Some(url) = source.url.clone() else {
|
||||||
|
store.mark_polled(source.id, now).await?;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match rss::fetch(client, &url).await {
|
||||||
|
Ok(entries) => {
|
||||||
|
let interests = if let Some(cached) = interests_cache.get(&source.user_id) {
|
||||||
|
cached
|
||||||
|
} else {
|
||||||
|
let loaded = store.list_interests(source.user_id).await?;
|
||||||
|
interests_cache.entry(source.user_id).or_insert(loaded)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut added = 0usize;
|
||||||
|
for entry in entries {
|
||||||
|
let candidate =
|
||||||
|
ingest::candidate_from_feed_entry(source.user_id, source.id, entry);
|
||||||
|
match ingest::persist_and_rank(store, candidate, source.weight, interests).await
|
||||||
|
{
|
||||||
|
Ok(item) if item.ingested_at >= now => added += 1,
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(source = %source.name, error = %e, "failed to persist candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(source = %source.name, added, "polled source");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(source = %source.name, %url, error = %e, "failed to fetch feed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark polled regardless so a persistently-failing feed doesn't monopolise cycles.
|
||||||
|
store.mark_polled(source.id, now).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rescore each active user's feed against current weights.
|
||||||
|
for user_id in store.user_ids_with_sources().await? {
|
||||||
|
match service::rescore_user(store, user_id, cfg.rescore_limit).await {
|
||||||
|
Ok(n) => tracing::debug!(%user_id, rescored = n, "rescored feed"),
|
||||||
|
Err(e) => tracing::warn!(%user_id, error = %e, "rescore failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let registry = tracing_subscriber::registry().with(filter);
|
||||||
|
if std::env::var_os("JOURNAL_STREAM").is_some() {
|
||||||
|
registry
|
||||||
|
.with(fmt::layer().json().flatten_event(true))
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
registry.with(fmt::layer()).init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
signal::ctrl_c().await.expect("install Ctrl-C handler");
|
||||||
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/newsfeed-worker/src/sourcing/mod.rs
Normal file
3
crates/newsfeed-worker/src/sourcing/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
//! Content sourcing. Currently RSS/Atom polling; other algorithmic sources plug in here.
|
||||||
|
|
||||||
|
pub mod rss;
|
||||||
80
crates/newsfeed-worker/src/sourcing/rss.rs
Normal file
80
crates/newsfeed-worker/src/sourcing/rss.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//! Fetch and parse an RSS/Atom feed into backend-agnostic [`FeedEntry`] values.
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use newsfeed_core::ingest::FeedEntry;
|
||||||
|
use newsfeed_entities::item::Media;
|
||||||
|
|
||||||
|
/// Fetch `url` and parse it, returning one [`FeedEntry`] per feed item.
|
||||||
|
pub async fn fetch(client: &Client, url: &str) -> anyhow::Result<Vec<FeedEntry>> {
|
||||||
|
let bytes = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("fetching {url}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.with_context(|| format!("bad status from {url}"))?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading body from {url}"))?;
|
||||||
|
|
||||||
|
let feed = feed_rs::parser::parse(&bytes[..]).with_context(|| format!("parsing feed {url}"))?;
|
||||||
|
|
||||||
|
Ok(feed.entries.into_iter().map(entry_to_feed_entry).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_to_feed_entry(entry: feed_rs::model::Entry) -> FeedEntry {
|
||||||
|
let url = entry.links.first().map(|l| l.href.clone());
|
||||||
|
// Prefer the entry's own id; fall back to the link so de-dup still works.
|
||||||
|
let external_id = if entry.id.is_empty() {
|
||||||
|
url.clone().unwrap_or_else(|| {
|
||||||
|
entry
|
||||||
|
.title
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.content.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
entry.id.clone()
|
||||||
|
};
|
||||||
|
let title = entry
|
||||||
|
.title
|
||||||
|
.map(|t| t.content)
|
||||||
|
.unwrap_or_else(|| "(untitled)".to_string());
|
||||||
|
let summary = entry
|
||||||
|
.summary
|
||||||
|
.map(|t| t.content)
|
||||||
|
.or_else(|| entry.content.and_then(|c| c.body));
|
||||||
|
let author = entry.authors.first().map(|p| p.name.clone());
|
||||||
|
let tags = entry.categories.into_iter().map(|c| c.term).collect();
|
||||||
|
let published_at = entry.published.or(entry.updated);
|
||||||
|
|
||||||
|
let media = entry
|
||||||
|
.media
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|m| m.content)
|
||||||
|
.filter_map(|c| {
|
||||||
|
c.url.map(|u| Media {
|
||||||
|
kind: c
|
||||||
|
.content_type
|
||||||
|
.map(|m| m.to_string())
|
||||||
|
.unwrap_or_else(|| "media".to_string()),
|
||||||
|
url: u.to_string(),
|
||||||
|
width: c.width,
|
||||||
|
height: c.height,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
FeedEntry {
|
||||||
|
external_id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
author,
|
||||||
|
tags,
|
||||||
|
media,
|
||||||
|
published_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
140
readme.md
Normal file
140
readme.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# 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](https://git.lair.cafe/grenade/architecture))
|
||||||
|
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.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# backend
|
||||||
|
cargo build --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
# frontend
|
||||||
|
cd web && pnpm install && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. API (creates ./data/newsfeed.db, binds 127.0.0.1:8081)
|
||||||
|
NEWSFEED_DATABASE_PATH=./data/newsfeed.db \
|
||||||
|
NEWSFEED_BIND=127.0.0.1:8081 \
|
||||||
|
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 :8081)
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# register + login (cookie jar)
|
||||||
|
curl -c cj -X POST localhost:8081/v1/auth/register -H content-type:application/json \
|
||||||
|
-d '{"username":"me","email":"me@example.com","password":"hunter2hunter2"}'
|
||||||
|
curl -c cj -X POST localhost:8081/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:8081/v1/interests -H content-type:application/json -d '{"label":"rust","weight":0.9}'
|
||||||
|
TOKEN=$(curl -b cj -X POST localhost:8081/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:8081/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:8081/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](https://git.lair.cafe/grenade/architecture). 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`
|
||||||
|
(`asset/nginx/newsfeed.public.conf`) — provision its Let's Encrypt cert, then symlink the
|
||||||
|
vhost into `sites-enabled`.
|
||||||
|
|
||||||
|
For the workspace-wide architectural conventions this project inherits, see the
|
||||||
|
[architecture repo](https://git.lair.cafe/grenade/architecture).
|
||||||
4
rust-toolchain.toml
Normal file
4
rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
|
targets = ["x86_64-unknown-linux-musl"]
|
||||||
213
script/infra-setup.sh
Executable file
213
script/infra-setup.sh
Executable file
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# One-time host provisioning for newsfeed's CI-driven deploy (architecture
|
||||||
|
# deployment-gitea-actions.md §2). Run by the operator from a workstation with full sudo
|
||||||
|
# and mesh ssh access — NOT by the CI runner. Idempotent; skips unreachable hosts.
|
||||||
|
#
|
||||||
|
# It provisions, per host:
|
||||||
|
# * the `gitea_ci` deploy user + the runner's authorized_key + systemd-journal group
|
||||||
|
# * a scoped /etc/sudoers.d/newsfeed_gitea_ci drop-in (visudo-verified)
|
||||||
|
# and per role:
|
||||||
|
# * API host (slartibartfast): the `newsfeed` service account, /etc/newsfeed +
|
||||||
|
# /var/lib/newsfeed, SELinux port label for 8081, firewalld service
|
||||||
|
# * WEB host (oolon): /var/www/newsfeed webroot, the internal `newsfeed.internal` TLS
|
||||||
|
# cert (lair provisioner) + step@newsfeed renewal, and the nginx vhost
|
||||||
|
#
|
||||||
|
# Usage: ./script/infra-setup.sh [--dry-run]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- infra truth (edit here if the topology changes) -------------------------------
|
||||||
|
API_HOST="slartibartfast.kosherinata.internal" # newsfeed-api + newsfeed-worker
|
||||||
|
WEB_HOST="oolon.kosherinata.internal" # nginx: SPA + API reverse proxy
|
||||||
|
API_PORT="8081"
|
||||||
|
CERT_NAME="newsfeed" # dot-free; serves ${CERT_NAME}.internal
|
||||||
|
RUNNER_PUBKEY="${HOME}/.ssh/id_gitea_ci.pub" # runner key distributed to gitea_ci
|
||||||
|
PROVISIONER_PW="${HOME}/.step/secrets/provisioner"
|
||||||
|
ROOT_CA="/etc/pki/ca-trust/source/anchors/root-internal.pem"
|
||||||
|
CA_URL="https://ca.internal"
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DRY_RUN=0
|
||||||
|
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
||||||
|
|
||||||
|
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33mWARN\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
run_remote() {
|
||||||
|
# run_remote <host> <script>
|
||||||
|
local host="$1" script="$2"
|
||||||
|
if [[ "$DRY_RUN" == 1 ]]; then
|
||||||
|
printf -- '--- would run on %s ---\n%s\n' "$host" "$script"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new "$host" "sudo bash -euo pipefail -s" <<<"$script"
|
||||||
|
}
|
||||||
|
|
||||||
|
reachable() {
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 "$1" true 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
require_files() {
|
||||||
|
[[ -f "$RUNNER_PUBKEY" ]] || { warn "runner pubkey $RUNNER_PUBKEY missing (ssh-keygen -t ed25519 -f ~/.ssh/id_gitea_ci)"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- gitea_ci user, key, journal group, sudoers (both hosts) ------------------------
|
||||||
|
provision_gitea_ci() {
|
||||||
|
local host="$1" sudoers="$2"
|
||||||
|
local pubkey; pubkey="$(cat "$RUNNER_PUBKEY")"
|
||||||
|
info "[$host] provisioning gitea_ci deploy user"
|
||||||
|
run_remote "$host" "
|
||||||
|
id gitea_ci >/dev/null 2>&1 || useradd --system --create-home --home-dir /var/lib/gitea_ci --shell /usr/sbin/nologin gitea_ci
|
||||||
|
usermod -aG systemd-journal gitea_ci
|
||||||
|
install -d -m 0700 -o gitea_ci -g gitea_ci /var/lib/gitea_ci/.ssh
|
||||||
|
grep -qF '${pubkey}' /var/lib/gitea_ci/.ssh/authorized_keys 2>/dev/null || echo '${pubkey}' >> /var/lib/gitea_ci/.ssh/authorized_keys
|
||||||
|
chown gitea_ci:gitea_ci /var/lib/gitea_ci/.ssh/authorized_keys
|
||||||
|
chmod 0600 /var/lib/gitea_ci/.ssh/authorized_keys
|
||||||
|
umask 022
|
||||||
|
cat > /etc/sudoers.d/newsfeed_gitea_ci <<'SUDOERS'
|
||||||
|
${sudoers}
|
||||||
|
SUDOERS
|
||||||
|
chmod 0440 /etc/sudoers.d/newsfeed_gitea_ci
|
||||||
|
visudo -cf /etc/sudoers.d/newsfeed_gitea_ci
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
API_SUDOERS='# Scoped deploy permissions for newsfeed api+worker on this host.
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/newsfeed-api
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/newsfeed-worker
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/newsfeed/config.toml
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/newsfeed/worker.toml
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart newsfeed-api.service
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart newsfeed-worker.service
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active newsfeed-api.service
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/newsfeed-api /usr/local/bin/newsfeed-worker /etc/newsfeed /var/lib/newsfeed'
|
||||||
|
|
||||||
|
WEB_SUDOERS='# Scoped deploy permissions for the newsfeed SPA on the oolon proxy.
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/newsfeed/
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/newsfeed
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t
|
||||||
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx.service'
|
||||||
|
|
||||||
|
# --- API host: service account, dirs, SELinux port, firewalld -----------------------
|
||||||
|
provision_api_host() {
|
||||||
|
local host="$API_HOST"
|
||||||
|
if ! reachable "$host"; then warn "[$host] unreachable, skipping"; return 0; fi
|
||||||
|
provision_gitea_ci "$host" "$API_SUDOERS"
|
||||||
|
|
||||||
|
info "[$host] service account, directories, SELinux, firewalld"
|
||||||
|
local sysusers firewalld
|
||||||
|
sysusers="$(cat "$REPO_ROOT/asset/systemd/newsfeed.sysusers.conf")"
|
||||||
|
firewalld="$(cat "$REPO_ROOT/asset/firewalld/newsfeed-api.xml")"
|
||||||
|
run_remote "$host" "
|
||||||
|
# service account
|
||||||
|
cat > /etc/sysusers.d/newsfeed.conf <<'SYSU'
|
||||||
|
${sysusers}
|
||||||
|
SYSU
|
||||||
|
systemd-sysusers /etc/sysusers.d/newsfeed.conf
|
||||||
|
|
||||||
|
# directories (config: root-owned, readable by service; state: service-owned)
|
||||||
|
install -d -m 0750 -o root -g newsfeed /etc/newsfeed
|
||||||
|
install -d -m 0750 -o newsfeed -g newsfeed /var/lib/newsfeed
|
||||||
|
install -d -m 0755 -o root -g root /usr/local/bin
|
||||||
|
restorecon -R /etc/newsfeed /var/lib/newsfeed
|
||||||
|
|
||||||
|
# SELinux: label the API port so the daemon may bind it
|
||||||
|
semanage port -l | grep -qE '^http_port_t.*[[:space:]]${API_PORT}([,[:space:]]|\$)' || semanage port -a -t http_port_t -p tcp ${API_PORT}
|
||||||
|
|
||||||
|
# firewalld named service (mesh-reachable API port)
|
||||||
|
cat > /etc/firewalld/services/newsfeed-api.xml <<'FWD'
|
||||||
|
${firewalld}
|
||||||
|
FWD
|
||||||
|
firewall-cmd --reload
|
||||||
|
zone=\$(firewall-cmd --get-default-zone)
|
||||||
|
firewall-cmd --permanent --zone=\$zone --query-service=newsfeed-api >/dev/null 2>&1 || firewall-cmd --permanent --zone=\$zone --add-service=newsfeed-api
|
||||||
|
firewall-cmd --zone=\$zone --query-service=newsfeed-api >/dev/null 2>&1 || firewall-cmd --zone=\$zone --add-service=newsfeed-api
|
||||||
|
"
|
||||||
|
|
||||||
|
info "[$host] installing systemd units"
|
||||||
|
for unit in newsfeed-api.service newsfeed-worker.service; do
|
||||||
|
local content; content="$(cat "$REPO_ROOT/asset/systemd/$unit")"
|
||||||
|
run_remote "$host" "cat > /etc/systemd/system/$unit <<'UNIT'
|
||||||
|
${content}
|
||||||
|
UNIT
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable $unit"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- WEB host (oolon): webroot, internal cert, nginx vhost --------------------------
|
||||||
|
provision_web_host() {
|
||||||
|
local host="$WEB_HOST"
|
||||||
|
if ! reachable "$host"; then warn "[$host] unreachable, skipping"; return 0; fi
|
||||||
|
provision_gitea_ci "$host" "$WEB_SUDOERS"
|
||||||
|
|
||||||
|
info "[$host] webroot"
|
||||||
|
run_remote "$host" "
|
||||||
|
install -d -m 0755 -o nginx -g nginx /var/www/newsfeed
|
||||||
|
# placeholder so nginx -t passes before the first SPA deploy
|
||||||
|
[ -f /var/www/newsfeed/index.html ] || echo '<!doctype html><title>newsfeed</title>' > /var/www/newsfeed/index.html
|
||||||
|
restorecon -R /var/www/newsfeed
|
||||||
|
"
|
||||||
|
|
||||||
|
mint_internal_cert "$host"
|
||||||
|
|
||||||
|
info "[$host] nginx vhost (newsfeed.internal)"
|
||||||
|
local vhost; vhost="$(cat "$REPO_ROOT/asset/nginx/newsfeed.internal.conf")"
|
||||||
|
run_remote "$host" "
|
||||||
|
install -d -m 0755 /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||||
|
cat > /etc/nginx/sites-available/newsfeed.internal.conf <<'VHOST'
|
||||||
|
${vhost}
|
||||||
|
VHOST
|
||||||
|
ln -sfn ../sites-available/newsfeed.internal.conf /etc/nginx/sites-enabled/newsfeed.internal.conf
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
"
|
||||||
|
info "[$host] to publish on the WAN, set server_name + a Let's Encrypt cert in"
|
||||||
|
info " asset/nginx/newsfeed.public.conf and symlink it into sites-enabled."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mint the first internal cert (idempotent) and enable renewal (internal-tls.md §4).
|
||||||
|
mint_internal_cert() {
|
||||||
|
local host="$1"
|
||||||
|
local cert="/etc/nginx/tls/cert/${CERT_NAME}.internal.pem"
|
||||||
|
local key="/etc/nginx/tls/key/${CERT_NAME}.internal.pem"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == 1 ]]; then info "[$host] would mint ${CERT_NAME}.internal cert"; return 0; fi
|
||||||
|
[[ -f "$PROVISIONER_PW" ]] || { warn "[$host] provisioner password $PROVISIONER_PW missing; skipping cert mint"; return 0; }
|
||||||
|
|
||||||
|
local state
|
||||||
|
state="$(ssh "$host" "[ -f $cert ] && step certificate verify $cert --roots $ROOT_CA >/dev/null 2>&1 && echo valid || echo missing")"
|
||||||
|
if [[ "$state" != valid ]]; then
|
||||||
|
info "[$host] minting ${CERT_NAME}.internal cert via lair provisioner"
|
||||||
|
rsync -az --rsync-path='sudo rsync' --chmod=0600 "$PROVISIONER_PW" "$host:/tmp/${CERT_NAME}-provisioner"
|
||||||
|
run_remote "$host" "
|
||||||
|
mkdir -p /etc/nginx/tls/cert /etc/nginx/tls/key
|
||||||
|
rc=0
|
||||||
|
step ca certificate --force \
|
||||||
|
--provisioner lair \
|
||||||
|
--provisioner-password-file /tmp/${CERT_NAME}-provisioner \
|
||||||
|
--ca-url $CA_URL --root $ROOT_CA \
|
||||||
|
--san ${CERT_NAME}.internal \
|
||||||
|
${CERT_NAME}.internal $cert $key || rc=\$?
|
||||||
|
rm -f /tmp/${CERT_NAME}-provisioner
|
||||||
|
[ \$rc -eq 0 ] || { echo 'cert mint failed' >&2; exit \$rc; }
|
||||||
|
chown root:root $cert $key
|
||||||
|
chmod 644 $cert; chmod 640 $key
|
||||||
|
setfacl -m u:nginx:r $key
|
||||||
|
"
|
||||||
|
else
|
||||||
|
info "[$host] ${CERT_NAME}.internal cert already valid"
|
||||||
|
fi
|
||||||
|
run_remote "$host" "systemctl enable --now step@${CERT_NAME}.timer"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_files
|
||||||
|
info "newsfeed infra-setup (api=$API_HOST web=$WEB_HOST)$([[ $DRY_RUN == 1 ]] && echo ' [dry-run]')"
|
||||||
|
provision_api_host
|
||||||
|
provision_web_host
|
||||||
|
info "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
6
web/.prettierrc.json
Normal file
6
web/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
25
web/eslint.config.js
Normal file
25
web/eslint.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'src/api/bindings'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0b0d10" />
|
||||||
|
<title>newsfeed</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
web/package.json
Normal file
34
web/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "newsfeed-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write src",
|
||||||
|
"gen:types": "cd .. && cargo test -p newsfeed-entities"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.13.0",
|
||||||
|
"globals": "^15.11.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
|
"eslint": "^9.13.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.14",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"typescript-eslint": "^8.11.0",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
1857
web/pnpm-lock.yaml
generated
Normal file
1857
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
web/src/App.tsx
Normal file
42
web/src/App.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AuthProvider, useAuth } from './lib/auth';
|
||||||
|
import { Nav } from './components/Nav';
|
||||||
|
import { Login } from './routes/Login';
|
||||||
|
import { Feed } from './routes/Feed';
|
||||||
|
import { Sources } from './routes/Sources';
|
||||||
|
import { Settings } from './routes/Settings';
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="center muted">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Nav />
|
||||||
|
<main className="content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Feed />} />
|
||||||
|
<Route path="/sources" element={<Sources />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Shell />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
web/src/api/bindings/ApiTokenInfo.ts
Normal file
11
web/src/api/bindings/ApiTokenInfo.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public metadata about an API token. The secret itself is shown exactly once, at
|
||||||
|
* creation, and is never retrievable afterwards.
|
||||||
|
*/
|
||||||
|
export type ApiTokenInfo = { id: string, user_id: string, name: string,
|
||||||
|
/**
|
||||||
|
* Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token.
|
||||||
|
*/
|
||||||
|
prefix: string, created_at: string, last_used_at: string | null, revoked_at: string | null, };
|
||||||
18
web/src/api/bindings/CandidateSubmission.ts
Normal file
18
web/src/api/bindings/CandidateSubmission.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Media } from "./Media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A candidate pushed to `POST /v1/ingest/candidates` by an agentic or algorithmic
|
||||||
|
* source. The authenticated API token determines the owning user; `source` names the
|
||||||
|
* producer for attribution and per-source weighting.
|
||||||
|
*/
|
||||||
|
export type CandidateSubmission = {
|
||||||
|
/**
|
||||||
|
* Stable de-dup key from the producer. Re-submitting the same key is a no-op.
|
||||||
|
*/
|
||||||
|
external_id: string, url: string | null, title: string, summary: string | null, author: string | null, tags: Array<string>, media: Array<Media>, published_at: string | null,
|
||||||
|
/**
|
||||||
|
* Optional name of the source that produced this candidate. If it matches an
|
||||||
|
* existing agentic source for the user it is linked; otherwise ingested unlinked.
|
||||||
|
*/
|
||||||
|
source: string | null, };
|
||||||
16
web/src/api/bindings/ContentItem.ts
Normal file
16
web/src/api/bindings/ContentItem.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { ItemState } from "./ItemState";
|
||||||
|
import type { Media } from "./Media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A content item owned by a user.
|
||||||
|
*/
|
||||||
|
export type ContentItem = { id: string, user_id: string, source_id: string | null,
|
||||||
|
/**
|
||||||
|
* De-duplication key from the origin (feed guid, tweet id, URL, …).
|
||||||
|
*/
|
||||||
|
external_id: string, url: string | null, title: string, summary: string | null, author: string | null, tags: Array<string>, media: Array<Media>, published_at: string | null, ingested_at: string, state: ItemState,
|
||||||
|
/**
|
||||||
|
* Cached ranking score; recomputed by the ranker as weights/signals change.
|
||||||
|
*/
|
||||||
|
score: number, };
|
||||||
6
web/src/api/bindings/CreateApiToken.ts
Normal file
6
web/src/api/bindings/CreateApiToken.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to mint a new API token.
|
||||||
|
*/
|
||||||
|
export type CreateApiToken = { name: string, };
|
||||||
15
web/src/api/bindings/CreatedApiToken.ts
Normal file
15
web/src/api/bindings/CreatedApiToken.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response returned once when a token is created. `secret` is the full bearer token
|
||||||
|
* (`nf_<prefix>_<random>`) and is never persisted in the clear.
|
||||||
|
*/
|
||||||
|
export type CreatedApiToken = {
|
||||||
|
/**
|
||||||
|
* The full secret. Present only in this response.
|
||||||
|
*/
|
||||||
|
secret: string, id: string, user_id: string, name: string,
|
||||||
|
/**
|
||||||
|
* Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token.
|
||||||
|
*/
|
||||||
|
prefix: string, created_at: string, last_used_at: string | null, revoked_at: string | null, };
|
||||||
11
web/src/api/bindings/FeedPage.ts
Normal file
11
web/src/api/bindings/FeedPage.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { ContentItem } from "./ContentItem";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of ranked feed items.
|
||||||
|
*/
|
||||||
|
export type FeedPage = { items: Array<ContentItem>,
|
||||||
|
/**
|
||||||
|
* Cursor to pass back for the next page, or `None` at the end.
|
||||||
|
*/
|
||||||
|
next_cursor: string | null, };
|
||||||
18
web/src/api/bindings/FeedQuery.ts
Normal file
18
web/src/api/bindings/FeedQuery.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for `GET /v1/feed`.
|
||||||
|
*/
|
||||||
|
export type FeedQuery = {
|
||||||
|
/**
|
||||||
|
* Max items to return. Defaults applied server-side.
|
||||||
|
*/
|
||||||
|
limit: number | null,
|
||||||
|
/**
|
||||||
|
* Opaque cursor for pagination (score+id of the last item seen).
|
||||||
|
*/
|
||||||
|
cursor: string | null,
|
||||||
|
/**
|
||||||
|
* When true, include saved items; otherwise only live feed items.
|
||||||
|
*/
|
||||||
|
include_saved: boolean, };
|
||||||
15
web/src/api/bindings/Interest.ts
Normal file
15
web/src/api/bindings/Interest.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A weighted topic of interest belonging to a single user.
|
||||||
|
*/
|
||||||
|
export type Interest = { id: string, user_id: string,
|
||||||
|
/**
|
||||||
|
* Free-text label matched (case-insensitively) against item title/summary/tags.
|
||||||
|
*/
|
||||||
|
label: string,
|
||||||
|
/**
|
||||||
|
* How strongly this interest pulls matching items up the feed, `-1.0..=1.0`.
|
||||||
|
* Negative weights actively bury matching content.
|
||||||
|
*/
|
||||||
|
weight: number, created_at: string, };
|
||||||
6
web/src/api/bindings/ItemState.ts
Normal file
6
web/src/api/bindings/ItemState.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle state of a content item.
|
||||||
|
*/
|
||||||
|
export type ItemState = "candidate" | "feed" | "saved" | "dismissed";
|
||||||
10
web/src/api/bindings/LoginRequest.ts
Normal file
10
web/src/api/bindings/LoginRequest.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login request.
|
||||||
|
*/
|
||||||
|
export type LoginRequest = {
|
||||||
|
/**
|
||||||
|
* Username or email.
|
||||||
|
*/
|
||||||
|
identifier: string, password: string, };
|
||||||
6
web/src/api/bindings/Me.ts
Normal file
6
web/src/api/bindings/Me.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current authenticated principal, returned by `GET /v1/auth/me`.
|
||||||
|
*/
|
||||||
|
export type Me = { id: string, username: string, email: string, };
|
||||||
6
web/src/api/bindings/Media.ts
Normal file
6
web/src/api/bindings/Media.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A media attachment (image/video/audio) associated with an item.
|
||||||
|
*/
|
||||||
|
export type Media = { kind: string, url: string, width: number | null, height: number | null, };
|
||||||
11
web/src/api/bindings/NewSource.ts
Normal file
11
web/src/api/bindings/NewSource.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { SourceKind } from "./SourceKind";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to create a source.
|
||||||
|
*/
|
||||||
|
export type NewSource = { kind: SourceKind, name: string, url: string | null,
|
||||||
|
/**
|
||||||
|
* Defaults to `1.0` when omitted.
|
||||||
|
*/
|
||||||
|
weight: number | null, };
|
||||||
6
web/src/api/bindings/RegisterRequest.ts
Normal file
6
web/src/api/bindings/RegisterRequest.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration request from the sign-up form.
|
||||||
|
*/
|
||||||
|
export type RegisterRequest = { username: string, email: string, password: string, };
|
||||||
7
web/src/api/bindings/Signal.ts
Normal file
7
web/src/api/bindings/Signal.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { SignalAction } from "./SignalAction";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recorded interaction, posted to `POST /v1/feed/signals`.
|
||||||
|
*/
|
||||||
|
export type Signal = { item_id: string, action: SignalAction, };
|
||||||
6
web/src/api/bindings/SignalAction.ts
Normal file
6
web/src/api/bindings/SignalAction.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of interaction the user can have with an item.
|
||||||
|
*/
|
||||||
|
export type SignalAction = "view" | "click" | "save" | "dismiss";
|
||||||
19
web/src/api/bindings/Source.ts
Normal file
19
web/src/api/bindings/Source.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { SourceKind } from "./SourceKind";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user's configured content source.
|
||||||
|
*/
|
||||||
|
export type Source = { id: string, user_id: string, kind: SourceKind, name: string,
|
||||||
|
/**
|
||||||
|
* Poll URL for [`SourceKind::Rss`]; unused/optional for agentic sources.
|
||||||
|
*/
|
||||||
|
url: string | null,
|
||||||
|
/**
|
||||||
|
* Baseline weight applied to every item from this source, `0.0..=1.0`.
|
||||||
|
*/
|
||||||
|
weight: number, enabled: boolean,
|
||||||
|
/**
|
||||||
|
* When the worker last polled this source (rss only).
|
||||||
|
*/
|
||||||
|
last_polled_at: string | null, created_at: string, };
|
||||||
6
web/src/api/bindings/SourceKind.ts
Normal file
6
web/src/api/bindings/SourceKind.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a source produces candidates.
|
||||||
|
*/
|
||||||
|
export type SourceKind = "rss" | "agentic";
|
||||||
6
web/src/api/bindings/UpsertInterest.ts
Normal file
6
web/src/api/bindings/UpsertInterest.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update an interest.
|
||||||
|
*/
|
||||||
|
export type UpsertInterest = { label: string, weight: number, };
|
||||||
7
web/src/api/bindings/User.ts
Normal file
7
web/src/api/bindings/User.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A registered user. The password hash never leaves `newsfeed-data`/`newsfeed-core`;
|
||||||
|
* it is deliberately absent from this DTO so it can't be serialised to a client.
|
||||||
|
*/
|
||||||
|
export type User = { id: string, username: string, email: string, created_at: string, };
|
||||||
84
web/src/api/client.ts
Normal file
84
web/src/api/client.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Thin typed client over the newsfeed API. Types are the generated bindings from the
|
||||||
|
// Rust `newsfeed-entities` crate (see src/api/bindings/, regenerate with `pnpm gen:types`).
|
||||||
|
// Session auth rides on an HttpOnly cookie, so every request sends credentials.
|
||||||
|
|
||||||
|
import type { Me } from './bindings/Me';
|
||||||
|
import type { RegisterRequest } from './bindings/RegisterRequest';
|
||||||
|
import type { LoginRequest } from './bindings/LoginRequest';
|
||||||
|
import type { ApiTokenInfo } from './bindings/ApiTokenInfo';
|
||||||
|
import type { CreateApiToken } from './bindings/CreateApiToken';
|
||||||
|
import type { CreatedApiToken } from './bindings/CreatedApiToken';
|
||||||
|
import type { Source } from './bindings/Source';
|
||||||
|
import type { NewSource } from './bindings/NewSource';
|
||||||
|
import type { Interest } from './bindings/Interest';
|
||||||
|
import type { UpsertInterest } from './bindings/UpsertInterest';
|
||||||
|
import type { FeedPage } from './bindings/FeedPage';
|
||||||
|
import type { Signal } from './bindings/Signal';
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
|
|
||||||
|
/** Error carrying the HTTP status so callers (e.g. 401 → login) can branch on it. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && typeof data.error === 'string') message = data.error;
|
||||||
|
} catch {
|
||||||
|
/* non-JSON error body */
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// auth
|
||||||
|
me: () => request<Me>('GET', '/v1/auth/me'),
|
||||||
|
register: (req: RegisterRequest) => request<Me>('POST', '/v1/auth/register', req),
|
||||||
|
login: (req: LoginRequest) => request<Me>('POST', '/v1/auth/login', req),
|
||||||
|
logout: () => request<void>('POST', '/v1/auth/logout'),
|
||||||
|
|
||||||
|
// feed
|
||||||
|
feed: (opts: { cursor?: string; includeSaved?: boolean } = {}) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (opts.cursor) q.set('cursor', opts.cursor);
|
||||||
|
if (opts.includeSaved) q.set('include_saved', 'true');
|
||||||
|
const qs = q.toString();
|
||||||
|
return request<FeedPage>('GET', `/v1/feed${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
signal: (sig: Signal) => request<void>('POST', '/v1/feed/signals', sig),
|
||||||
|
|
||||||
|
// sources
|
||||||
|
listSources: () => request<Source[]>('GET', '/v1/sources'),
|
||||||
|
createSource: (s: NewSource) => request<Source>('POST', '/v1/sources', s),
|
||||||
|
deleteSource: (id: string) => request<void>('DELETE', `/v1/sources/${id}`),
|
||||||
|
|
||||||
|
// interests
|
||||||
|
listInterests: () => request<Interest[]>('GET', '/v1/interests'),
|
||||||
|
upsertInterest: (i: UpsertInterest) => request<Interest>('PUT', '/v1/interests', i),
|
||||||
|
deleteInterest: (id: string) => request<void>('DELETE', `/v1/interests/${id}`),
|
||||||
|
|
||||||
|
// api tokens
|
||||||
|
listTokens: () => request<ApiTokenInfo[]>('GET', '/v1/tokens'),
|
||||||
|
createToken: (t: CreateApiToken) => request<CreatedApiToken>('POST', '/v1/tokens', t),
|
||||||
|
revokeToken: (id: string) => request<void>('DELETE', `/v1/tokens/${id}`),
|
||||||
|
};
|
||||||
65
web/src/components/FeedItem.tsx
Normal file
65
web/src/components/FeedItem.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { api } from '../api/client';
|
||||||
|
import type { ContentItem } from '../api/bindings/ContentItem';
|
||||||
|
import type { SignalAction } from '../api/bindings/SignalAction';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ContentItem;
|
||||||
|
onSignal: (action: SignalAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedItem({ item, onSignal }: Props) {
|
||||||
|
const image = item.media.find((m) => m.kind.startsWith('image'))?.url;
|
||||||
|
const published = item.published_at ? new Date(item.published_at).toLocaleDateString() : null;
|
||||||
|
|
||||||
|
const openAndClick = () => {
|
||||||
|
onSignal('click');
|
||||||
|
// Fire-and-forget signal; navigation proceeds regardless.
|
||||||
|
void api.signal({ item_id: item.id, action: 'click' }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="feed-item card">
|
||||||
|
{image && (
|
||||||
|
<a href={item.url ?? '#'} target="_blank" rel="noreferrer" onClick={openAndClick} className="feed-thumb">
|
||||||
|
<img src={image} alt="" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="feed-body">
|
||||||
|
<div className="feed-meta">
|
||||||
|
<span className="score" title="Your ranking score">
|
||||||
|
{item.score.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
{item.author && <span className="muted">{item.author}</span>}
|
||||||
|
{published && <span className="muted">{published}</span>}
|
||||||
|
</div>
|
||||||
|
<h2 className="feed-title">
|
||||||
|
{item.url ? (
|
||||||
|
<a href={item.url} target="_blank" rel="noreferrer" onClick={openAndClick}>
|
||||||
|
{item.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
item.title
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{item.summary && <p className="feed-summary">{item.summary}</p>}
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="tags">
|
||||||
|
{item.tags.map((t) => (
|
||||||
|
<span key={t} className="tag">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="feed-actions">
|
||||||
|
<button onClick={() => onSignal('save')} disabled={item.state === 'saved'}>
|
||||||
|
{item.state === 'saved' ? '★ Saved' : '☆ Save'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onSignal('dismiss')} className="ghost">
|
||||||
|
✕ Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
web/src/components/Nav.tsx
Normal file
38
web/src/components/Nav.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Responsive navigation: a bottom tab bar on mobile, a left rail on desktop (driven
|
||||||
|
// entirely by CSS media queries against .nav).
|
||||||
|
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ to: '/', label: 'Feed', icon: '📰', end: true },
|
||||||
|
{ to: '/sources', label: 'Sources', icon: '🌐', end: false },
|
||||||
|
{ to: '/settings', label: 'Settings', icon: '⚙️', end: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Nav() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
return (
|
||||||
|
<nav className="nav">
|
||||||
|
<div className="nav-brand">newsfeed</div>
|
||||||
|
<ul className="nav-links">
|
||||||
|
{links.map((l) => (
|
||||||
|
<li key={l.to}>
|
||||||
|
<NavLink to={l.to} end={l.end} className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||||
|
<span className="nav-icon" aria-hidden>
|
||||||
|
{l.icon}
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">{l.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button className="nav-logout" onClick={logout} title={`Log out ${user?.username ?? ''}`}>
|
||||||
|
<span className="nav-icon" aria-hidden>
|
||||||
|
⎋
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Log out</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
438
web/src/index.css
Normal file
438
web/src/index.css
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b0d10;
|
||||||
|
--surface: #14181d;
|
||||||
|
--surface-2: #1b2027;
|
||||||
|
--border: #262d36;
|
||||||
|
--text: #e6e9ee;
|
||||||
|
--muted: #8a93a0;
|
||||||
|
--accent: #4c8dff;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--pos: #43c47a;
|
||||||
|
--neg: #ff6b6b;
|
||||||
|
--radius: 14px;
|
||||||
|
--nav-h: 60px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f5f6f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f0f2f5;
|
||||||
|
--border: #e2e6eb;
|
||||||
|
--text: #10151b;
|
||||||
|
--muted: #62707f;
|
||||||
|
--accent: #2f6fe0;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
button.link {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input[type='range'] {
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
input[type='checkbox'] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- layout ---------- */
|
||||||
|
.app {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1rem calc(var(--nav-h) + env(safe-area-inset-bottom) + 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--neg);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pos {
|
||||||
|
color: var(--pos);
|
||||||
|
}
|
||||||
|
.neg {
|
||||||
|
color: var(--neg);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-head h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
.toggle {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- nav ---------- */
|
||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: calc(var(--nav-h) + env(safe-area-inset-bottom));
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.nav-links li {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.nav a,
|
||||||
|
.nav-logout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.nav a.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 860px) {
|
||||||
|
.app {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 220px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
border-top: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding: 0 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 0;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.nav a,
|
||||||
|
.nav-logout {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.nav a.active {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.nav-logout {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- auth ---------- */
|
||||||
|
.auth-card {
|
||||||
|
width: min(380px, 92vw);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- feed ---------- */
|
||||||
|
.feed-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.feed-item {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.feed-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 240px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.feed-body {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.feed-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.score {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.feed-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.feed-summary {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.feed-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.more {
|
||||||
|
display: block;
|
||||||
|
margin: 1.25rem auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- lists / pills / tags ---------- */
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.list-row-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.pill-rss {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.pill-agentic {
|
||||||
|
background: color-mix(in srgb, var(--pos) 20%, transparent);
|
||||||
|
color: var(--pos);
|
||||||
|
}
|
||||||
|
.pill-revoked {
|
||||||
|
background: color-mix(in srgb, var(--neg) 20%, transparent);
|
||||||
|
color: var(--neg);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- token secret ---------- */
|
||||||
|
.secret {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px dashed var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
50
web/src/lib/auth.tsx
Normal file
50
web/src/lib/auth.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Auth state derived from the server. `me` is the single source of truth: a successful
|
||||||
|
// query means an authenticated session, a 401 means logged out.
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api, ApiError } from '../api/client';
|
||||||
|
import type { Me } from '../api/bindings/Me';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: Me | null;
|
||||||
|
loading: boolean;
|
||||||
|
refresh: () => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const meQuery = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: api.me,
|
||||||
|
// A 401 is a normal "logged out" answer, not a retryable error.
|
||||||
|
retry: (_count, err) => !(err instanceof ApiError && err.status === 401),
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoutMutation = useMutation({
|
||||||
|
mutationFn: api.logout,
|
||||||
|
onSuccess: () => qc.setQueryData(['me'], null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unauthorized = meQuery.error instanceof ApiError && meQuery.error.status === 401;
|
||||||
|
|
||||||
|
const value: AuthState = {
|
||||||
|
user: unauthorized ? null : (meQuery.data ?? null),
|
||||||
|
loading: meQuery.isLoading,
|
||||||
|
refresh: () => qc.invalidateQueries({ queryKey: ['me'] }),
|
||||||
|
logout: () => logoutMutation.mutate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
21
web/src/main.tsx
Normal file
21
web/src/main.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { App } from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
58
web/src/routes/Feed.tsx
Normal file
58
web/src/routes/Feed.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { FeedItem } from '../components/FeedItem';
|
||||||
|
import type { SignalAction } from '../api/bindings/SignalAction';
|
||||||
|
|
||||||
|
export function Feed() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [includeSaved, setIncludeSaved] = useState(false);
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ['feed', { includeSaved }],
|
||||||
|
queryFn: ({ pageParam }) => api.feed({ cursor: pageParam, includeSaved }),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signal = useMutation({
|
||||||
|
mutationFn: ({ id, action }: { id: string; action: SignalAction }) =>
|
||||||
|
api.signal({ item_id: id, action }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['feed'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page-head">
|
||||||
|
<h1>Feed</h1>
|
||||||
|
<label className="toggle">
|
||||||
|
<input type="checkbox" checked={includeSaved} onChange={(e) => setIncludeSaved(e.target.checked)} />
|
||||||
|
Show saved
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{query.isLoading && <p className="muted">Loading your feed…</p>}
|
||||||
|
{query.isError && <p className="error">Couldn’t load the feed.</p>}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<p className="muted empty">
|
||||||
|
Nothing here yet. Add a source, or push candidates to the ingest endpoint with an API token.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="feed-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<FeedItem key={item.id} item={item} onSignal={(action) => signal.mutate({ id: item.id, action })} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{query.hasNextPage && (
|
||||||
|
<button className="more" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
|
||||||
|
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
web/src/routes/Login.tsx
Normal file
95
web/src/routes/Login.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api, ApiError } from '../api/client';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const { refresh } = useAuth();
|
||||||
|
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||||
|
const [identifier, setIdentifier] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (mode === 'register') {
|
||||||
|
await api.register({ username, email, password });
|
||||||
|
}
|
||||||
|
await api.login({
|
||||||
|
identifier: mode === 'register' ? username : identifier,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const error =
|
||||||
|
mutation.error instanceof ApiError ? mutation.error.message : mutation.error ? 'Something went wrong' : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="center">
|
||||||
|
<form className="card auth-card" onSubmit={onSubmit}>
|
||||||
|
<h1 className="brand">newsfeed</h1>
|
||||||
|
<p className="muted">Your feed. Your weights. Your rules.</p>
|
||||||
|
|
||||||
|
{mode === 'register' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'login' && (
|
||||||
|
<label>
|
||||||
|
Username or email
|
||||||
|
<input value={identifier} onChange={(e) => setIdentifier(e.target.value)} autoComplete="username" required />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete={mode === 'register' ? 'new-password' : 'current-password'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" className="primary" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? '…' : mode === 'register' ? 'Create account' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link"
|
||||||
|
onClick={() => setMode(mode === 'login' ? 'register' : 'login')}
|
||||||
|
>
|
||||||
|
{mode === 'login' ? 'Need an account? Register' : 'Have an account? Log in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
web/src/routes/Settings.tsx
Normal file
172
web/src/routes/Settings.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { CreatedApiToken } from '../api/bindings/CreatedApiToken';
|
||||||
|
|
||||||
|
function Interests() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const interests = useQuery({ queryKey: ['interests'], queryFn: api.listInterests });
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [weight, setWeight] = useState(0.5);
|
||||||
|
|
||||||
|
const upsert = useMutation({
|
||||||
|
mutationFn: (body: { label: string; weight: number }) => api.upsertInterest(body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['interests'] }),
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteInterest(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['interests'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAdd = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!label.trim()) return;
|
||||||
|
upsert.mutate({ label: label.trim(), weight });
|
||||||
|
setLabel('');
|
||||||
|
setWeight(0.5);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Interests</h2>
|
||||||
|
<p className="muted small">
|
||||||
|
Positive weights pull matching stories up; negative weights bury them. These weights — and only these — decide
|
||||||
|
your ranking.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="row" onSubmit={onAdd}>
|
||||||
|
<input
|
||||||
|
className="grow"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="topic, e.g. rust"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={-1}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={weight}
|
||||||
|
onChange={(e) => setWeight(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="mono">{weight.toFixed(2)}</span>
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul className="list">
|
||||||
|
{interests.data?.map((i) => (
|
||||||
|
<li key={i.id} className="list-row">
|
||||||
|
<strong>{i.label}</strong>
|
||||||
|
<div className="list-row-end">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={-1}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
defaultValue={i.weight}
|
||||||
|
onMouseUp={(e) => upsert.mutate({ label: i.label, weight: Number((e.target as HTMLInputElement).value) })}
|
||||||
|
onTouchEnd={(e) =>
|
||||||
|
upsert.mutate({ label: i.label, weight: Number((e.target as HTMLInputElement).value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={`mono ${i.weight < 0 ? 'neg' : 'pos'}`}>{i.weight.toFixed(2)}</span>
|
||||||
|
<button className="ghost" onClick={() => remove.mutate(i.id)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{interests.data?.length === 0 && <p className="muted empty">No interests yet.</p>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tokens() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const tokens = useQuery({ queryKey: ['tokens'], queryFn: api.listTokens });
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [fresh, setFresh] = useState<CreatedApiToken | null>(null);
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => api.createToken({ name: name.trim() }),
|
||||||
|
onSuccess: (t) => {
|
||||||
|
setFresh(t);
|
||||||
|
setName('');
|
||||||
|
qc.invalidateQueries({ queryKey: ['tokens'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const revoke = useMutation({
|
||||||
|
mutationFn: (id: string) => api.revokeToken(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tokens'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>API tokens</h2>
|
||||||
|
<p className="muted small">
|
||||||
|
Agentic and algorithmic producers POST candidates to <code>/v1/ingest/candidates</code> with a bearer token.
|
||||||
|
Each token is scoped to your feed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="row"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name.trim()) create.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input className="grow" value={name} onChange={(e) => setName(e.target.value)} placeholder="token name" />
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Mint token
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{fresh && (
|
||||||
|
<div className="secret">
|
||||||
|
<p className="small">
|
||||||
|
Copy this now — it won’t be shown again:
|
||||||
|
</p>
|
||||||
|
<code className="token">{fresh.secret}</code>
|
||||||
|
<button className="ghost" onClick={() => setFresh(null)}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="list">
|
||||||
|
{tokens.data?.map((t) => (
|
||||||
|
<li key={t.id} className="list-row">
|
||||||
|
<div>
|
||||||
|
<strong>{t.name}</strong> <span className="mono muted">{t.prefix}…</span>
|
||||||
|
{t.revoked_at && <span className="pill pill-revoked">revoked</span>}
|
||||||
|
</div>
|
||||||
|
{!t.revoked_at && (
|
||||||
|
<button className="ghost" onClick={() => revoke.mutate(t.id)}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{tokens.data?.length === 0 && <p className="muted empty">No tokens yet.</p>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page-head">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</header>
|
||||||
|
<div className="stack">
|
||||||
|
<Interests />
|
||||||
|
<Tokens />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user