chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
51
crates/quicprochat-server/src/v2_handlers/account.rs
Normal file
51
crates/quicprochat-server/src/v2_handlers/account.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Account handler — account deletion.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::account::AccountService;
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
pub async fn handle_delete_account(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
// DeleteAccountRequest is empty but decode for protocol correctness.
|
||||
let _req = match v1::DeleteAccountRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = AccountService {
|
||||
store: Arc::clone(&state.store),
|
||||
kt_log: Arc::clone(&state.kt_log),
|
||||
};
|
||||
|
||||
match svc.delete_account(&identity_key) {
|
||||
Ok(()) => {
|
||||
// Remove session for the deleted account.
|
||||
if let Some(token) = ctx.session_token.as_deref() {
|
||||
state.sessions.remove(token);
|
||||
}
|
||||
|
||||
let proto = v1::DeleteAccountResponse { success: true };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
255
crates/quicprochat-server/src/v2_handlers/auth.rs
Normal file
255
crates/quicprochat-server/src/v2_handlers/auth.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
//! OPAQUE auth handlers — registration and login.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::error::RpcStatus;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::auth::{PendingLogin, SessionInfo, SESSION_TTL_SECS};
|
||||
use crate::domain::auth::AuthService;
|
||||
use crate::domain::types::{RegisterFinishReq, RegisterStartReq};
|
||||
|
||||
use super::ServerState;
|
||||
|
||||
pub async fn handle_opaque_register_start(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::OpaqueRegisterStartRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.username.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "username must not be empty");
|
||||
}
|
||||
|
||||
let svc = AuthService {
|
||||
store: Arc::clone(&state.store),
|
||||
opaque_setup: Arc::clone(&state.opaque_setup),
|
||||
pending_logins: Arc::clone(&state.pending_logins),
|
||||
sessions: Arc::clone(&state.sessions),
|
||||
auth_cfg: Arc::clone(&state.auth_cfg),
|
||||
};
|
||||
|
||||
let domain_req = RegisterStartReq {
|
||||
username: req.username,
|
||||
request_bytes: req.request,
|
||||
};
|
||||
|
||||
match svc.register_start(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::OpaqueRegisterStartResponse {
|
||||
response: resp.response_bytes,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("register_start: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_opaque_register_finish(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::OpaqueRegisterFinishRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.username.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "username must not be empty");
|
||||
}
|
||||
|
||||
let svc = AuthService {
|
||||
store: Arc::clone(&state.store),
|
||||
opaque_setup: Arc::clone(&state.opaque_setup),
|
||||
pending_logins: Arc::clone(&state.pending_logins),
|
||||
sessions: Arc::clone(&state.sessions),
|
||||
auth_cfg: Arc::clone(&state.auth_cfg),
|
||||
};
|
||||
|
||||
let domain_req = RegisterFinishReq {
|
||||
username: req.username.clone(),
|
||||
upload_bytes: req.upload,
|
||||
identity_key: req.identity_key.clone(),
|
||||
};
|
||||
|
||||
match svc.register_finish(domain_req) {
|
||||
Ok(resp) => {
|
||||
state
|
||||
.hooks
|
||||
.on_user_registered(&req.username, &req.identity_key);
|
||||
|
||||
let proto = v1::OpaqueRegisterFinishResponse {
|
||||
success: resp.success,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("register_finish: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_opaque_login_start(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::OpaqueLoginStartRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.username.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "username must not be empty");
|
||||
}
|
||||
|
||||
// Look up user record.
|
||||
let user_record = match state.store.get_user_record(&req.username) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
return HandlerResult::err(RpcStatus::NotFound, "user not found");
|
||||
}
|
||||
Err(e) => return HandlerResult::err(RpcStatus::Internal, &format!("store: {e}")),
|
||||
};
|
||||
|
||||
// Deserialise stored registration.
|
||||
let registration =
|
||||
match opaque_ke::ServerRegistration::<quicprochat_core::opaque_auth::OpaqueSuite>::deserialize(&user_record) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::Internal,
|
||||
&format!("corrupt user record: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Start login.
|
||||
let credential_request =
|
||||
match opaque_ke::CredentialRequest::<quicprochat_core::opaque_auth::OpaqueSuite>::deserialize(&req.request)
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, &format!("bad login request: {e}"))
|
||||
}
|
||||
};
|
||||
|
||||
let login_start = match opaque_ke::ServerLogin::<
|
||||
quicprochat_core::opaque_auth::OpaqueSuite,
|
||||
>::start(
|
||||
&mut rand::rngs::OsRng,
|
||||
&state.opaque_setup,
|
||||
Some(registration),
|
||||
credential_request,
|
||||
req.username.as_bytes(),
|
||||
Default::default(),
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(RpcStatus::Internal, &format!("login start: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let response_bytes = login_start.message.serialize().to_vec();
|
||||
|
||||
// Store pending login state.
|
||||
let now = crate::auth::current_timestamp();
|
||||
state.pending_logins.insert(
|
||||
req.username.clone(),
|
||||
PendingLogin {
|
||||
state_bytes: login_start.state.serialize().to_vec(),
|
||||
created_at: now,
|
||||
},
|
||||
);
|
||||
|
||||
let proto = v1::OpaqueLoginStartResponse {
|
||||
response: response_bytes,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
|
||||
pub async fn handle_opaque_login_finish(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::OpaqueLoginFinishRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.username.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "username must not be empty");
|
||||
}
|
||||
|
||||
// Retrieve pending login state.
|
||||
let pending = match state.pending_logins.remove(&req.username) {
|
||||
Some((_, p)) => p,
|
||||
None => {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::BadRequest,
|
||||
"no pending login for this username",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let login_state = match opaque_ke::ServerLogin::<
|
||||
quicprochat_core::opaque_auth::OpaqueSuite,
|
||||
>::deserialize(&pending.state_bytes)
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::Internal,
|
||||
&format!("corrupt pending login: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let finalization = match opaque_ke::CredentialFinalization::<
|
||||
quicprochat_core::opaque_auth::OpaqueSuite,
|
||||
>::deserialize(&req.finalization)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, &format!("bad finalization: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = login_state.finish(finalization, Default::default()) {
|
||||
state.hooks.on_auth(&crate::hooks::AuthEvent {
|
||||
username: req.username.clone(),
|
||||
success: false,
|
||||
failure_reason: format!("{e}"),
|
||||
});
|
||||
return HandlerResult::err(RpcStatus::Unauthorized, &format!("login failed: {e}"));
|
||||
}
|
||||
|
||||
// Generate session token.
|
||||
let mut token = vec![0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token);
|
||||
|
||||
let now = crate::auth::current_timestamp();
|
||||
state.sessions.insert(
|
||||
token.clone(),
|
||||
SessionInfo {
|
||||
username: req.username.clone(),
|
||||
identity_key: req.identity_key.clone(),
|
||||
created_at: now,
|
||||
expires_at: now + SESSION_TTL_SECS,
|
||||
},
|
||||
);
|
||||
|
||||
state.hooks.on_auth(&crate::hooks::AuthEvent {
|
||||
username: req.username,
|
||||
success: true,
|
||||
failure_reason: String::new(),
|
||||
});
|
||||
|
||||
let proto = v1::OpaqueLoginFinishResponse {
|
||||
session_token: token,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
101
crates/quicprochat-server/src/v2_handlers/blob.rs
Normal file
101
crates/quicprochat-server/src/v2_handlers/blob.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! Blob handlers — chunked file upload/download.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::blobs::BlobService;
|
||||
use crate::domain::types::{CallerAuth, DownloadBlobReq, UploadBlobReq};
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
fn caller_auth(identity_key: Vec<u8>) -> CallerAuth {
|
||||
CallerAuth {
|
||||
identity_key,
|
||||
token: Vec::new(),
|
||||
device_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_upload_blob(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::UploadBlobRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = BlobService {
|
||||
data_dir: state.data_dir.clone(),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = UploadBlobReq {
|
||||
blob_hash: req.blob_hash,
|
||||
chunk: req.chunk,
|
||||
offset: req.offset,
|
||||
total_size: req.total_size,
|
||||
mime_type: req.mime_type,
|
||||
};
|
||||
|
||||
match svc.upload_blob(domain_req, &auth) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::UploadBlobResponse {
|
||||
blob_id: resp.blob_id,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_download_blob(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::DownloadBlobRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = BlobService {
|
||||
data_dir: state.data_dir.clone(),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = DownloadBlobReq {
|
||||
blob_id: req.blob_id,
|
||||
offset: req.offset,
|
||||
length: req.length,
|
||||
};
|
||||
|
||||
match svc.download_blob(domain_req, &auth) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::DownloadBlobResponse {
|
||||
chunk: resp.chunk,
|
||||
total_size: resp.total_size,
|
||||
mime_type: resp.mime_type,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
60
crates/quicprochat-server/src/v2_handlers/channel.rs
Normal file
60
crates/quicprochat-server/src/v2_handlers/channel.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Channel handler — 1:1 DM channel creation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::channels::ChannelService;
|
||||
use crate::domain::types::CreateChannelReq;
|
||||
use crate::hooks::ChannelEvent;
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
pub async fn handle_create_channel(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::CreateChannelRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = ChannelService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
let domain_req = CreateChannelReq {
|
||||
peer_key: req.peer_key.clone(),
|
||||
};
|
||||
|
||||
match svc.create_channel(domain_req, &identity_key) {
|
||||
Ok(resp) => {
|
||||
state.hooks.on_channel_created(&ChannelEvent {
|
||||
channel_id: resp.channel_id.clone(),
|
||||
initiator_key: identity_key,
|
||||
peer_key: req.peer_key,
|
||||
was_new: resp.was_new,
|
||||
});
|
||||
|
||||
let proto = v1::CreateChannelResponse {
|
||||
channel_id: resp.channel_id,
|
||||
was_new: resp.was_new,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
408
crates/quicprochat-server/src/v2_handlers/delivery.rs
Normal file
408
crates/quicprochat-server/src/v2_handlers/delivery.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
//! Delivery handlers — enqueue, fetch, fetch_wait, peek, ack, batch_enqueue.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::error::RpcStatus;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::domain::delivery::DeliveryService;
|
||||
use crate::domain::types::{AckReq, BatchEnqueueReq, EnqueueReq, FetchReq, PeekReq};
|
||||
use crate::hooks::{HookAction, MessageEvent};
|
||||
|
||||
use super::{require_auth, ServerState};
|
||||
|
||||
/// Build a 96-byte delivery proof: `SHA-256(seq || recipient_key || timestamp_ms) || Ed25519(hash)`.
|
||||
///
|
||||
/// The sender stores this as cryptographic evidence that the server enqueued the message.
|
||||
fn build_delivery_proof(
|
||||
signing_key: &quicprochat_core::IdentityKeypair,
|
||||
seq: u64,
|
||||
recipient_key: &[u8],
|
||||
timestamp_ms: u64,
|
||||
) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(seq.to_le_bytes());
|
||||
hasher.update(recipient_key);
|
||||
hasher.update(timestamp_ms.to_le_bytes());
|
||||
let hash: [u8; 32] = hasher.finalize().into();
|
||||
|
||||
let sig = signing_key.sign_raw(&hash);
|
||||
|
||||
let mut proof = vec![0u8; 96];
|
||||
proof[..32].copy_from_slice(&hash);
|
||||
proof[32..].copy_from_slice(&sig);
|
||||
proof
|
||||
}
|
||||
|
||||
pub async fn handle_enqueue(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::EnqueueRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.recipient_key.is_empty() || req.payload.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "recipient_key and payload required");
|
||||
}
|
||||
|
||||
// Rate limiting.
|
||||
if let Err(_e) = crate::auth::check_rate_limit(&state.rate_limits, &identity_key) {
|
||||
return HandlerResult::err(RpcStatus::RateLimited, "rate limit exceeded");
|
||||
}
|
||||
|
||||
// Idempotency dedup: if message_id is provided and already seen, return the cached seq.
|
||||
if !req.message_id.is_empty() {
|
||||
if let Some(entry) = state.seen_message_ids.get(&req.message_id) {
|
||||
let (cached_seq, _ts) = *entry;
|
||||
let proto = v1::EnqueueResponse {
|
||||
seq: cached_seq,
|
||||
delivery_proof: Vec::new(),
|
||||
duplicate: true,
|
||||
};
|
||||
return HandlerResult::ok(Bytes::from(proto.encode_to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
let svc = DeliveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
waiters: Arc::clone(&state.waiters),
|
||||
};
|
||||
|
||||
let domain_req = EnqueueReq {
|
||||
recipient_key: req.recipient_key.clone(),
|
||||
payload: req.payload.clone(),
|
||||
channel_id: req.channel_id.clone(),
|
||||
ttl_secs: req.ttl_secs,
|
||||
};
|
||||
|
||||
match svc.enqueue(domain_req) {
|
||||
Ok(resp) => {
|
||||
// Record message_id for dedup.
|
||||
if !req.message_id.is_empty() {
|
||||
let now = crate::auth::current_timestamp();
|
||||
state.seen_message_ids.insert(req.message_id, (resp.seq, now));
|
||||
}
|
||||
|
||||
// Fire hook.
|
||||
let action = state.hooks.on_message_enqueue(&MessageEvent {
|
||||
sender_identity: Some(identity_key),
|
||||
recipient_key: req.recipient_key.clone(),
|
||||
channel_id: req.channel_id,
|
||||
payload_len: req.payload.len(),
|
||||
seq: resp.seq,
|
||||
});
|
||||
if let HookAction::Reject(reason) = action {
|
||||
return HandlerResult::err(RpcStatus::Forbidden, &reason);
|
||||
}
|
||||
|
||||
// Build server-signed delivery proof.
|
||||
let timestamp_ms = crate::auth::current_timestamp();
|
||||
let delivery_proof = build_delivery_proof(
|
||||
&state.signing_key,
|
||||
resp.seq,
|
||||
&req.recipient_key,
|
||||
timestamp_ms,
|
||||
);
|
||||
|
||||
let proto = v1::EnqueueResponse {
|
||||
seq: resp.seq,
|
||||
delivery_proof,
|
||||
duplicate: false,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("enqueue: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_fetch(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::FetchRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let svc = DeliveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
waiters: Arc::clone(&state.waiters),
|
||||
};
|
||||
|
||||
let base_key = if req.recipient_key.is_empty() {
|
||||
identity_key
|
||||
} else {
|
||||
req.recipient_key
|
||||
};
|
||||
let recipient_key = if req.device_id.is_empty() {
|
||||
base_key
|
||||
} else {
|
||||
DeliveryService::device_recipient_key(&base_key, &req.device_id)
|
||||
};
|
||||
|
||||
let domain_req = FetchReq {
|
||||
recipient_key,
|
||||
channel_id: req.channel_id,
|
||||
limit: req.limit,
|
||||
};
|
||||
|
||||
match svc.fetch(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::FetchResponse {
|
||||
payloads: resp
|
||||
.payloads
|
||||
.into_iter()
|
||||
.map(|e| v1::Envelope {
|
||||
seq: e.seq,
|
||||
data: e.data,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("fetch: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_fetch_wait(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::FetchWaitRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let base_key = if req.recipient_key.is_empty() {
|
||||
identity_key
|
||||
} else {
|
||||
req.recipient_key
|
||||
};
|
||||
let recipient_key = if req.device_id.is_empty() {
|
||||
base_key
|
||||
} else {
|
||||
DeliveryService::device_recipient_key(&base_key, &req.device_id)
|
||||
};
|
||||
|
||||
let timeout_ms = if req.timeout_ms == 0 {
|
||||
30_000
|
||||
} else {
|
||||
req.timeout_ms.min(60_000)
|
||||
};
|
||||
|
||||
let svc = DeliveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
waiters: Arc::clone(&state.waiters),
|
||||
};
|
||||
|
||||
// Try immediate fetch first.
|
||||
let fetch_req = FetchReq {
|
||||
recipient_key: recipient_key.clone(),
|
||||
channel_id: req.channel_id.clone(),
|
||||
limit: req.limit,
|
||||
};
|
||||
|
||||
match svc.fetch(fetch_req) {
|
||||
Ok(resp) if !resp.payloads.is_empty() => {
|
||||
let proto = v1::FetchWaitResponse {
|
||||
payloads: resp
|
||||
.payloads
|
||||
.into_iter()
|
||||
.map(|e| v1::Envelope {
|
||||
seq: e.seq,
|
||||
data: e.data,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
return HandlerResult::ok(Bytes::from(proto.encode_to_vec()));
|
||||
}
|
||||
Err(e) => {
|
||||
return HandlerResult::err(RpcStatus::Internal, &format!("fetch: {e}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Long-poll: wait for notification or timeout.
|
||||
let notify = state
|
||||
.waiters
|
||||
.entry(recipient_key.clone())
|
||||
.or_insert_with(|| Arc::new(Notify::new()))
|
||||
.clone();
|
||||
|
||||
let timeout = tokio::time::Duration::from_millis(timeout_ms);
|
||||
let _ = tokio::time::timeout(timeout, notify.notified()).await;
|
||||
|
||||
// Re-fetch after wake or timeout.
|
||||
let fetch_req = FetchReq {
|
||||
recipient_key,
|
||||
channel_id: req.channel_id,
|
||||
limit: req.limit,
|
||||
};
|
||||
|
||||
match svc.fetch(fetch_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::FetchWaitResponse {
|
||||
payloads: resp
|
||||
.payloads
|
||||
.into_iter()
|
||||
.map(|e| v1::Envelope {
|
||||
seq: e.seq,
|
||||
data: e.data,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("fetch: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_peek(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::PeekRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let svc = DeliveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
waiters: Arc::clone(&state.waiters),
|
||||
};
|
||||
|
||||
let base_key = if req.recipient_key.is_empty() {
|
||||
identity_key
|
||||
} else {
|
||||
req.recipient_key
|
||||
};
|
||||
let recipient_key = if req.device_id.is_empty() {
|
||||
base_key
|
||||
} else {
|
||||
DeliveryService::device_recipient_key(&base_key, &req.device_id)
|
||||
};
|
||||
|
||||
let domain_req = PeekReq {
|
||||
recipient_key,
|
||||
channel_id: req.channel_id,
|
||||
limit: req.limit,
|
||||
};
|
||||
|
||||
match svc.peek(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::PeekResponse {
|
||||
payloads: resp
|
||||
.payloads
|
||||
.into_iter()
|
||||
.map(|e| v1::Envelope {
|
||||
seq: e.seq,
|
||||
data: e.data,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("peek: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_ack(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::AckRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let svc = DeliveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
waiters: Arc::clone(&state.waiters),
|
||||
};
|
||||
|
||||
let base_key = if req.recipient_key.is_empty() {
|
||||
identity_key
|
||||
} else {
|
||||
req.recipient_key
|
||||
};
|
||||
let recipient_key = if req.device_id.is_empty() {
|
||||
base_key
|
||||
} else {
|
||||
DeliveryService::device_recipient_key(&base_key, &req.device_id)
|
||||
};
|
||||
|
||||
let domain_req = AckReq {
|
||||
recipient_key,
|
||||
channel_id: req.channel_id,
|
||||
seq_up_to: req.seq_up_to,
|
||||
};
|
||||
|
||||
match svc.ack(domain_req) {
|
||||
Ok(()) => {
|
||||
let proto = v1::AckResponse {};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("ack: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_batch_enqueue(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::BatchEnqueueRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.recipient_keys.is_empty() || req.payload.is_empty() {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::BadRequest,
|
||||
"recipient_keys and payload required",
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limiting.
|
||||
if let Err(_e) = crate::auth::check_rate_limit(&state.rate_limits, &identity_key) {
|
||||
return HandlerResult::err(RpcStatus::RateLimited, "rate limit exceeded");
|
||||
}
|
||||
|
||||
let svc = DeliveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
waiters: Arc::clone(&state.waiters),
|
||||
};
|
||||
|
||||
let domain_req = BatchEnqueueReq {
|
||||
recipient_keys: req.recipient_keys,
|
||||
payload: req.payload,
|
||||
channel_id: req.channel_id,
|
||||
ttl_secs: req.ttl_secs,
|
||||
};
|
||||
|
||||
match svc.batch_enqueue(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::BatchEnqueueResponse { seqs: resp.seqs };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("batch_enqueue: {e}")),
|
||||
}
|
||||
}
|
||||
127
crates/quicprochat-server/src/v2_handlers/device.rs
Normal file
127
crates/quicprochat-server/src/v2_handlers/device.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Device handlers — register, list, revoke devices.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::devices::DeviceService;
|
||||
use crate::domain::types::{RegisterDeviceReq, RevokeDeviceReq};
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
pub async fn handle_register_device(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::RegisterDeviceRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = DeviceService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
let domain_req = RegisterDeviceReq {
|
||||
device_id: req.device_id,
|
||||
device_name: req.device_name,
|
||||
};
|
||||
|
||||
match svc.register_device(domain_req, &identity_key) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::RegisterDeviceResponse {
|
||||
success: resp.success,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_list_devices(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
// ListDevicesRequest is empty but we still decode for protocol correctness.
|
||||
let _req = match v1::ListDevicesRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = DeviceService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
match svc.list_devices(&identity_key) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::ListDevicesResponse {
|
||||
devices: resp
|
||||
.devices
|
||||
.into_iter()
|
||||
.map(|d| v1::Device {
|
||||
device_id: d.device_id,
|
||||
device_name: d.device_name,
|
||||
registered_at: d.registered_at,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_revoke_device(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::RevokeDeviceRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = DeviceService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
let domain_req = RevokeDeviceReq {
|
||||
device_id: req.device_id,
|
||||
};
|
||||
|
||||
match svc.revoke_device(domain_req, &identity_key) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::RevokeDeviceResponse {
|
||||
success: resp.success,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
227
crates/quicprochat-server/src/v2_handlers/federation.rs
Normal file
227
crates/quicprochat-server/src/v2_handlers/federation.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
//! Federation v2 RPC handlers — relay, proxy, and health.
|
||||
//!
|
||||
//! Implements the inbound side of server-to-server federation: accepts relay
|
||||
//! and proxy requests from peer servers and delegates to local storage.
|
||||
//! Outbound relay to remote peers is handled by the capnp-based
|
||||
//! `FederationClient` on the main connection path.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::error::RpcStatus;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::federation::address::FederatedAddress;
|
||||
|
||||
use super::ServerState;
|
||||
|
||||
/// Validate that the request carries a valid federation auth origin.
|
||||
fn validate_federation_auth(auth: &Option<v1::FederationAuth>) -> Result<String, HandlerResult> {
|
||||
let a = auth.as_ref().ok_or_else(|| {
|
||||
HandlerResult::err(RpcStatus::Unauthorized, "missing federation auth")
|
||||
})?;
|
||||
if a.origin.is_empty() {
|
||||
return Err(HandlerResult::err(
|
||||
RpcStatus::Unauthorized,
|
||||
"federation auth origin must not be empty",
|
||||
));
|
||||
}
|
||||
Ok(a.origin.clone())
|
||||
}
|
||||
|
||||
/// Relay a single message to a local recipient.
|
||||
///
|
||||
/// This handler is called by peer servers to deliver messages to users
|
||||
/// homed on this server. If the recipient is not local, returns NotFound
|
||||
/// (the originating server should route directly to the correct home server).
|
||||
pub async fn handle_relay_enqueue(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let req = match v1::RelayEnqueueRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let origin = match validate_federation_auth(&req.auth) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if req.recipient_key.len() != 32 {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "recipient_key must be 32 bytes");
|
||||
}
|
||||
if req.payload.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "payload must not be empty");
|
||||
}
|
||||
|
||||
match state
|
||||
.store
|
||||
.enqueue(&req.recipient_key, &req.channel_id, req.payload, None)
|
||||
{
|
||||
Ok(seq) => {
|
||||
if let Some(waiter) = state.waiters.get(&req.recipient_key) {
|
||||
waiter.notify_waiters();
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
origin = %origin,
|
||||
recipient_prefix = %hex::encode(&req.recipient_key[..4]),
|
||||
seq = seq,
|
||||
"federation: relayed enqueue"
|
||||
);
|
||||
|
||||
let resp = v1::RelayEnqueueResponse { seq };
|
||||
HandlerResult::ok(Bytes::from(resp.encode_to_vec()))
|
||||
}
|
||||
Err(e) => HandlerResult::err(RpcStatus::Internal, &format!("store error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay a batch of messages to local recipients.
|
||||
pub async fn handle_relay_batch_enqueue(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::RelayBatchEnqueueRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let _origin = match validate_federation_auth(&req.auth) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if req.payload.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "payload must not be empty");
|
||||
}
|
||||
|
||||
let mut seqs = Vec::with_capacity(req.recipient_keys.len());
|
||||
for rk in &req.recipient_keys {
|
||||
if rk.len() != 32 {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::BadRequest,
|
||||
"each recipient_key must be 32 bytes",
|
||||
);
|
||||
}
|
||||
match state
|
||||
.store
|
||||
.enqueue(rk, &req.channel_id, req.payload.clone(), None)
|
||||
{
|
||||
Ok(seq) => {
|
||||
if let Some(waiter) = state.waiters.get(rk.as_slice()) {
|
||||
waiter.notify_waiters();
|
||||
}
|
||||
seqs.push(seq);
|
||||
}
|
||||
Err(e) => {
|
||||
return HandlerResult::err(RpcStatus::Internal, &format!("store error: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
recipient_count = req.recipient_keys.len(),
|
||||
"federation: relayed batch_enqueue"
|
||||
);
|
||||
|
||||
let resp = v1::RelayBatchEnqueueResponse { seqs };
|
||||
HandlerResult::ok(Bytes::from(resp.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// Proxy a key package fetch from local storage.
|
||||
pub async fn handle_proxy_fetch_key_package(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::ProxyFetchKeyPackageRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let _origin = match validate_federation_auth(&req.auth) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let package = match state.store.fetch_key_package(&req.identity_key) {
|
||||
Ok(pkg) => pkg.unwrap_or_default(),
|
||||
Err(e) => return HandlerResult::err(RpcStatus::Internal, &format!("store error: {e}")),
|
||||
};
|
||||
|
||||
let resp = v1::ProxyFetchKeyPackageResponse { package };
|
||||
HandlerResult::ok(Bytes::from(resp.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// Proxy a hybrid key fetch from local storage.
|
||||
pub async fn handle_proxy_fetch_hybrid_key(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::ProxyFetchHybridKeyRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let _origin = match validate_federation_auth(&req.auth) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let hybrid_public_key = match state.store.fetch_hybrid_key(&req.identity_key) {
|
||||
Ok(pk) => pk.unwrap_or_default(),
|
||||
Err(e) => return HandlerResult::err(RpcStatus::Internal, &format!("store error: {e}")),
|
||||
};
|
||||
|
||||
let resp = v1::ProxyFetchHybridKeyResponse { hybrid_public_key };
|
||||
HandlerResult::ok(Bytes::from(resp.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// Proxy a user resolution from local storage.
|
||||
///
|
||||
/// Supports federated `user@domain` addresses: if the domain matches the
|
||||
/// local server, the local user is resolved; otherwise returns empty.
|
||||
pub async fn handle_proxy_resolve_user(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::ProxyResolveUserRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let _origin = match validate_federation_auth(&req.auth) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let addr = FederatedAddress::parse(&req.username);
|
||||
let is_local = addr.is_local(&state.local_domain);
|
||||
|
||||
let identity_key = if is_local {
|
||||
match state.store.get_user_identity_key(&addr.username) {
|
||||
Ok(key) => key.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
return HandlerResult::err(RpcStatus::Internal, &format!("store error: {e}"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remote user: not on this server. Return empty.
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let resp = v1::ProxyResolveUserResponse { identity_key };
|
||||
HandlerResult::ok(Bytes::from(resp.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// Federation health check — returns ok status and this server's domain.
|
||||
pub async fn handle_federation_health(
|
||||
state: Arc<ServerState>,
|
||||
_ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let resp = v1::FederationHealthResponse {
|
||||
status: "ok".into(),
|
||||
server_domain: state.local_domain.clone(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(resp.encode_to_vec()))
|
||||
}
|
||||
162
crates/quicprochat-server/src/v2_handlers/group.rs
Normal file
162
crates/quicprochat-server/src/v2_handlers/group.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! Group management handlers — remove member, update metadata, list members, rotate keys.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::error::RpcStatus;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::groups::GroupService;
|
||||
use crate::domain::types::{ListGroupMembersReq, UpdateGroupMetadataReq};
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
/// Handle RemoveMember (410): track member removal server-side.
|
||||
///
|
||||
/// Note: actual MLS removal (Remove proposal + Commit) is done client-side
|
||||
/// via the SDK. This handler records the membership change on the server.
|
||||
pub async fn handle_remove_member(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::RemoveMemberRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.group_id.is_empty() || req.member_identity_key.is_empty() {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::BadRequest,
|
||||
"group_id and member_identity_key required",
|
||||
);
|
||||
}
|
||||
|
||||
let svc = GroupService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
match svc.remove_member(&req.group_id, &req.member_identity_key) {
|
||||
Ok(_) => {
|
||||
let _ = identity_key; // caller is authorized; removal tracked
|
||||
let proto = v1::RemoveMemberResponse {
|
||||
commit: Vec::new(), // commit is generated client-side
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle UpdateGroupMetadata (411): store group name, description, avatar.
|
||||
pub async fn handle_update_group_metadata(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::UpdateGroupMetadataRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let svc = GroupService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
let domain_req = UpdateGroupMetadataReq {
|
||||
group_id: req.group_id,
|
||||
name: req.name,
|
||||
description: req.description,
|
||||
avatar_hash: req.avatar_hash,
|
||||
};
|
||||
|
||||
match svc.update_metadata(domain_req, &identity_key) {
|
||||
Ok(()) => {
|
||||
let proto = v1::UpdateGroupMetadataResponse { success: true };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ListGroupMembers (412): return member list with resolved usernames.
|
||||
pub async fn handle_list_group_members(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::ListGroupMembersRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let svc = GroupService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
let domain_req = ListGroupMembersReq {
|
||||
group_id: req.group_id,
|
||||
};
|
||||
|
||||
match svc.list_members(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::ListGroupMembersResponse {
|
||||
members: resp
|
||||
.members
|
||||
.into_iter()
|
||||
.map(|m| v1::GroupMemberInfo {
|
||||
identity_key: m.identity_key,
|
||||
username: m.username,
|
||||
joined_at: m.joined_at,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle RotateKeys (413): acknowledge key rotation.
|
||||
///
|
||||
/// Actual MLS key rotation (Update proposal + Commit) is done client-side.
|
||||
/// This handler exists for server-side tracking and future rate limiting.
|
||||
pub async fn handle_rotate_keys(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::RotateKeysRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.group_id.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "group_id required");
|
||||
}
|
||||
|
||||
// Key rotation is handled entirely client-side in MLS.
|
||||
// This endpoint is for server-side auditing and future rate limiting.
|
||||
let proto = v1::RotateKeysResponse {
|
||||
commit: Vec::new(), // commit is generated client-side
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
217
crates/quicprochat-server/src/v2_handlers/keys.rs
Normal file
217
crates/quicprochat-server/src/v2_handlers/keys.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! Key management handlers — KeyPackage and hybrid key operations.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::keys::KeyService;
|
||||
use crate::domain::types::{
|
||||
CallerAuth, FetchHybridKeyReq, FetchHybridKeysReq, FetchKeyPackageReq, UploadHybridKeyReq,
|
||||
UploadKeyPackageReq,
|
||||
};
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
fn caller_auth(identity_key: Vec<u8>) -> CallerAuth {
|
||||
CallerAuth {
|
||||
identity_key,
|
||||
token: Vec::new(),
|
||||
device_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_upload_key_package(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::UploadKeyPackageRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = KeyService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = UploadKeyPackageReq {
|
||||
identity_key: req.identity_key,
|
||||
package: req.package,
|
||||
};
|
||||
|
||||
match svc.upload_key_package(domain_req, &auth) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::UploadKeyPackageResponse {
|
||||
fingerprint: resp.fingerprint,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_fetch_key_package(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::FetchKeyPackageRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = KeyService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = FetchKeyPackageReq {
|
||||
identity_key: req.identity_key,
|
||||
};
|
||||
|
||||
match svc.fetch_key_package(domain_req, &auth) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::FetchKeyPackageResponse {
|
||||
package: resp.package,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_upload_hybrid_key(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::UploadHybridKeyRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = KeyService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = UploadHybridKeyReq {
|
||||
identity_key: req.identity_key,
|
||||
hybrid_public_key: req.hybrid_public_key,
|
||||
};
|
||||
|
||||
match svc.upload_hybrid_key(domain_req, &auth) {
|
||||
Ok(()) => {
|
||||
let proto = v1::UploadHybridKeyResponse {};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_fetch_hybrid_key(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::FetchHybridKeyRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = KeyService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = FetchHybridKeyReq {
|
||||
identity_key: req.identity_key,
|
||||
};
|
||||
|
||||
match svc.fetch_hybrid_key(domain_req, &auth) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::FetchHybridKeyResponse {
|
||||
hybrid_public_key: resp.hybrid_public_key,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_fetch_hybrid_keys(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::FetchHybridKeysRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = KeyService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = FetchHybridKeysReq {
|
||||
identity_keys: req.identity_keys,
|
||||
};
|
||||
|
||||
match svc.fetch_hybrid_keys(domain_req, &auth) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::FetchHybridKeysResponse { keys: resp.keys };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
439
crates/quicprochat-server/src/v2_handlers/mod.rs
Normal file
439
crates/quicprochat-server/src/v2_handlers/mod.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
//! v2 RPC handler dispatch — protobuf in, domain logic, protobuf out.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use opaque_ke::ServerSetup;
|
||||
use quicprochat_core::opaque_auth::OpaqueSuite;
|
||||
use quicprochat_proto::method_ids;
|
||||
use quicprochat_rpc::error::RpcStatus;
|
||||
use quicprochat_rpc::method::{HandlerResult, MethodRegistry, RequestContext};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::audit::AuditLogger;
|
||||
use crate::auth::{AuthConfig, PendingLogin, RateEntry, SessionInfo};
|
||||
use crate::hooks::ServerHooks;
|
||||
use crate::storage::Store;
|
||||
|
||||
pub mod account;
|
||||
pub mod auth;
|
||||
pub mod blob;
|
||||
pub mod channel;
|
||||
pub mod delivery;
|
||||
pub mod device;
|
||||
pub mod federation;
|
||||
pub mod group;
|
||||
pub mod keys;
|
||||
pub mod moderation;
|
||||
pub mod p2p;
|
||||
pub mod recovery;
|
||||
pub mod user;
|
||||
|
||||
/// Shared server state accessible by all v2 RPC handlers.
|
||||
pub struct ServerState {
|
||||
pub store: Arc<dyn Store>,
|
||||
pub waiters: Arc<DashMap<Vec<u8>, Arc<Notify>>>,
|
||||
pub auth_cfg: Arc<AuthConfig>,
|
||||
pub opaque_setup: Arc<ServerSetup<OpaqueSuite>>,
|
||||
pub pending_logins: Arc<DashMap<String, PendingLogin>>,
|
||||
pub sessions: Arc<DashMap<Vec<u8>, SessionInfo>>,
|
||||
pub rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>,
|
||||
pub sealed_sender: bool,
|
||||
pub hooks: Arc<dyn ServerHooks>,
|
||||
pub signing_key: Arc<quicprochat_core::IdentityKeypair>,
|
||||
pub kt_log: Arc<std::sync::Mutex<quicprochat_kt::MerkleLog>>,
|
||||
pub revocation_log: Arc<std::sync::Mutex<quicprochat_kt::RevocationLog>>,
|
||||
pub data_dir: PathBuf,
|
||||
pub redact_logs: bool,
|
||||
/// Structured audit logger for security-relevant events.
|
||||
pub audit_logger: Arc<dyn AuditLogger>,
|
||||
/// When true, the server is draining and will reject new work.
|
||||
/// Health endpoint returns "draining" status so load balancers stop routing.
|
||||
pub draining: Arc<AtomicBool>,
|
||||
/// Idempotency dedup: message_id -> (seq, timestamp). TTL-cleaned by cleanup task.
|
||||
pub seen_message_ids: Arc<DashMap<Vec<u8>, (u64, u64)>>,
|
||||
/// Banned users: identity_key -> BanRecord.
|
||||
pub banned_users: Arc<DashMap<Vec<u8>, BanRecord>>,
|
||||
/// Moderation reports (append-only).
|
||||
pub moderation_reports: Arc<std::sync::Mutex<Vec<ModerationReport>>>,
|
||||
/// Unique node identifier for multi-node health reporting.
|
||||
pub node_id: String,
|
||||
/// Process start time for uptime calculation.
|
||||
pub start_time: std::time::Instant,
|
||||
/// Storage backend name (e.g. "sql", "file").
|
||||
pub storage_backend: String,
|
||||
/// Federation client for outbound server-to-server relay. None when federation is disabled.
|
||||
pub federation_client: Option<Arc<crate::federation::FederationClient>>,
|
||||
/// This server's domain for federation addressing. Empty when federation is disabled.
|
||||
pub local_domain: String,
|
||||
}
|
||||
|
||||
/// A ban record for a user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BanRecord {
|
||||
pub reason: String,
|
||||
pub banned_at: u64,
|
||||
/// 0 = permanent.
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// A stored moderation report.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModerationReport {
|
||||
pub id: u64,
|
||||
pub encrypted_report: Vec<u8>,
|
||||
pub conversation_id: Vec<u8>,
|
||||
pub reporter_identity: Vec<u8>,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Validate the session token from the request context and return the
|
||||
/// authenticated caller's identity key. Returns an Unauthorized HandlerResult
|
||||
/// on failure.
|
||||
pub fn require_auth(state: &ServerState, ctx: &RequestContext) -> Result<Vec<u8>, HandlerResult> {
|
||||
let token = ctx
|
||||
.session_token
|
||||
.as_deref()
|
||||
.or(ctx.identity_key.as_deref())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(HandlerResult::err(
|
||||
RpcStatus::Unauthorized,
|
||||
"missing session token",
|
||||
));
|
||||
}
|
||||
|
||||
// Check session store.
|
||||
if let Some(session) = state.sessions.get(token) {
|
||||
let now = crate::auth::current_timestamp();
|
||||
if session.expires_at > now && !session.identity_key.is_empty() {
|
||||
// Check ban status.
|
||||
if let Some(ban) = state.banned_users.get(&session.identity_key) {
|
||||
if ban.expires_at == 0 || ban.expires_at > now {
|
||||
return Err(HandlerResult::err(
|
||||
RpcStatus::Forbidden,
|
||||
"account banned",
|
||||
));
|
||||
}
|
||||
// Ban expired — remove it.
|
||||
drop(ban);
|
||||
state.banned_users.remove(&session.identity_key);
|
||||
}
|
||||
return Ok(session.identity_key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to static bearer token (dev mode).
|
||||
if state.auth_cfg.allow_insecure_identity_from_request {
|
||||
if let Some(ik) = ctx.identity_key.as_deref() {
|
||||
if !ik.is_empty() {
|
||||
return Ok(ik.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(HandlerResult::err(
|
||||
RpcStatus::Unauthorized,
|
||||
"invalid or expired session token",
|
||||
))
|
||||
}
|
||||
|
||||
/// Map a domain error to an RPC HandlerResult error.
|
||||
pub fn domain_err(e: crate::domain::types::DomainError) -> HandlerResult {
|
||||
use crate::domain::types::DomainError;
|
||||
match &e {
|
||||
DomainError::InvalidIdentityKey(_)
|
||||
| DomainError::EmptyPackage
|
||||
| DomainError::EmptyHybridKey
|
||||
| DomainError::EmptyUsername
|
||||
| DomainError::BlobHashLength(_)
|
||||
| DomainError::BadParams(_) => HandlerResult::err(RpcStatus::BadRequest, &e.to_string()),
|
||||
|
||||
DomainError::BlobNotFound | DomainError::DeviceNotFound | DomainError::GroupNotFound => {
|
||||
HandlerResult::err(RpcStatus::NotFound, &e.to_string())
|
||||
}
|
||||
|
||||
DomainError::PackageTooLarge(_) | DomainError::BlobTooLarge(_) => {
|
||||
HandlerResult::err(RpcStatus::BadRequest, &e.to_string())
|
||||
}
|
||||
|
||||
DomainError::BlobHashMismatch => {
|
||||
HandlerResult::err(RpcStatus::BadRequest, &e.to_string())
|
||||
}
|
||||
|
||||
DomainError::DeviceLimit(_) => HandlerResult::err(RpcStatus::Forbidden, &e.to_string()),
|
||||
|
||||
DomainError::Io(_) | DomainError::Storage(_) => {
|
||||
HandlerResult::err(RpcStatus::Internal, &e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the v2 method registry with all handlers registered.
|
||||
///
|
||||
/// `default_rpc_timeout` sets the server-wide per-RPC timeout. Individual methods
|
||||
/// (e.g. blob upload, health) may override this with shorter or longer values.
|
||||
pub fn build_registry(default_rpc_timeout: std::time::Duration) -> MethodRegistry<ServerState> {
|
||||
let mut reg = MethodRegistry::new();
|
||||
reg.set_default_timeout(default_rpc_timeout);
|
||||
|
||||
// Auth (100-103)
|
||||
reg.register(
|
||||
method_ids::OPAQUE_REGISTER_START,
|
||||
"OpaqueRegisterStart",
|
||||
auth::handle_opaque_register_start,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::OPAQUE_REGISTER_FINISH,
|
||||
"OpaqueRegisterFinish",
|
||||
auth::handle_opaque_register_finish,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::OPAQUE_LOGIN_START,
|
||||
"OpaqueLoginStart",
|
||||
auth::handle_opaque_login_start,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::OPAQUE_LOGIN_FINISH,
|
||||
"OpaqueLoginFinish",
|
||||
auth::handle_opaque_login_finish,
|
||||
);
|
||||
|
||||
// Delivery (200-205)
|
||||
reg.register(method_ids::ENQUEUE, "Enqueue", delivery::handle_enqueue);
|
||||
reg.register(method_ids::FETCH, "Fetch", delivery::handle_fetch);
|
||||
reg.register(
|
||||
method_ids::FETCH_WAIT,
|
||||
"FetchWait",
|
||||
delivery::handle_fetch_wait,
|
||||
);
|
||||
reg.register(method_ids::PEEK, "Peek", delivery::handle_peek);
|
||||
reg.register(method_ids::ACK, "Ack", delivery::handle_ack);
|
||||
reg.register(
|
||||
method_ids::BATCH_ENQUEUE,
|
||||
"BatchEnqueue",
|
||||
delivery::handle_batch_enqueue,
|
||||
);
|
||||
|
||||
// Keys (300-304)
|
||||
reg.register(
|
||||
method_ids::UPLOAD_KEY_PACKAGE,
|
||||
"UploadKeyPackage",
|
||||
keys::handle_upload_key_package,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::FETCH_KEY_PACKAGE,
|
||||
"FetchKeyPackage",
|
||||
keys::handle_fetch_key_package,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::UPLOAD_HYBRID_KEY,
|
||||
"UploadHybridKey",
|
||||
keys::handle_upload_hybrid_key,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::FETCH_HYBRID_KEY,
|
||||
"FetchHybridKey",
|
||||
keys::handle_fetch_hybrid_key,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::FETCH_HYBRID_KEYS,
|
||||
"FetchHybridKeys",
|
||||
keys::handle_fetch_hybrid_keys,
|
||||
);
|
||||
|
||||
// Channel (400)
|
||||
reg.register(
|
||||
method_ids::CREATE_CHANNEL,
|
||||
"CreateChannel",
|
||||
channel::handle_create_channel,
|
||||
);
|
||||
|
||||
// Group management (410-413)
|
||||
reg.register(
|
||||
method_ids::REMOVE_MEMBER,
|
||||
"RemoveMember",
|
||||
group::handle_remove_member,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::UPDATE_GROUP_METADATA,
|
||||
"UpdateGroupMetadata",
|
||||
group::handle_update_group_metadata,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::LIST_GROUP_MEMBERS,
|
||||
"ListGroupMembers",
|
||||
group::handle_list_group_members,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::ROTATE_KEYS,
|
||||
"RotateKeys",
|
||||
group::handle_rotate_keys,
|
||||
);
|
||||
|
||||
// User (500-501)
|
||||
reg.register(
|
||||
method_ids::RESOLVE_USER,
|
||||
"ResolveUser",
|
||||
user::handle_resolve_user,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::RESOLVE_IDENTITY,
|
||||
"ResolveIdentity",
|
||||
user::handle_resolve_identity,
|
||||
);
|
||||
|
||||
// Key Transparency (510-520)
|
||||
reg.register(
|
||||
method_ids::REVOKE_KEY,
|
||||
"RevokeKey",
|
||||
user::handle_revoke_key,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::CHECK_REVOCATION,
|
||||
"CheckRevocation",
|
||||
user::handle_check_revocation,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::AUDIT_KEY_TRANSPARENCY,
|
||||
"AuditKeyTransparency",
|
||||
user::handle_audit_key_transparency,
|
||||
);
|
||||
|
||||
// Blob (600-601) — longer timeout for file transfers.
|
||||
reg.register_with_timeout(
|
||||
method_ids::UPLOAD_BLOB,
|
||||
"UploadBlob",
|
||||
std::time::Duration::from_secs(120),
|
||||
blob::handle_upload_blob,
|
||||
);
|
||||
reg.register_with_timeout(
|
||||
method_ids::DOWNLOAD_BLOB,
|
||||
"DownloadBlob",
|
||||
std::time::Duration::from_secs(120),
|
||||
blob::handle_download_blob,
|
||||
);
|
||||
|
||||
// Device (700-702)
|
||||
reg.register(
|
||||
method_ids::REGISTER_DEVICE,
|
||||
"RegisterDevice",
|
||||
device::handle_register_device,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::LIST_DEVICES,
|
||||
"ListDevices",
|
||||
device::handle_list_devices,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::REVOKE_DEVICE,
|
||||
"RevokeDevice",
|
||||
device::handle_revoke_device,
|
||||
);
|
||||
|
||||
// P2P (800-802)
|
||||
reg.register(
|
||||
method_ids::PUBLISH_ENDPOINT,
|
||||
"PublishEndpoint",
|
||||
p2p::handle_publish_endpoint,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::RESOLVE_ENDPOINT,
|
||||
"ResolveEndpoint",
|
||||
p2p::handle_resolve_endpoint,
|
||||
);
|
||||
reg.register_with_timeout(
|
||||
method_ids::HEALTH,
|
||||
"Health",
|
||||
std::time::Duration::from_secs(5),
|
||||
p2p::handle_health,
|
||||
);
|
||||
|
||||
// Federation (900-905)
|
||||
reg.register(
|
||||
method_ids::RELAY_ENQUEUE,
|
||||
"RelayEnqueue",
|
||||
federation::handle_relay_enqueue,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::RELAY_BATCH_ENQUEUE,
|
||||
"RelayBatchEnqueue",
|
||||
federation::handle_relay_batch_enqueue,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::PROXY_FETCH_KEY_PACKAGE,
|
||||
"ProxyFetchKeyPackage",
|
||||
federation::handle_proxy_fetch_key_package,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::PROXY_FETCH_HYBRID_KEY,
|
||||
"ProxyFetchHybridKey",
|
||||
federation::handle_proxy_fetch_hybrid_key,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::PROXY_RESOLVE_USER,
|
||||
"ProxyResolveUser",
|
||||
federation::handle_proxy_resolve_user,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::FEDERATION_HEALTH,
|
||||
"FederationHealth",
|
||||
federation::handle_federation_health,
|
||||
);
|
||||
|
||||
// Moderation (420-424)
|
||||
reg.register(
|
||||
method_ids::REPORT_MESSAGE,
|
||||
"ReportMessage",
|
||||
moderation::handle_report_message,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::BAN_USER,
|
||||
"BanUser",
|
||||
moderation::handle_ban_user,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::UNBAN_USER,
|
||||
"UnbanUser",
|
||||
moderation::handle_unban_user,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::LIST_REPORTS,
|
||||
"ListReports",
|
||||
moderation::handle_list_reports,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::LIST_BANNED,
|
||||
"ListBanned",
|
||||
moderation::handle_list_banned,
|
||||
);
|
||||
|
||||
// Recovery (750-752)
|
||||
reg.register(
|
||||
method_ids::STORE_RECOVERY_BUNDLE,
|
||||
"StoreRecoveryBundle",
|
||||
recovery::handle_store_recovery_bundle,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::FETCH_RECOVERY_BUNDLE,
|
||||
"FetchRecoveryBundle",
|
||||
recovery::handle_fetch_recovery_bundle,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::DELETE_RECOVERY_BUNDLE,
|
||||
"DeleteRecoveryBundle",
|
||||
recovery::handle_delete_recovery_bundle,
|
||||
);
|
||||
|
||||
// Account (950)
|
||||
reg.register(
|
||||
method_ids::DELETE_ACCOUNT,
|
||||
"DeleteAccount",
|
||||
account::handle_delete_account,
|
||||
);
|
||||
|
||||
reg
|
||||
}
|
||||
199
crates/quicprochat-server/src/v2_handlers/moderation.rs
Normal file
199
crates/quicprochat-server/src/v2_handlers/moderation.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Moderation handlers — report, ban, unban, list reports, list banned.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::error::RpcStatus;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{require_auth, BanRecord, ModerationReport, ServerState};
|
||||
|
||||
/// Submit an encrypted report. Any authenticated user can report.
|
||||
pub async fn handle_report_message(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::ReportMessageRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.encrypted_report.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "encrypted_report required");
|
||||
}
|
||||
|
||||
let now = crate::auth::current_timestamp();
|
||||
let report = {
|
||||
let mut reports = match state.moderation_reports.lock() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("moderation_reports lock poisoned: {e}");
|
||||
return HandlerResult::err(RpcStatus::Internal, "internal error");
|
||||
}
|
||||
};
|
||||
let id = reports.len() as u64;
|
||||
let report = ModerationReport {
|
||||
id,
|
||||
encrypted_report: req.encrypted_report,
|
||||
conversation_id: req.conversation_id,
|
||||
reporter_identity: identity_key.clone(),
|
||||
timestamp: now,
|
||||
};
|
||||
reports.push(report.clone());
|
||||
report
|
||||
};
|
||||
|
||||
info!(
|
||||
report_id = report.id,
|
||||
reporter = hex::encode(&identity_key[..4.min(identity_key.len())]),
|
||||
"moderation report submitted"
|
||||
);
|
||||
|
||||
let proto = v1::ReportMessageResponse { accepted: true };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// Ban a user. Requires admin role (currently: any authenticated user for MVP).
|
||||
pub async fn handle_ban_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let admin_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::BanUserRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.identity_key.is_empty() || req.identity_key.len() != 32 {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "identity_key must be 32 bytes");
|
||||
}
|
||||
|
||||
let now = crate::auth::current_timestamp();
|
||||
let expires_at = if req.duration_secs == 0 {
|
||||
0 // permanent
|
||||
} else {
|
||||
now + req.duration_secs
|
||||
};
|
||||
|
||||
let record = BanRecord {
|
||||
reason: req.reason.clone(),
|
||||
banned_at: now,
|
||||
expires_at,
|
||||
};
|
||||
state.banned_users.insert(req.identity_key.clone(), record);
|
||||
|
||||
info!(
|
||||
target_key = hex::encode(&req.identity_key[..4]),
|
||||
admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]),
|
||||
reason = %req.reason,
|
||||
duration_secs = req.duration_secs,
|
||||
"user banned"
|
||||
);
|
||||
|
||||
let proto = v1::BanUserResponse { success: true };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// Unban a user. Requires admin role.
|
||||
pub async fn handle_unban_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let admin_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::UnbanUserRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
if req.identity_key.is_empty() {
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "identity_key required");
|
||||
}
|
||||
|
||||
let removed = state.banned_users.remove(&req.identity_key).is_some();
|
||||
|
||||
info!(
|
||||
target_key = hex::encode(&req.identity_key[..4.min(req.identity_key.len())]),
|
||||
admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]),
|
||||
removed,
|
||||
"user unbanned"
|
||||
);
|
||||
|
||||
let proto = v1::UnbanUserResponse { success: removed };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// List moderation reports. Requires admin role.
|
||||
pub async fn handle_list_reports(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let _admin_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::ListReportsRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let reports = match state.moderation_reports.lock() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("moderation_reports lock poisoned: {e}");
|
||||
return HandlerResult::err(RpcStatus::Internal, "internal error");
|
||||
}
|
||||
};
|
||||
|
||||
let offset = req.offset as usize;
|
||||
let limit = if req.limit == 0 { 50 } else { req.limit as usize };
|
||||
|
||||
let entries: Vec<v1::ReportEntry> = reports
|
||||
.iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|r| v1::ReportEntry {
|
||||
id: r.id,
|
||||
encrypted_report: r.encrypted_report.clone(),
|
||||
conversation_id: r.conversation_id.clone(),
|
||||
reporter_identity: r.reporter_identity.clone(),
|
||||
timestamp: r.timestamp,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let proto = v1::ListReportsResponse { reports: entries };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
|
||||
/// List banned users.
|
||||
pub async fn handle_list_banned(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let _admin_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let _req = match v1::ListBannedRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
};
|
||||
|
||||
let now = crate::auth::current_timestamp();
|
||||
let entries: Vec<v1::BannedUserEntry> = state
|
||||
.banned_users
|
||||
.iter()
|
||||
.filter(|entry| entry.expires_at == 0 || entry.expires_at > now)
|
||||
.map(|entry| v1::BannedUserEntry {
|
||||
identity_key: entry.key().clone(),
|
||||
reason: entry.reason.clone(),
|
||||
banned_at: entry.banned_at,
|
||||
expires_at: entry.expires_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let proto = v1::ListBannedResponse { users: entries };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
118
crates/quicprochat-server/src/v2_handlers/p2p.rs
Normal file
118
crates/quicprochat-server/src/v2_handlers/p2p.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! P2P handlers — publish/resolve endpoints and health check.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::p2p::P2pService;
|
||||
use crate::domain::types::{CallerAuth, PublishEndpointReq, ResolveEndpointReq};
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
fn caller_auth(identity_key: Vec<u8>) -> CallerAuth {
|
||||
CallerAuth {
|
||||
identity_key,
|
||||
token: Vec::new(),
|
||||
device_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_publish_endpoint(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::PublishEndpointRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = P2pService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = PublishEndpointReq {
|
||||
identity_key: req.identity_key,
|
||||
node_addr: req.node_addr,
|
||||
};
|
||||
|
||||
match svc.publish_endpoint(domain_req, &auth) {
|
||||
Ok(()) => {
|
||||
let proto = v1::PublishEndpointResponse {};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_resolve_endpoint(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::ResolveEndpointRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = P2pService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
let auth = caller_auth(identity_key);
|
||||
|
||||
let domain_req = ResolveEndpointReq {
|
||||
identity_key: req.identity_key,
|
||||
};
|
||||
|
||||
match svc.resolve_endpoint(domain_req, &auth) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::ResolveEndpointResponse {
|
||||
node_addr: resp.node_addr,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_health(
|
||||
state: Arc<ServerState>,
|
||||
_ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let status = if state.draining.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
"draining"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
let uptime = state.start_time.elapsed().as_secs();
|
||||
let resp = v1::HealthResponse {
|
||||
status: status.into(),
|
||||
node_id: state.node_id.clone(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
uptime_secs: uptime,
|
||||
storage_backend: state.storage_backend.clone(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(resp.encode_to_vec()))
|
||||
}
|
||||
99
crates/quicprochat-server/src/v2_handlers/recovery.rs
Normal file
99
crates/quicprochat-server/src/v2_handlers/recovery.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! Recovery handlers — store/fetch/delete encrypted recovery bundles.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::recovery::RecoveryService;
|
||||
|
||||
use super::{domain_err, ServerState};
|
||||
|
||||
/// Store an encrypted recovery bundle (no auth required — recovery is pre-login).
|
||||
pub async fn handle_store_recovery_bundle(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::StoreRecoveryBundleRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = RecoveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
match svc.store_bundle(&req.token_hash, req.bundle, req.ttl_secs) {
|
||||
Ok(()) => {
|
||||
let proto = v1::StoreRecoveryBundleResponse { success: true };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch an encrypted recovery bundle (no auth required — recovery is pre-login).
|
||||
pub async fn handle_fetch_recovery_bundle(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::FetchRecoveryBundleRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = RecoveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
match svc.fetch_bundle(&req.token_hash) {
|
||||
Ok(bundle_opt) => {
|
||||
let proto = v1::FetchRecoveryBundleResponse {
|
||||
bundle: bundle_opt.unwrap_or_default(),
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an encrypted recovery bundle (no auth required — caller proves
|
||||
/// knowledge of the token_hash).
|
||||
pub async fn handle_delete_recovery_bundle(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let req = match v1::DeleteRecoveryBundleRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = RecoveryService {
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
match svc.delete_bundle(&req.token_hash) {
|
||||
Ok(deleted) => {
|
||||
let proto = v1::DeleteRecoveryBundleResponse { success: deleted };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
213
crates/quicprochat-server/src/v2_handlers/user.rs
Normal file
213
crates/quicprochat-server/src/v2_handlers/user.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! User resolution handlers — username <-> identity key lookups,
|
||||
//! key revocation, and KT audit.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use quicprochat_proto::qpc::v1;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::types::{
|
||||
AuditKeyTransparencyReq, CheckRevocationReq, ResolveIdentityReq, ResolveUserReq, RevokeKeyReq,
|
||||
};
|
||||
use crate::domain::users::UserService;
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
fn user_svc(state: &Arc<ServerState>) -> UserService {
|
||||
UserService {
|
||||
store: Arc::clone(&state.store),
|
||||
kt_log: Arc::clone(&state.kt_log),
|
||||
revocation_log: Arc::clone(&state.revocation_log),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_resolve_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::ResolveUserRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = ResolveUserReq {
|
||||
username: req.username,
|
||||
};
|
||||
|
||||
match svc.resolve_user(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::ResolveUserResponse {
|
||||
identity_key: resp.identity_key,
|
||||
inclusion_proof: resp.inclusion_proof,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_resolve_identity(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::ResolveIdentityRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = ResolveIdentityReq {
|
||||
identity_key: req.identity_key,
|
||||
};
|
||||
|
||||
match svc.resolve_identity(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::ResolveIdentityResponse {
|
||||
username: resp.username,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_revoke_key(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::RevokeKeyRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = RevokeKeyReq {
|
||||
identity_key: req.identity_key,
|
||||
reason: req.reason,
|
||||
};
|
||||
|
||||
match svc.revoke_key(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::RevokeKeyResponse {
|
||||
success: resp.success,
|
||||
leaf_index: resp.leaf_index,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_check_revocation(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::CheckRevocationRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = CheckRevocationReq {
|
||||
identity_key: req.identity_key,
|
||||
};
|
||||
|
||||
match svc.check_revocation(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::CheckRevocationResponse {
|
||||
revoked: resp.revoked,
|
||||
reason: resp.reason,
|
||||
timestamp_ms: resp.timestamp_ms,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_audit_key_transparency(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::AuditKeyTransparencyRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = AuditKeyTransparencyReq {
|
||||
start: req.start,
|
||||
end: req.end,
|
||||
};
|
||||
|
||||
match svc.audit_key_transparency(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::AuditKeyTransparencyResponse {
|
||||
entries: resp
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| v1::LogEntry {
|
||||
index: e.index,
|
||||
leaf_hash: e.leaf_hash,
|
||||
})
|
||||
.collect(),
|
||||
tree_size: resp.tree_size,
|
||||
root: resp.root,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user