implement endpoint to list remote users

This commit is contained in:
DSeeLP 2025-04-17 10:19:18 +02:00
parent 9b1368dd9c
commit ce13d854c1
6 changed files with 148 additions and 37 deletions

View File

@ -425,6 +425,29 @@ paths:
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
default: default:
$ref: '#/components/responses/Default' $ref: '#/components/responses/Default'
/api/users/interop:
get:
operationId: users-list-all-interop
summary: List all remote users
tags:
- Users
security:
- bearer: []
responses:
200:
description: Ok
content:
application/json:
schema:
type: array
items:
type: string
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
default:
$ref: '#/components/responses/Default'
/api/chats: /api/chats:
get: get:
operationId: list-chats operationId: list-chats

View File

@ -98,13 +98,13 @@ async fn interop_pay(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
#[derive(Serialize)] #[derive(Deserialize, Serialize)]
struct ListUsers { pub struct InteropListUsers {
users: Vec<String>, pub users: Vec<String>,
expires_in: u32, pub expires_in: u32,
} }
async fn list_users(EState(state): State, _: InteropAuth) -> Result<Json<ListUsers>, Error> { async fn list_users(EState(state): State, _: InteropAuth) -> Result<Json<InteropListUsers>, Error> {
let conn = state.conn().await?; let conn = state.conn().await?;
let stmt = conn.prepare_cached( let stmt = conn.prepare_cached(
"select case when a.name = 'personal' then u.name else concat(u.name, ':', a.name) end as account from accounts a join users u ON a.\"user\" = u.id;", "select case when a.name = 'personal' then u.name else concat(u.name, ':', a.name) end as account from accounts a join users u ON a.\"user\" = u.id;",
@ -115,7 +115,7 @@ async fn list_users(EState(state): State, _: InteropAuth) -> Result<Json<ListUse
.into_iter() .into_iter()
.map(|row| row.get(0)) .map(|row| row.get(0))
.collect(); .collect();
Ok(Json(ListUsers { Ok(Json(InteropListUsers {
users: accounts, users: accounts,
expires_in: 42, expires_in: 42,
})) }))

View File

@ -11,6 +11,8 @@ use axum::{
use bank_core::{ApiError, make_schemas, pagination::RequestPagination}; use bank_core::{ApiError, make_schemas, pagination::RequestPagination};
use jsonwebtoken::{DecodingKey, EncodingKey}; use jsonwebtoken::{DecodingKey, EncodingKey};
use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde::{Deserialize, Serialize, de::DeserializeOwned};
use tokio::{sync::Mutex, time::Instant};
use tracing::error;
use tracing_error::SpanTrace; use tracing_error::SpanTrace;
pub use axum::extract::State as EState; pub use axum::extract::State as EState;
@ -28,6 +30,7 @@ mod transactions;
mod user; mod user;
pub use interop::router as interop_router; pub use interop::router as interop_router;
use transactions::TARGET_NOT_FOUND;
use crate::config::Meta; use crate::config::Meta;
@ -104,6 +107,7 @@ pub enum InnerError {
} }
impl Error { impl Error {
#[track_caller]
pub fn new(inner: InnerError) -> Self { pub fn new(inner: InnerError) -> Self {
let trace = if cfg!(debug_assertions) || inner.internal() { let trace = if cfg!(debug_assertions) || inner.internal() {
Some(SpanTrace::capture()) Some(SpanTrace::capture())
@ -121,34 +125,56 @@ impl Error {
} }
impl<T: Into<InnerError>> From<T> for Error { impl<T: Into<InnerError>> From<T> for Error {
#[track_caller]
fn from(value: T) -> Self { fn from(value: T) -> Self {
Error::new(value.into()) Error::new(value.into())
} }
} }
impl From<deadpool_postgres::PoolError> for InnerError { impl From<deadpool_postgres::PoolError> for InnerError {
#[track_caller]
fn from(value: deadpool_postgres::PoolError) -> Self { fn from(value: deadpool_postgres::PoolError) -> Self {
Self::Pool(value) Self::Pool(value)
} }
} }
impl From<tokio_postgres::Error> for InnerError { impl From<tokio_postgres::Error> for InnerError {
#[track_caller]
fn from(value: tokio_postgres::Error) -> Self { fn from(value: tokio_postgres::Error) -> Self {
Self::Postgres(value) Self::Postgres(value)
} }
} }
impl From<password_auth::ParseError> for InnerError { impl From<password_auth::ParseError> for InnerError {
#[track_caller]
fn from(value: password_auth::ParseError) -> Self { fn from(value: password_auth::ParseError) -> Self {
Self::PHCParse(value) Self::PHCParse(value)
} }
} }
impl From<ApiError<'static>> for InnerError { impl From<ApiError<'static>> for InnerError {
#[track_caller]
fn from(value: ApiError<'static>) -> Self { fn from(value: ApiError<'static>) -> Self {
InnerError::Plain(value) InnerError::Plain(value)
} }
} }
impl From<InteropError> for InnerError {
#[track_caller]
fn from(value: InteropError) -> Self {
match value {
InteropError::TransactionTargetNotFound => TARGET_NOT_FOUND.into(),
InteropError::Http(err) => {
error!("{err}");
ApiError::INTERNAL_SERVER_ERROR.into()
}
InteropError::Other(response) => {
error!("{response:?}");
ApiError::INTERNAL_SERVER_ERROR.into()
}
}
}
}
impl InnerError { impl InnerError {
pub const fn internal(&self) -> bool { pub const fn internal(&self) -> bool {
match self { match self {
@ -280,8 +306,20 @@ pub struct InteropState {
pub secret: String, pub secret: String,
pub client: reqwest::Client, pub client: reqwest::Client,
pub url: url::Url, pub url: url::Url,
pub user_url: url::Url,
pub pay_url: url::Url, pub pay_url: url::Url,
pub prefix: String, pub suffix: String,
pub users: Mutex<Option<(InteropUsers, Instant)>>,
}
pub enum InteropError {
TransactionTargetNotFound,
Http(reqwest::Error),
Other(reqwest::Response),
}
pub struct InteropUsers {
names: Vec<String>,
} }
impl AppState { impl AppState {

View File

@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::model::{Accounts, Transactions, Users}; use crate::model::{Accounts, Transactions, Users};
use super::{ use super::{
AppState, EState, Error, InteropState, Json, State, AppState, EState, Error, InteropError, InteropState, Json, State,
auth::Auth, auth::Auth,
socket::{SocketEvent, SocketMessage}, socket::{SocketEvent, SocketMessage},
}; };
@ -296,7 +296,7 @@ impl MakePayment {
} }
} }
const TARGET_NOT_FOUND: ApiError<'static> = ApiError::const_new( pub const TARGET_NOT_FOUND: ApiError<'static> = ApiError::const_new(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"transaction.target.not_found", "transaction.target.not_found",
"Not Found", "Not Found",
@ -334,21 +334,7 @@ pub async fn make_payment(
if amount % 100 != 0 { if amount % 100 != 0 {
todo!() todo!()
} }
if let Err(err) = send_interop_payment(&interop, &from.name, &user, (amount / 100) as u32).await?;
send_interop_payment(&interop, &from.name, &user, (amount / 100) as u32).await
{
return Err(match err {
InteropError::NotFound => TARGET_NOT_FOUND.into(),
InteropError::Http(err) => {
error!("{err}");
ApiError::INTERNAL_SERVER_ERROR.into()
}
InteropError::Other(response) => {
error!("{response:?}");
ApiError::INTERNAL_SERVER_ERROR.into()
}
});
}
transaction transaction
} }
AccountTarget::Selector(selector) => { AccountTarget::Selector(selector) => {
@ -498,12 +484,6 @@ impl<'a, T: GenericClient> TransactionBuilder<'a, T> {
} }
} }
enum InteropError {
NotFound,
Http(reqwest::Error),
Other(reqwest::Response),
}
async fn send_interop_payment( async fn send_interop_payment(
interop: &InteropState, interop: &InteropState,
from: &str, from: &str,
@ -513,6 +493,7 @@ async fn send_interop_payment(
let response = interop let response = interop
.client .client
.post(interop.pay_url.clone()) .post(interop.pay_url.clone())
.bearer_auth(&interop.secret)
.json(&serde_json::json!({ .json(&serde_json::json!({
"from": from, "from": from,
"to": to, "to": to,
@ -525,7 +506,7 @@ async fn send_interop_payment(
return Ok(()); return Ok(());
} }
if response.status() == StatusCode::NOT_FOUND { if response.status() == StatusCode::NOT_FOUND {
return Err(InteropError::NotFound); return Err(InteropError::TransactionTargetNotFound);
} }
Err(InteropError::Other(response)) Err(InteropError::Other(response))
} }

View File

@ -1,4 +1,4 @@
use std::sync::Arc; use std::{sync::Arc, time::Duration};
use axum::{ use axum::{
Router, Router,
@ -11,18 +11,24 @@ use bank_core::{
transaction::{FullTransaction, TransactionQuery}, transaction::{FullTransaction, TransactionQuery},
user::{User, UserAccounts, UserBalance}, user::{User, UserAccounts, UserBalance},
}; };
use concread::cowcell::asynch::CowCell;
use futures_util::lock::Mutex;
use garde::Validate; use garde::Validate;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::instrument; use serde_json::json;
use tokio::time::Instant;
use tracing::{error, instrument};
use url::Url;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
api::ApiError, api::{ApiError, InteropUsers, interop::InteropListUsers},
model::{Accounts, Transactions, Users}, model::{Accounts, Transactions, Users},
}; };
use super::{ use super::{
AppState, EState, Error, Json, PaginationQuery, Query, State, AppState, EState, Error, InteropError, InteropState, Json, PaginationQuery, Query, State,
auth::{Auth, Claims}, auth::{Auth, Claims},
}; };
@ -36,6 +42,7 @@ pub(super) fn router() -> Router<Arc<AppState>> {
.route("/@me/transactions", get(me_transaction_history)) .route("/@me/transactions", get(me_transaction_history))
.route("/@me/password", put(change_password)) .route("/@me/password", put(change_password))
.route("/", get(list_users)) .route("/", get(list_users))
.route("/interop", get(list_interop_users))
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -90,6 +97,66 @@ pub async fn list_users(
Ok(Json(users)) Ok(Json(users))
} }
async fn fetch_users(state: &InteropState) -> Result<Vec<String>, InteropError> {
let response = state
.client
.get(state.user_url.clone())
.bearer_auth(&state.secret)
.send()
.await
.map_err(InteropError::Http)?;
if response.status() != StatusCode::OK {
return Err(InteropError::Other(response));
}
let mut data = response
.json::<InteropListUsers>()
.await
.map_err(InteropError::Http)?;
for name in data.users.iter_mut() {
name.push('@');
name.push_str(&state.suffix);
}
Ok(data.users)
}
#[instrument(skip(state))]
pub async fn list_interop_users(
EState(state): State,
_: Auth,
) -> Result<Json<serde_json::Value>, Error> {
let Some(interop) = &state.interop else {
return Ok(Json(serde_json::Value::Array(vec![])));
};
let mut guard = interop.users.lock().await;
let names = match &mut *guard {
Some((users, time)) => {
if Instant::now().duration_since(*time) > Duration::from_secs(100) {
match fetch_users(interop).await {
Ok(names) => {
users.names = names;
}
Err(err) => {
let _ = Error::from(err);
}
};
};
users.names.as_slice()
}
None => {
match fetch_users(interop).await {
Ok(names) => {
*guard = Some((InteropUsers { names }, Instant::now()));
}
Err(err) => {
let _ = Error::from(err);
}
};
guard.as_ref().unwrap().0.names.as_slice()
}
};
Ok(Json(json!(names)))
}
#[instrument(skip(state))] #[instrument(skip(state))]
pub async fn me_transaction_history( pub async fn me_transaction_history(
EState(state): State, EState(state): State,

View File

@ -7,7 +7,7 @@ use bankserver::{
setup_db, setup_db,
}; };
use jsonwebtoken::{DecodingKey, EncodingKey}; use jsonwebtoken::{DecodingKey, EncodingKey};
use tokio::{net::TcpListener, signal}; use tokio::{net::TcpListener, signal, sync::Mutex};
use tracing::{info, level_filters::LevelFilter}; use tracing::{info, level_filters::LevelFilter};
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt};
@ -45,9 +45,11 @@ async fn main() {
.timeout(Duration::from_secs(2)) .timeout(Duration::from_secs(2))
.build() .build()
.unwrap(), .unwrap(),
user_url: config.url.join("/_internal/users").unwrap(),
pay_url: config.url.join("/_internal/pay").unwrap(), pay_url: config.url.join("/_internal/pay").unwrap(),
url: config.url, url: config.url,
prefix: config.prefix, suffix: config.prefix,
users: Mutex::new(None),
}); });
let router = Router::new() let router = Router::new()