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