commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,501 @@
|
||||
pub mod attachments;
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
pub mod contact_groups;
|
||||
pub mod contacts;
|
||||
pub mod diagnostics;
|
||||
pub mod display_preferences;
|
||||
pub mod drafts;
|
||||
pub mod folder_mgmt;
|
||||
pub mod folder_preferences;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod identities;
|
||||
pub mod mail_preferences;
|
||||
pub mod messages;
|
||||
pub mod notification_preferences;
|
||||
pub mod pgp;
|
||||
pub mod quota;
|
||||
pub mod search;
|
||||
pub mod send;
|
||||
pub mod sieve;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
pub mod unsubscribe;
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit};
|
||||
use axum::http::Request;
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
use axum::{Extension, Router, middleware};
|
||||
use tower_governor::GovernorError;
|
||||
use tower_governor::GovernorLayer;
|
||||
use tower_governor::governor::GovernorConfigBuilder;
|
||||
use tower_governor::key_extractor::KeyExtractor;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::auth::csrf::csrf_protection;
|
||||
use crate::auth::middleware::auth_guard;
|
||||
use crate::auth::session::SessionStore;
|
||||
use crate::config::{AppConfig, TrustedProxy};
|
||||
use crate::imap::client::ImapClient;
|
||||
use crate::realtime::events::EventBus;
|
||||
use crate::realtime::idle::IdleManager;
|
||||
use crate::smtp::client::SmtpClient;
|
||||
|
||||
/// Per-IP key extractor that supports trusted reverse proxies.
|
||||
///
|
||||
/// When the direct peer IP is in the configured `trusted_proxies` list,
|
||||
/// `X-Forwarded-For` is walked from right to left. Trusted proxy hops on the
|
||||
/// right are skipped and the nearest untrusted address becomes the client IP.
|
||||
/// If the header is missing or malformed, the direct peer IP is used. Falls
|
||||
/// back to loopback when `ConnectInfo<SocketAddr>` is unavailable (e.g. in
|
||||
/// unit tests).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProxyAwareIpExtractor {
|
||||
trusted_proxies: Vec<TrustedProxy>,
|
||||
}
|
||||
|
||||
impl ProxyAwareIpExtractor {
|
||||
fn is_trusted(&self, ip: &IpAddr) -> bool {
|
||||
self.trusted_proxies
|
||||
.iter()
|
||||
.any(|trusted| trusted.contains(ip))
|
||||
}
|
||||
|
||||
fn forwarded_client_ip(
|
||||
&self,
|
||||
headers: &axum::http::HeaderMap,
|
||||
peer_ip: IpAddr,
|
||||
) -> Option<IpAddr> {
|
||||
let mut current_hop = peer_ip;
|
||||
|
||||
for value in headers.get_all("x-forwarded-for").iter().rev() {
|
||||
for entry in value.to_str().ok()?.split(',').rev() {
|
||||
if !self.is_trusted(¤t_hop) {
|
||||
return Some(current_hop);
|
||||
}
|
||||
|
||||
current_hop = entry.trim().parse::<IpAddr>().ok()?;
|
||||
}
|
||||
}
|
||||
|
||||
Some(current_hop)
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyExtractor for ProxyAwareIpExtractor {
|
||||
type Key = IpAddr;
|
||||
|
||||
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
|
||||
let peer_ip = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip())
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
|
||||
if self.is_trusted(&peer_ip)
|
||||
&& let Some(client_ip) = self.forwarded_client_ip(req.headers(), peer_ip)
|
||||
{
|
||||
return Ok(client_ip);
|
||||
}
|
||||
|
||||
Ok(peer_ip)
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembles all application routes into an Axum Router.
|
||||
///
|
||||
/// Route layout:
|
||||
/// - `GET /api/health` / `/api/ready` — liveness/readiness (public)
|
||||
/// - `POST /api/auth/login` — login (public, CSRF only)
|
||||
/// - `GET /api/auth/session` — get session (auth_guard + CSRF)
|
||||
/// - `POST /api/auth/logout` — logout (auth_guard + CSRF)
|
||||
/// - `GET /api/folders` — list folders (auth_guard + CSRF)
|
||||
/// - `GET /api/folders/:folder/messages` — list messages (auth_guard + CSRF)
|
||||
/// - `GET /api/messages/:folder/:uid` — get message detail (auth_guard + CSRF)
|
||||
/// - `PATCH /api/messages/:folder/:uid/flags` — update flags (auth_guard + CSRF)
|
||||
/// - `GET /api/messages/:folder/:uid/attachments/:attachment_id` — download attachment (auth_guard + CSRF)
|
||||
/// - `POST /api/messages/move` — move message (auth_guard + CSRF)
|
||||
/// - `DELETE /api/messages/:folder/:uid` — delete message (auth_guard + CSRF)
|
||||
///
|
||||
/// All non-API paths are served by the embedded RustBox UI.
|
||||
/// The UI assets are compiled into the same executable; there is no frontend runtime.
|
||||
///
|
||||
/// Middleware layers:
|
||||
/// - tower-http tracing
|
||||
/// - CSRF protection on auth routes
|
||||
/// - auth_guard on protected routes
|
||||
pub fn create_router(
|
||||
config: Arc<AppConfig>,
|
||||
store: Arc<SessionStore>,
|
||||
imap_client: Arc<dyn ImapClient>,
|
||||
smtp_client: Arc<dyn SmtpClient>,
|
||||
search_engine: Arc<crate::search::engine::SearchEngine>,
|
||||
event_bus: Arc<EventBus>,
|
||||
idle_manager: Arc<IdleManager>,
|
||||
) -> Router {
|
||||
// Rate-limit login: replenish 1 token every 12 s, burst of 5.
|
||||
let governor_conf = GovernorConfigBuilder::default()
|
||||
.key_extractor(ProxyAwareIpExtractor {
|
||||
trusted_proxies: config.parsed_trusted_proxies(),
|
||||
})
|
||||
.period(Duration::from_secs(12))
|
||||
.burst_size(5)
|
||||
.finish()
|
||||
.expect("valid governor config");
|
||||
|
||||
// Public auth route: GovernorLayer (outermost) -> CSRF -> handler.
|
||||
let public_auth = Router::new()
|
||||
.route("/login", post(auth::login))
|
||||
.route("/policy", get(auth::auth_policy))
|
||||
.layer(middleware::from_fn(csrf_protection))
|
||||
.layer(GovernorLayer::new(governor_conf));
|
||||
|
||||
// Browser-bound routes (no auth_guard, no rate limit).
|
||||
// These routes only need the browser cookie, not full auth.
|
||||
let browser_routes = Router::new()
|
||||
.route("/accounts", get(auth::list_accounts))
|
||||
.route(
|
||||
"/accounts/{id}",
|
||||
delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)),
|
||||
);
|
||||
|
||||
// Protected auth routes (auth_guard + CSRF).
|
||||
let protected_auth = Router::new()
|
||||
.route("/session", get(auth::get_session))
|
||||
.route("/sessions", get(auth::list_active_sessions))
|
||||
.route("/sessions/revoke-others", post(auth::revoke_other_sessions))
|
||||
.route("/sessions/{id}", delete(auth::revoke_active_session))
|
||||
.route("/logout", post(auth::logout))
|
||||
.layer(middleware::from_fn(auth_guard))
|
||||
.layer(middleware::from_fn(csrf_protection));
|
||||
|
||||
let auth_router = Router::new()
|
||||
.merge(public_auth)
|
||||
.merge(browser_routes)
|
||||
.merge(protected_auth);
|
||||
|
||||
// Protected data routes (auth_guard + CSRF).
|
||||
let protected_data = Router::new()
|
||||
.route(
|
||||
"/folders",
|
||||
get(folders::list_folders).post(folder_mgmt::create_folder),
|
||||
)
|
||||
.route(
|
||||
"/folders/{name}",
|
||||
patch(folder_mgmt::rename_folder).delete(folder_mgmt::delete_folder),
|
||||
)
|
||||
.route(
|
||||
"/folders/{name}/subscribe",
|
||||
patch(folder_mgmt::subscribe_folder),
|
||||
)
|
||||
.route(
|
||||
"/folders/{name}/mark-all-read",
|
||||
post(folder_mgmt::mark_all_read),
|
||||
)
|
||||
.route("/folders/{name}/empty", post(folder_mgmt::empty_folder))
|
||||
.route(
|
||||
"/folders/{folder}/import-eml",
|
||||
post(messages::import_eml).layer(DefaultBodyLimit::max(52 * 1024 * 1024)),
|
||||
)
|
||||
.route("/folders/{folder}/messages", get(messages::list_messages))
|
||||
.route(
|
||||
"/messages/{folder}/{uid}",
|
||||
get(messages::get_message).delete(messages::delete_message_handler),
|
||||
)
|
||||
.route(
|
||||
"/messages/{folder}/{uid}/flags",
|
||||
patch(messages::update_flags),
|
||||
)
|
||||
.route(
|
||||
"/messages/{folder}/{uid}/attachments/{attachment_id}",
|
||||
get(messages::download_attachment),
|
||||
)
|
||||
.route(
|
||||
"/messages/{folder}/{uid}/source",
|
||||
get(messages::download_eml_source),
|
||||
)
|
||||
.route("/messages/move", post(messages::move_message_handler))
|
||||
.route("/messages/copy", post(messages::copy_message_handler))
|
||||
.route("/messages/send", post(send::send_message_handler))
|
||||
.route(
|
||||
"/messages/{folder}/{uid}/unsubscribe",
|
||||
post(unsubscribe::unsubscribe_message),
|
||||
)
|
||||
.route(
|
||||
"/drafts",
|
||||
get(drafts::list_drafts_handler).post(drafts::upsert_draft_handler),
|
||||
)
|
||||
.route(
|
||||
"/drafts/{id}",
|
||||
get(drafts::get_draft_handler).delete(drafts::delete_draft_handler),
|
||||
)
|
||||
.route(
|
||||
"/drafts/{draft_id}/attachments",
|
||||
post(attachments::upload_attachment).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/drafts/{draft_id}/attachments/{attachment_id}",
|
||||
delete(attachments::delete_attachment),
|
||||
)
|
||||
.route(
|
||||
"/drafts/{draft_id}/attachments/{attachment_id}/content",
|
||||
get(attachments::get_attachment_content),
|
||||
)
|
||||
.route("/search", get(search::search_messages))
|
||||
.route(
|
||||
"/search/saved",
|
||||
get(search::list_saved_searches).post(search::create_saved_search),
|
||||
)
|
||||
.route(
|
||||
"/search/saved/{id}",
|
||||
axum::routing::put(search::update_saved_search).delete(search::delete_saved_search),
|
||||
)
|
||||
.route(
|
||||
"/contact-groups",
|
||||
get(contact_groups::list_groups_handler).post(contact_groups::create_group_handler),
|
||||
)
|
||||
.route(
|
||||
"/contact-groups/{id}",
|
||||
put(contact_groups::update_group_handler).delete(contact_groups::delete_group_handler),
|
||||
)
|
||||
.route(
|
||||
"/contact-groups/{id}/members",
|
||||
get(contact_groups::list_members_handler).post(contact_groups::add_member_handler),
|
||||
)
|
||||
.route(
|
||||
"/contact-groups/{id}/members/{contact_id}",
|
||||
delete(contact_groups::remove_member_handler),
|
||||
)
|
||||
.route(
|
||||
"/tags",
|
||||
get(tags::list_tags_handler).post(tags::create_tag_handler),
|
||||
)
|
||||
.route("/tags/reconcile", post(tags::reconcile_tags_handler))
|
||||
.route(
|
||||
"/tags/{id}",
|
||||
put(tags::update_tag_handler).delete(tags::delete_tag_handler),
|
||||
)
|
||||
.route(
|
||||
"/tags/{id}/messages",
|
||||
post(tags::tag_message_handler).get(tags::list_tag_messages_handler),
|
||||
)
|
||||
.route("/tags/{id}/messages/bulk", post(tags::bulk_tag_handler))
|
||||
.route(
|
||||
"/tags/{id}/messages/{folder}/{uid}",
|
||||
delete(tags::untag_message_handler),
|
||||
)
|
||||
.route(
|
||||
"/messages/{folder}/{uid}/tags",
|
||||
get(tags::get_message_tags_handler),
|
||||
)
|
||||
.route(
|
||||
"/contacts",
|
||||
get(contacts::list_contacts_handler).post(contacts::create_contact_handler),
|
||||
)
|
||||
.route(
|
||||
"/contacts/{id}/photo",
|
||||
get(contacts::get_contact_photo_handler)
|
||||
.post(contacts::upload_contact_photo_handler)
|
||||
.delete(contacts::delete_contact_photo_handler)
|
||||
.layer(DefaultBodyLimit::max(3 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/contacts/duplicates",
|
||||
get(contacts::duplicate_contacts_handler),
|
||||
)
|
||||
.route("/contacts/merge", post(contacts::merge_contacts_handler))
|
||||
.route("/contacts/export", get(contacts::export_contacts_handler))
|
||||
.route(
|
||||
"/contacts/import",
|
||||
post(contacts::import_contacts_handler).layer(DefaultBodyLimit::max(11 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/contacts/autocomplete/all",
|
||||
get(contacts::autocomplete_all_handler),
|
||||
)
|
||||
.route(
|
||||
"/contacts/autocomplete",
|
||||
get(contacts::autocomplete_handler),
|
||||
)
|
||||
.route(
|
||||
"/contacts/{id}",
|
||||
get(contacts::get_contact_handler).delete(contacts::delete_contact_handler),
|
||||
)
|
||||
.route(
|
||||
"/contacts/{contact_id}/groups",
|
||||
get(contact_groups::list_contact_groups_handler),
|
||||
)
|
||||
.route(
|
||||
"/contacts/{id}/export",
|
||||
get(contacts::export_single_contact_handler),
|
||||
)
|
||||
.route(
|
||||
"/identities",
|
||||
get(identities::list_identities_handler)
|
||||
.post(identities::create_identity_handler)
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/identities/{id}",
|
||||
get(identities::get_identity_handler)
|
||||
.put(identities::update_identity_handler)
|
||||
.delete(identities::delete_identity_handler)
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/pgp/keys",
|
||||
get(pgp::list_keys)
|
||||
.post(pgp::import_key)
|
||||
.layer(DefaultBodyLimit::max(3 * 1024 * 1024)),
|
||||
)
|
||||
.route("/pgp/keys/generate", post(pgp::generate_key))
|
||||
.route("/pgp/keys/{fingerprint}/export", get(pgp::export_key))
|
||||
.route(
|
||||
"/pgp/keys/{fingerprint}/test-passphrase",
|
||||
post(pgp::test_passphrase),
|
||||
)
|
||||
.route(
|
||||
"/pgp/keys/{fingerprint}/change-passphrase",
|
||||
post(pgp::change_passphrase),
|
||||
)
|
||||
.route("/pgp/keys/{fingerprint}/trust", patch(pgp::set_trust))
|
||||
.route("/pgp/keys/{fingerprint}", delete(pgp::delete_key))
|
||||
.route(
|
||||
"/pgp/preferences",
|
||||
get(pgp::get_preferences).put(pgp::update_preferences),
|
||||
)
|
||||
.route("/pgp/discover", post(pgp::discover_key))
|
||||
.route("/pgp/recipients", post(pgp::recipient_status))
|
||||
.route("/pgp/verify", post(pgp::verify))
|
||||
.route(
|
||||
"/pgp/decrypt",
|
||||
post(pgp::decrypt).layer(DefaultBodyLimit::max(12 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/pgp/messages/{folder}/{uid}/process",
|
||||
post(pgp::process_message),
|
||||
)
|
||||
.route(
|
||||
"/settings/display",
|
||||
get(display_preferences::get_display_preferences)
|
||||
.put(display_preferences::update_display_preferences),
|
||||
)
|
||||
.route(
|
||||
"/settings/special-folders",
|
||||
get(special_folders::get_special_folders).put(special_folders::update_special_folders),
|
||||
)
|
||||
.route(
|
||||
"/settings/notifications",
|
||||
get(notification_preferences::get_notification_preferences)
|
||||
.put(notification_preferences::update_notification_preferences),
|
||||
)
|
||||
.route(
|
||||
"/settings/mailbox",
|
||||
get(mail_preferences::get_mail_preferences)
|
||||
.put(mail_preferences::update_mail_preferences),
|
||||
)
|
||||
.route(
|
||||
"/folders/{folder}/preferences",
|
||||
get(folder_preferences::get_folder_preferences)
|
||||
.put(folder_preferences::update_folder_preferences),
|
||||
)
|
||||
.route(
|
||||
"/calendar/events",
|
||||
get(calendar::list_events).post(calendar::create_event),
|
||||
)
|
||||
.route(
|
||||
"/calendar/events/import-ics",
|
||||
post(calendar::import_ics).layer(DefaultBodyLimit::max(11 * 1024 * 1024)),
|
||||
)
|
||||
.route("/calendar/events/export-ics", get(calendar::export_ics))
|
||||
.route(
|
||||
"/calendar/events/{id}",
|
||||
get(calendar::get_event)
|
||||
.put(calendar::update_event)
|
||||
.delete(calendar::delete_event),
|
||||
)
|
||||
.route(
|
||||
"/calendar/settings",
|
||||
get(calendar::get_calendar_settings).put(calendar::update_calendar_settings),
|
||||
)
|
||||
.route(
|
||||
"/calendar/meeting-templates",
|
||||
get(calendar::list_meeting_templates).post(calendar::create_meeting_template),
|
||||
)
|
||||
.route(
|
||||
"/calendar/meeting-templates/{id}",
|
||||
delete(calendar::delete_meeting_template),
|
||||
)
|
||||
.route("/quota", get(quota::get_quota))
|
||||
.route("/diagnostics/imap", get(diagnostics::imap_diagnostics))
|
||||
.route("/diagnostics/smtp", get(diagnostics::smtp_diagnostics))
|
||||
.route("/diagnostics/sync", get(diagnostics::sync_diagnostics))
|
||||
.route(
|
||||
"/diagnostics/imap/reconnect",
|
||||
post(diagnostics::reconnect_imap),
|
||||
)
|
||||
.route("/diagnostics/search", get(diagnostics::search_diagnostics))
|
||||
.route(
|
||||
"/diagnostics/search/rebuild",
|
||||
post(diagnostics::rebuild_search_index),
|
||||
)
|
||||
.route("/sieve/status", get(sieve::status))
|
||||
.route("/sieve/scripts", get(sieve::list_scripts))
|
||||
.route(
|
||||
"/sieve/scripts/{name}",
|
||||
get(sieve::get_script)
|
||||
.put(sieve::put_script)
|
||||
.delete(sieve::delete_script),
|
||||
)
|
||||
.route(
|
||||
"/sieve/scripts/{name}/activate",
|
||||
post(sieve::activate_script),
|
||||
)
|
||||
.route("/sieve/scripts/{name}/rename", post(sieve::rename_script))
|
||||
.route("/sieve/deactivate", post(sieve::deactivate_script))
|
||||
.route("/sieve/check", post(sieve::check_script))
|
||||
.layer(middleware::from_fn(auth_guard))
|
||||
.layer(middleware::from_fn(csrf_protection));
|
||||
|
||||
// WebSocket route — auth is handled inside the handler via cookie,
|
||||
// so it bypasses CSRF and auth_guard middleware.
|
||||
let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler));
|
||||
|
||||
let api_router = Router::new()
|
||||
.route("/health", get(health::health_check))
|
||||
.route("/ready", get(health::readiness_check))
|
||||
.nest("/auth", auth_router)
|
||||
.merge(ws_route)
|
||||
.merge(protected_data);
|
||||
|
||||
// RustBox serves API and UI from the same Rust process.
|
||||
let inner = Router::new()
|
||||
.nest("/api", api_router)
|
||||
.fallback(crate::ui::serve_ui);
|
||||
|
||||
// Use the same base path for routing and authentication cookies.
|
||||
let router = match config.normalized_base_path() {
|
||||
"/" => inner,
|
||||
base_path => Router::new().nest(base_path, inner),
|
||||
};
|
||||
|
||||
let router = router
|
||||
.layer(Extension(idle_manager))
|
||||
.layer(Extension(event_bus))
|
||||
.layer(Extension(smtp_client))
|
||||
.layer(Extension(search_engine))
|
||||
.layer(Extension(imap_client))
|
||||
.layer(Extension(store))
|
||||
.layer(Extension(config.clone()))
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user