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:
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user