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

View 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

View 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));
}
}

View 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>;

View 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
}

View 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};

View 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
{
}

View 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);
}
}

View 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, &params);
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,
})
}