Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pulldown-cmark = { version = "0.13", default-features = false, features = ["html
qrcodegen = "1.8.0"
rand = "0.8"
rand_core = "0.6"
sha2 = "0.10"
ratatui = "0.30"
ringbuf = "0.4.8"
rstest = "0.26.1"
Expand Down
1 change: 1 addition & 0 deletions late-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ rustfft.workspace = true
symphonia = { workspace = true, features = ["mp3"] }
image.workspace = true
axum.workspace = true
sha2.workspace = true

# Telemetry (optional, heavy deps — gated behind "otel" feature)
opentelemetry = { workspace = true, optional = true }
Expand Down
9 changes: 9 additions & 0 deletions late-core/migrations/045_create_native_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE native_tokens (
token TEXT NOT NULL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX native_tokens_user_id_idx ON native_tokens (user_id);
CREATE INDEX native_tokens_expires_at_idx ON native_tokens (expires_at);
8 changes: 8 additions & 0 deletions late-core/migrations/046_native_token_metadata.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Clear pre-hash tokens; stored values are raw tokens and cannot authenticate
-- under the new SHA-256 scheme.
TRUNCATE native_tokens;

ALTER TABLE native_tokens
ADD COLUMN last_used_at TIMESTAMPTZ,
ADD COLUMN user_agent TEXT,
ADD COLUMN created_ip TEXT;
1 change: 1 addition & 0 deletions late-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod leaderboard;
pub mod mention_feed_read;
pub mod minesweeper;
pub mod moderation_audit_log;
pub mod native_token;
pub mod nonogram;
pub mod notification;
pub mod profile;
Expand Down
99 changes: 99 additions & 0 deletions late-core/src/models/native_token.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
use tokio_postgres::{Client, Row};
use uuid::Uuid;

pub struct NativeToken {
/// SHA-256 hex hash of the raw bearer token. Raw token is never stored.
pub token_hash: String,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub user_agent: Option<String>,
pub created_ip: Option<String>,
}

impl From<Row> for NativeToken {
fn from(row: Row) -> Self {
Self {
token_hash: row.get("token"),
user_id: row.get("user_id"),
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
last_used_at: row.get("last_used_at"),
user_agent: row.get("user_agent"),
created_ip: row.get("created_ip"),
}
}
}

fn hash_token(raw: &str) -> String {
let hash = Sha256::digest(raw.as_bytes());
hash.iter().fold(String::with_capacity(64), |mut s, b| {
write!(s, "{b:02x}").unwrap();
s
})
}

impl NativeToken {
pub async fn create(
client: &Client,
raw_token: &str,
user_id: Uuid,
expires_at: DateTime<Utc>,
user_agent: Option<&str>,
created_ip: Option<&str>,
) -> Result<Self> {
let token_hash = hash_token(raw_token);
let row = client
.query_one(
"INSERT INTO native_tokens (token, user_id, expires_at, user_agent, created_ip)
VALUES ($1, $2, $3, $4, $5)
RETURNING *",
&[&token_hash, &user_id, &expires_at, &user_agent, &created_ip],
)
.await?;
Ok(Self::from(row))
}

/// Returns `(user_id, username)` if the token exists and has not expired.
/// Also updates `last_used_at` atomically.
pub async fn find_user_by_token(
client: &Client,
raw_token: &str,
) -> Result<Option<(Uuid, String)>> {
let token_hash = hash_token(raw_token);
let row = client
.query_opt(
"WITH updated AS (
UPDATE native_tokens SET last_used_at = NOW()
WHERE token = $1 AND expires_at > NOW()
RETURNING user_id
)
SELECT u.id, u.username
FROM updated
JOIN users u ON u.id = updated.user_id",
&[&token_hash],
)
.await?;
Ok(row.map(|r| (r.get("id"), r.get("username"))))
}

pub async fn delete(client: &Client, raw_token: &str) -> Result<()> {
let token_hash = hash_token(raw_token);
client
.execute("DELETE FROM native_tokens WHERE token = $1", &[&token_hash])
.await?;
Ok(())
}

pub async fn purge_expired(client: &Client) -> Result<u64> {
let n = client
.execute("DELETE FROM native_tokens WHERE expires_at <= NOW()", &[])
.await?;
Ok(n)
}
}
1 change: 1 addition & 0 deletions late-ssh/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ futures-util = { workspace = true }
tikv-jemallocator = "0.6"

# Database
deadpool-postgres = { workspace = true }
tokio-postgres = { workspace = true }
crossterm.workspace = true
dartboard-core.workspace = true
Expand Down
6 changes: 4 additions & 2 deletions late-ssh/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use axum::{
Json, Router,
extract::{
ConnectInfo, Query, State as AxumState, WebSocketUpgrade,
ConnectInfo, DefaultBodyLimit, Query, State as AxumState, WebSocketUpgrade,
ws::{Message, WebSocket},
},
http::StatusCode,
Expand Down Expand Up @@ -91,8 +91,10 @@ pub async fn run_api_server_with_listener(
.route("/api/ws/pair", get(ws_handler))
.route("/api/ws/tunnel", get(crate::web_tunnel::ws_handler))
.route("/api/ws/chat", get(crate::web::ws_chat_handler))
.merge(crate::native_api::router())
.layer(cors)
.layer(middleware::from_fn(http_telemetry_middleware))
.layer(DefaultBodyLimit::max(64 * 1024))
.with_state(state);

let shutdown = shutdown.unwrap_or_default();
Expand Down Expand Up @@ -333,7 +335,7 @@ fn token_hint(token: &str) -> String {
format!("{prefix}..({})", token.len())
}

fn effective_client_ip(headers: &HeaderMap, peer_addr: SocketAddr, state: &State) -> IpAddr {
pub(crate) fn effective_client_ip(headers: &HeaderMap, peer_addr: SocketAddr, state: &State) -> IpAddr {
if is_trusted_proxy_peer(peer_addr.ip(), &state.config.ssh_proxy_trusted_cidrs)
&& let Some(ip) = forwarded_for_ip(headers)
{
Expand Down
1 change: 1 addition & 0 deletions late-ssh/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod config;
pub mod dartboard;
pub mod metrics;
pub mod moderation;
pub mod native_api;
pub mod session;
pub mod session_bootstrap;
pub mod ssh;
Expand Down
26 changes: 25 additions & 1 deletion late-ssh/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

use anyhow::Context;
use late_core::{
api_types::NowPlaying, db::Db, icecast, models::chat_room::ChatRoom, rate_limit::IpRateLimiter,
api_types::NowPlaying,
db::Db,
icecast,
models::{chat_room::ChatRoom, native_token::NativeToken},
rate_limit::IpRateLimiter,
shutdown::CancellationToken,
};
use late_ssh::{
Expand Down Expand Up @@ -271,6 +275,11 @@ async fn main() -> anyhow::Result<()> {
web_chat_registry,
ssh_attempt_limiter,
ws_pair_limiter,
native_challenges: late_ssh::state::NativeChallengeStore::new(),
native_ws_tickets: late_ssh::state::NativeWsTicketStore::new(),
native_challenge_limiter: IpRateLimiter::new(20, 60),
native_token_limiter: IpRateLimiter::new(10, 60),
native_ws_limiter: IpRateLimiter::new(10, 60),
is_draining: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};

Expand All @@ -292,6 +301,21 @@ async fn main() -> anyhow::Result<()> {
Ok(())
});

let purge_db = state.db.clone();
tasks.spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60 * 60));
loop {
interval.tick().await;
if let Ok(client) = purge_db.get().await {
match NativeToken::purge_expired(&client).await {
Ok(n) if n > 0 => tracing::info!(n, "purged expired native tokens"),
Err(e) => tracing::warn!("native token purge failed: {e}"),
_ => {}
}
}
}
});

let ssh_shutdown = accept_shutdown.clone();
let ssh_state = state.clone();
let mut ssh_task = tokio::spawn(async move {
Expand Down
45 changes: 45 additions & 0 deletions late-ssh/src/native_api/artboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use axum::{
Json, Router,
extract::State as AxumState,
http::StatusCode,
routing::{get, post},
};
use dartboard_core::CanvasOp;
use rand_core::{OsRng, RngCore};
use serde::Serialize;

use crate::state::State;

use super::{ApiError, NativeAuthUser};

pub fn router() -> Router<State> {
Router::new()
.route("/api/native/artboard", get(get_artboard))
.route("/api/native/artboard/ops", post(post_artboard_op))
}

#[derive(Serialize)]
struct ArtboardResponse {
canvas: serde_json::Value,
}

async fn get_artboard(
_auth: NativeAuthUser,
AxumState(state): AxumState<State>,
) -> Result<Json<ArtboardResponse>, ApiError> {
let canvas = state.dartboard_server.canvas_snapshot();
let canvas_json =
serde_json::to_value(&canvas).map_err(|_| ApiError::Db)?;
Ok(Json(ArtboardResponse { canvas: canvas_json }))
}

async fn post_artboard_op(
auth: NativeAuthUser,
AxumState(state): AxumState<State>,
Json(op): Json<CanvasOp>,
) -> Result<StatusCode, ApiError> {
let user_id_u64 = auth.user_id.as_u64_pair().1;
let client_op_id: u64 = OsRng.next_u64();
state.dartboard_server.submit_op_for(user_id_u64, client_op_id, op);
Ok(StatusCode::ACCEPTED)
}
53 changes: 53 additions & 0 deletions late-ssh/src/native_api/articles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use axum::{
Json, Router,
extract::{Query, State as AxumState},
routing::get,
};
use late_core::models::article::Article;
use serde::{Deserialize, Serialize};

use crate::state::State;

use super::{ApiError, NativeAuthUser};

pub fn router() -> Router<State> {
Router::new().route("/api/native/articles", get(get_articles))
}

#[derive(Deserialize)]
struct ArticlesParams {
limit: Option<i64>,
}

#[derive(Serialize)]
struct ArticleItem {
id: String,
url: String,
title: String,
summary: String,
ascii_art: String,
created: String,
}

async fn get_articles(
_auth: NativeAuthUser,
Query(params): Query<ArticlesParams>,
AxumState(state): AxumState<State>,
) -> Result<Json<Vec<ArticleItem>>, ApiError> {
let limit = params.limit.unwrap_or(20).clamp(1, 100);
let client = state.db.get().await.map_err(|_| ApiError::Db)?;
let articles = Article::list_recent(&client, limit).await.map_err(|_| ApiError::Db)?;
Ok(Json(
articles
.into_iter()
.map(|a| ArticleItem {
id: a.id.to_string(),
url: a.url,
title: a.title,
summary: a.summary,
ascii_art: a.ascii_art,
created: a.created.to_rfc3339(),
})
.collect(),
))
}
Loading