feat: scaffold newsfeed — user-controlled news feed
Some checks failed
deploy / build (push) Failing after 7s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

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:
2026-07-08 11:36:57 +03:00
commit 5ce52fff4d
104 changed files with 10963 additions and 0 deletions

213
script/infra-setup.sh Executable file
View 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 "$@"