Initial commit
This commit is contained in:
5
backend/.cargo/config.toml
Executable file
5
backend/.cargo/config.toml
Executable file
@@ -0,0 +1,5 @@
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[build]
|
||||
rustflags = ["-C", "linker=clang"]
|
||||
9
backend/.dockerignore
Executable file
9
backend/.dockerignore
Executable file
@@ -0,0 +1,9 @@
|
||||
target
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
*.log
|
||||
Cargo.lock
|
||||
88
backend/Cargo.toml
Executable file
88
backend/Cargo.toml
Executable file
@@ -0,0 +1,88 @@
|
||||
[package]
|
||||
name = "sap-sync-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
rouille = "3.6"
|
||||
|
||||
# Database
|
||||
postgres = { version = "0.19", features = ["with-chrono-0_4"] }
|
||||
r2d2 = "0.8"
|
||||
r2d2_postgres = "0.18"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Security
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
# Date/Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.10"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.16", features = ["derive"] }
|
||||
|
||||
# API Documentation
|
||||
utoipa = { version = "4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "5.0", features = ["axum"] }
|
||||
|
||||
# Metrics
|
||||
prometheus = "0.13"
|
||||
|
||||
# Rate Limiting
|
||||
tower_governor = "0.4"
|
||||
|
||||
# Config
|
||||
dotenvy = "0.15"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
|
||||
# Cryptography
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
|
||||
# XML parsing
|
||||
quick-xml = "0.31"
|
||||
|
||||
# URL encoding
|
||||
urlencoding = "2.1"
|
||||
|
||||
async-stream = "0.3"
|
||||
|
||||
# MFA
|
||||
totp-lite = "2.0"
|
||||
|
||||
# Exports
|
||||
csv = "1.3"
|
||||
rust_xlsxwriter = "0.64"
|
||||
printpdf = "0.5"
|
||||
axum = "0.8.8"
|
||||
hex = "0.4.3"
|
||||
ureq = "3.3.0"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
mockall = "0.12"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
47
backend/Dockerfile
Executable file
47
backend/Dockerfile
Executable file
@@ -0,0 +1,47 @@
|
||||
# Build stage
|
||||
FROM rust:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY Cargo.toml ./
|
||||
|
||||
RUN mkdir src && \
|
||||
echo "fn main() {}" > src/main.rs && \
|
||||
cargo fetch
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libpq5 \
|
||||
libssl3 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/sap-sync-backend /app/sap-sync-backend
|
||||
|
||||
RUN mkdir -p /app/logs && chmod +x /app/sap-sync-backend
|
||||
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:3001/api/health || exit 1
|
||||
|
||||
CMD ["./sap-sync-backend"]
|
||||
56
backend/src/alert_system.rs
Executable file
56
backend/src/alert_system.rs
Executable file
@@ -0,0 +1,56 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlertThreshold {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub subscription_id: Option<i32>,
|
||||
pub metric_type: String,
|
||||
pub threshold_value: f64,
|
||||
pub comparison_operator: String,
|
||||
pub action: String,
|
||||
pub notification_channels: Vec<String>,
|
||||
pub is_active: bool,
|
||||
pub last_triggered: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlertThresholdCreate {
|
||||
pub name: String,
|
||||
pub subscription_id: Option<i32>,
|
||||
pub metric_type: String,
|
||||
pub threshold_value: f64,
|
||||
pub comparison_operator: String,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub notification_channels: Vec<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlertThresholdUpdate {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub threshold_value: Option<f64>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AlertHistoryItem {
|
||||
pub id: i32,
|
||||
pub threshold_id: i32,
|
||||
pub threshold_name: String,
|
||||
pub actual_value: f64,
|
||||
pub triggered_at: String,
|
||||
pub action_taken: Option<String>,
|
||||
pub notification_sent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AlertCheckResult {
|
||||
pub threshold_id: i32,
|
||||
pub threshold_name: String,
|
||||
pub actual_value: f64,
|
||||
pub triggered: bool,
|
||||
pub action_taken: Option<String>,
|
||||
}
|
||||
56
backend/src/audit.rs
Executable file
56
backend/src/audit.rs
Executable file
@@ -0,0 +1,56 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuditLogRequest {
|
||||
#[serde(default)]
|
||||
pub from: Option<String>,
|
||||
#[serde(default)]
|
||||
pub to: Option<String>,
|
||||
#[serde(default)]
|
||||
pub event_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_id: Option<i32>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
100
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionAuditLog {
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
pub username: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub event: String,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SyncAuditLog {
|
||||
pub id: i32,
|
||||
pub sync_job_id: i32,
|
||||
pub entity_type: String,
|
||||
pub entity_id: String,
|
||||
pub action: String,
|
||||
pub status: String,
|
||||
pub error_message: Option<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub resolution_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuditSummary {
|
||||
pub total_events: i64,
|
||||
pub login_events: i64,
|
||||
pub logout_events: i64,
|
||||
pub sync_events: i64,
|
||||
pub error_events: i64,
|
||||
}
|
||||
51
backend/src/auth.rs
Executable file
51
backend/src/auth.rs
Executable file
@@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasswordChangeForm {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MfaSetupResponse {
|
||||
pub secret: String,
|
||||
pub qr_code_url: String,
|
||||
pub backup_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MfaVerifyResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub user: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MfaVerifyRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EmailOtpRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EmailOtpResponse {
|
||||
pub message: String,
|
||||
}
|
||||
6
backend/src/billing_id.rs
Executable file
6
backend/src/billing_id.rs
Executable file
@@ -0,0 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BillingRecordId {
|
||||
pub id: i32,
|
||||
}
|
||||
56
backend/src/billing_system.rs
Executable file
56
backend/src/billing_system.rs
Executable file
@@ -0,0 +1,56 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PricingConfig {
|
||||
pub id: i32,
|
||||
pub metric_type: String,
|
||||
pub unit: String,
|
||||
pub price_per_unit: f64,
|
||||
pub currency: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PricingConfigCreate {
|
||||
pub metric_type: String,
|
||||
pub unit: String,
|
||||
pub price_per_unit: f64,
|
||||
pub currency: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Invoice {
|
||||
pub id: i32,
|
||||
pub customer_id: i32,
|
||||
pub subscription_id: i32,
|
||||
pub period_start: String,
|
||||
pub period_end: String,
|
||||
pub line_items: Vec<LineItem>,
|
||||
pub subtotal: f64,
|
||||
pub tax: f64,
|
||||
pub total: f64,
|
||||
pub currency: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LineItem {
|
||||
pub description: String,
|
||||
pub quantity: f64,
|
||||
pub unit: String,
|
||||
pub rate: f64,
|
||||
pub amount: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InvoicePreview {
|
||||
pub customer_name: String,
|
||||
pub period_start: String,
|
||||
pub period_end: String,
|
||||
pub line_items: Vec<LineItem>,
|
||||
pub subtotal: f64,
|
||||
pub tax: f64,
|
||||
pub total: f64,
|
||||
pub currency: String,
|
||||
}
|
||||
159
backend/src/config.rs
Executable file
159
backend/src/config.rs
Executable file
@@ -0,0 +1,159 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_database_url")]
|
||||
pub database_url: String,
|
||||
|
||||
#[serde(default = "default_port")]
|
||||
pub server_port: u16,
|
||||
|
||||
#[serde(default)]
|
||||
pub session_secure: bool,
|
||||
|
||||
#[serde(default = "default_admin_username")]
|
||||
pub admin_username: String,
|
||||
|
||||
#[serde(default = "default_admin_email")]
|
||||
pub admin_email: String,
|
||||
|
||||
#[serde(default = "default_admin_password")]
|
||||
pub admin_password: String,
|
||||
|
||||
#[serde(default = "default_mfa_enabled")]
|
||||
pub mfa_enabled: bool,
|
||||
|
||||
#[serde(default = "default_mfa_service_name")]
|
||||
pub mfa_service_name: String,
|
||||
|
||||
#[serde(default = "default_sync_interval")]
|
||||
pub sync_interval_secs: u64,
|
||||
|
||||
#[serde(default = "default_sync_direction")]
|
||||
pub default_sync_direction: String,
|
||||
|
||||
#[serde(default = "default_conflict_resolution")]
|
||||
pub conflict_resolution: String,
|
||||
|
||||
#[serde(default = "default_max_workers")]
|
||||
pub max_workers: usize,
|
||||
|
||||
#[serde(default = "default_smtp_host")]
|
||||
pub smtp_host: String,
|
||||
|
||||
#[serde(default = "default_smtp_port")]
|
||||
pub smtp_port: u16,
|
||||
|
||||
#[serde(default)]
|
||||
pub smtp_username: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub smtp_password: String,
|
||||
|
||||
#[serde(default = "default_smtp_from")]
|
||||
pub smtp_from: String,
|
||||
}
|
||||
|
||||
fn default_database_url() -> String {
|
||||
"postgresql://sap_user:papsync123@localhost:5432/sap_sync".to_string()
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
3001
|
||||
}
|
||||
|
||||
fn default_admin_username() -> String {
|
||||
"admin".to_string()
|
||||
}
|
||||
|
||||
fn default_admin_email() -> String {
|
||||
"admin@sap-sync.local".to_string()
|
||||
}
|
||||
|
||||
fn default_admin_password() -> String {
|
||||
"Admin123!".to_string()
|
||||
}
|
||||
|
||||
fn default_mfa_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_mfa_service_name() -> String {
|
||||
"SAP Sync".to_string()
|
||||
}
|
||||
|
||||
fn default_sync_interval() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
fn default_sync_direction() -> String {
|
||||
"sap_to_plesk".to_string()
|
||||
}
|
||||
|
||||
fn default_conflict_resolution() -> String {
|
||||
"timestamp_based".to_string()
|
||||
}
|
||||
|
||||
fn default_max_workers() -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
fn default_smtp_host() -> String {
|
||||
"localhost".to_string()
|
||||
}
|
||||
|
||||
fn default_smtp_port() -> u16 {
|
||||
1025
|
||||
}
|
||||
|
||||
fn default_smtp_from() -> String {
|
||||
"noreply@sap-sync.local".to_string()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
Config {
|
||||
database_url: std::env::var("DATABASE_URL").unwrap_or_else(|_| default_database_url()),
|
||||
server_port: std::env::var("APP__SERVER__PORT")
|
||||
.or_else(|_| std::env::var("PORT"))
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()
|
||||
.unwrap_or(default_port()),
|
||||
session_secure: std::env::var("APP__SESSION__SECURE")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false),
|
||||
admin_username: std::env::var("ADMIN_USERNAME")
|
||||
.unwrap_or_else(|_| default_admin_username()),
|
||||
admin_email: std::env::var("ADMIN_EMAIL").unwrap_or_else(|_| default_admin_email()),
|
||||
admin_password: std::env::var("ADMIN_PASSWORD")
|
||||
.unwrap_or_else(|_| default_admin_password()),
|
||||
mfa_enabled: std::env::var("APP__MFA__ENABLED")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(default_mfa_enabled()),
|
||||
mfa_service_name: std::env::var("APP__MFA__QR_CODE_SERVICE_NAME")
|
||||
.unwrap_or_else(|_| default_mfa_service_name()),
|
||||
sync_interval_secs: std::env::var("APP__SYNC__DEFAULT_INTERVAL_SECONDS")
|
||||
.unwrap_or_else(|_| "3600".to_string())
|
||||
.parse()
|
||||
.unwrap_or(default_sync_interval()),
|
||||
default_sync_direction: std::env::var("APP__SYNC__DEFAULT_DIRECTION")
|
||||
.unwrap_or_else(|_| default_sync_direction()),
|
||||
conflict_resolution: std::env::var("APP__SYNC__CONFLICT_RESOLUTION")
|
||||
.unwrap_or_else(|_| default_conflict_resolution()),
|
||||
max_workers: std::env::var("APP__SYNC__MAX_WORKERS")
|
||||
.unwrap_or_else(|_| "4".to_string())
|
||||
.parse()
|
||||
.unwrap_or(default_max_workers()),
|
||||
smtp_host: std::env::var("SMTP_HOST").unwrap_or_else(|_| default_smtp_host()),
|
||||
smtp_port: std::env::var("SMTP_PORT")
|
||||
.unwrap_or_else(|_| "1025".to_string())
|
||||
.parse()
|
||||
.unwrap_or(default_smtp_port()),
|
||||
smtp_username: std::env::var("SMTP_USERNAME").unwrap_or_default(),
|
||||
smtp_password: std::env::var("SMTP_PASSWORD").unwrap_or_default(),
|
||||
smtp_from: std::env::var("SMTP_FROM").unwrap_or_else(|_| default_smtp_from()),
|
||||
}
|
||||
}
|
||||
}
|
||||
314
backend/src/errors.rs
Executable file
314
backend/src/errors.rs
Executable file
@@ -0,0 +1,314 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
/// Plesk-specific error types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
|
||||
pub enum PleskError {
|
||||
#[error("Connection to {host} failed: {reason}")]
|
||||
ConnectionFailed { host: String, reason: String },
|
||||
|
||||
#[error("Authentication failed: {reason}")]
|
||||
AuthenticationFailed { reason: String },
|
||||
|
||||
#[error("Two-factor authentication required")]
|
||||
TwoFactorRequired { session_id: String, method: String },
|
||||
|
||||
#[error("Plesk API error (code {code}): {message}")]
|
||||
ApiError { code: i32, message: String },
|
||||
|
||||
#[error("Connection timed out after {duration_ms}ms")]
|
||||
Timeout { duration_ms: u64 },
|
||||
|
||||
#[error("Invalid configuration: {field} - {message}")]
|
||||
InvalidConfig { field: String, message: String },
|
||||
|
||||
#[error("SSL certificate error: {reason}")]
|
||||
SslError { reason: String },
|
||||
}
|
||||
|
||||
/// SAP-specific error types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
|
||||
pub enum SapError {
|
||||
#[error("Connection to {host} failed: {reason}")]
|
||||
ConnectionFailed { host: String, reason: String },
|
||||
|
||||
#[error("Authentication failed: {reason}")]
|
||||
AuthenticationFailed { reason: String },
|
||||
|
||||
#[error("Session expired")]
|
||||
SessionExpired,
|
||||
|
||||
#[error("SAP API error (code {code}): {message}")]
|
||||
ApiError { code: i32, message: String },
|
||||
|
||||
#[error("Invalid response from SAP: {raw}")]
|
||||
InvalidResponse { raw: String },
|
||||
|
||||
#[error("Connection timed out after {duration_ms}ms")]
|
||||
Timeout { duration_ms: u64 },
|
||||
|
||||
#[error("Invalid configuration: {field} - {message}")]
|
||||
InvalidConfig { field: String, message: String },
|
||||
|
||||
#[error("SSL certificate error: {reason}")]
|
||||
SslError { reason: String },
|
||||
}
|
||||
|
||||
/// Connection test result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionTestResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub error: Option<ConnectionError>,
|
||||
pub requires_2fa: bool,
|
||||
pub session_id: Option<String>,
|
||||
pub two_factor_method: Option<String>,
|
||||
}
|
||||
|
||||
/// Unified connection error for API responses
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionError {
|
||||
pub error_type: String,
|
||||
pub error_code: String,
|
||||
pub message: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
impl From<PleskError> for ConnectionError {
|
||||
fn from(err: PleskError) -> Self {
|
||||
let (error_type, error_code, message) = match &err {
|
||||
PleskError::ConnectionFailed { .. } => {
|
||||
("connection", "PLESK_CONN_001", err.to_string())
|
||||
}
|
||||
PleskError::AuthenticationFailed { .. } => {
|
||||
("authentication", "PLESK_AUTH_001", err.to_string())
|
||||
}
|
||||
PleskError::TwoFactorRequired { .. } => {
|
||||
("two_factor", "PLESK_2FA_001", err.to_string())
|
||||
}
|
||||
PleskError::ApiError { .. } => ("api", "PLESK_API_001", err.to_string()),
|
||||
PleskError::Timeout { .. } => ("timeout", "PLESK_TIMEOUT_001", err.to_string()),
|
||||
PleskError::InvalidConfig { .. } => ("validation", "PLESK_VAL_001", err.to_string()),
|
||||
PleskError::SslError { .. } => ("ssl", "PLESK_SSL_001", err.to_string()),
|
||||
};
|
||||
ConnectionError {
|
||||
error_type: error_type.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
message,
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SapError> for ConnectionError {
|
||||
fn from(err: SapError) -> Self {
|
||||
let (error_type, error_code, message) = match &err {
|
||||
SapError::ConnectionFailed { .. } => ("connection", "SAP_CONN_001", err.to_string()),
|
||||
SapError::AuthenticationFailed { .. } => {
|
||||
("authentication", "SAP_AUTH_001", err.to_string())
|
||||
}
|
||||
SapError::SessionExpired => ("session", "SAP_SESSION_001", err.to_string()),
|
||||
SapError::ApiError { .. } => ("api", "SAP_API_001", err.to_string()),
|
||||
SapError::InvalidResponse { .. } => ("response", "SAP_RESP_001", err.to_string()),
|
||||
SapError::Timeout { .. } => ("timeout", "SAP_TIMEOUT_001", err.to_string()),
|
||||
SapError::InvalidConfig { .. } => ("validation", "SAP_VAL_001", err.to_string()),
|
||||
SapError::SslError { .. } => ("ssl", "SAP_SSL_001", err.to_string()),
|
||||
};
|
||||
ConnectionError {
|
||||
error_type: error_type.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
message,
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensive error types for the API
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("Authentication error: {0}")]
|
||||
Authentication(String),
|
||||
|
||||
#[error("Authorization error: {0}")]
|
||||
Authorization(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("SAP API error: {0}")]
|
||||
SapApi(String),
|
||||
|
||||
#[error("Plesk API error: {0}")]
|
||||
PleskApi(String),
|
||||
|
||||
#[error("Sync error: {0}")]
|
||||
Sync(String),
|
||||
|
||||
#[error("Internal server error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
ApiError::Authentication(_) => StatusCode::UNAUTHORIZED,
|
||||
ApiError::Authorization(_) => StatusCode::FORBIDDEN,
|
||||
ApiError::Validation(_) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::SapApi(_) => StatusCode::BAD_GATEWAY,
|
||||
ApiError::PleskApi(_) => StatusCode::BAD_GATEWAY,
|
||||
ApiError::Sync(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
let error_code = self.error_code();
|
||||
let error_message = self.to_string();
|
||||
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"error": error_message,
|
||||
"error_code": error_code,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Get the error code for this error
|
||||
pub fn error_code(&self) -> String {
|
||||
match self {
|
||||
ApiError::Authentication(_) => "AUTH_001".to_string(),
|
||||
ApiError::Authorization(_) => "AUTH_002".to_string(),
|
||||
ApiError::Validation(_) => "VAL_001".to_string(),
|
||||
ApiError::Database(_) => "DB_001".to_string(),
|
||||
ApiError::SapApi(_) => "SAP_001".to_string(),
|
||||
ApiError::PleskApi(_) => "PLESK_001".to_string(),
|
||||
ApiError::Sync(_) => "SYNC_001".to_string(),
|
||||
ApiError::Internal(_) => "INT_001".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a client error (4xx)
|
||||
pub fn is_client_error(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ApiError::Authentication(_) | ApiError::Authorization(_) | ApiError::Validation(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is a server error (5xx)
|
||||
pub fn is_server_error(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ApiError::Database(_)
|
||||
| ApiError::SapApi(_)
|
||||
| ApiError::PleskApi(_)
|
||||
| ApiError::Sync(_)
|
||||
| ApiError::Internal(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error context for better error tracking
|
||||
#[derive(Debug)]
|
||||
pub struct ErrorContext {
|
||||
pub request_id: String,
|
||||
pub path: String,
|
||||
pub method: String,
|
||||
pub user_id: Option<i32>,
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl ErrorContext {
|
||||
pub fn new(request_id: String, path: String, method: String) -> Self {
|
||||
Self {
|
||||
request_id,
|
||||
path,
|
||||
method,
|
||||
user_id: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_user_id(mut self, user_id: i32) -> Self {
|
||||
self.user_id = Some(user_id);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ErrorContext> for ApiError {
|
||||
fn from(ctx: ErrorContext) -> Self {
|
||||
ApiError::Internal(format!(
|
||||
"Error in {}: {} (request_id: {})",
|
||||
ctx.path, ctx.method, ctx.request_id
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for easier error handling
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_conversion() {
|
||||
let error = ApiError::Authentication("Invalid credentials".to_string());
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_code() {
|
||||
assert_eq!(
|
||||
ApiError::Authentication("test".to_string()).error_code(),
|
||||
"AUTH_001"
|
||||
);
|
||||
assert_eq!(
|
||||
ApiError::Validation("test".to_string()).error_code(),
|
||||
"VAL_001"
|
||||
);
|
||||
assert_eq!(
|
||||
ApiError::Database("test".to_string()).error_code(),
|
||||
"DB_001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_classification() {
|
||||
assert!(ApiError::Authentication("test".to_string()).is_client_error());
|
||||
assert!(ApiError::Validation("test".to_string()).is_client_error());
|
||||
assert!(!ApiError::Authentication("test".to_string()).is_server_error());
|
||||
assert!(ApiError::Database("test".to_string()).is_server_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_context() {
|
||||
let ctx = ErrorContext::new(
|
||||
"req-123".to_string(),
|
||||
"/api/test".to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
assert_eq!(ctx.request_id, "req-123");
|
||||
assert_eq!(ctx.path, "/api/test");
|
||||
assert_eq!(ctx.method, "GET");
|
||||
assert!(ctx.user_id.is_none());
|
||||
|
||||
let ctx_with_user = ctx.with_user_id(42);
|
||||
assert_eq!(ctx_with_user.user_id, Some(42));
|
||||
}
|
||||
}
|
||||
84
backend/src/export.rs
Executable file
84
backend/src/export.rs
Executable file
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExportRequest {
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub include_headers: bool,
|
||||
#[serde(default)]
|
||||
pub file_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExportResult {
|
||||
pub format: String,
|
||||
pub file_name: String,
|
||||
pub size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RevenueReportRequest {
|
||||
#[serde(default)]
|
||||
pub from: Option<String>,
|
||||
#[serde(default)]
|
||||
pub to: Option<String>,
|
||||
#[serde(default)]
|
||||
pub group_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RevenueReport {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub total_revenue: f64,
|
||||
pub total_invoices: i64,
|
||||
pub top_customers: Vec<CustomerRevenue>,
|
||||
pub by_subscription: Vec<SubscriptionRevenue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CustomerRevenue {
|
||||
pub customer_id: i32,
|
||||
pub customer_name: String,
|
||||
pub total_revenue: f64,
|
||||
pub invoice_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SubscriptionRevenue {
|
||||
pub subscription_id: i32,
|
||||
pub subscription_name: String,
|
||||
pub total_revenue: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UsageReportRequest {
|
||||
#[serde(default)]
|
||||
pub from: Option<String>,
|
||||
#[serde(default)]
|
||||
pub to: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subscription_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UsageReport {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub by_subscription: Vec<SubscriptionUsage>,
|
||||
pub by_metric: Vec<MetricUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SubscriptionUsage {
|
||||
pub subscription_id: i32,
|
||||
pub subscription_name: String,
|
||||
pub metrics: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MetricUsage {
|
||||
pub metric_type: String,
|
||||
pub total_value: f64,
|
||||
pub unit: String,
|
||||
}
|
||||
368
backend/src/handlers_sync.rs
Executable file
368
backend/src/handlers_sync.rs
Executable file
@@ -0,0 +1,368 @@
|
||||
use crate::state::AppState;
|
||||
use crate::sync::*;
|
||||
use axum::http::StatusCode;
|
||||
use axum::{extract::State, response::IntoResponse, Json};
|
||||
use postgres::Row;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use crate::errors::ApiError;
|
||||
use tracing::{info, error};
|
||||
|
||||
pub async fn sync_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
info!(request_id = %request_id, "Get sync status");
|
||||
|
||||
let mut conn = match state.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database connection error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// Get stats
|
||||
let stats_result = conn.query_one(
|
||||
"SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'running'::sync_job_status) AS running,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'::sync_job_status AND created_at >= CURRENT_DATE) AS completed_today,
|
||||
COUNT(*) FILTER (WHERE status = 'failed'::sync_job_status AND created_at >= CURRENT_DATE) AS failed_today
|
||||
FROM sync_jobs",
|
||||
&[],
|
||||
);
|
||||
|
||||
let (running, completed_today, failed_today) = match stats_result {
|
||||
Ok(row) => (
|
||||
row.get::<_, i64>(0),
|
||||
row.get::<_, i64>(1),
|
||||
row.get::<_, i64>(2),
|
||||
),
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Stats query failed");
|
||||
(0, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
// Get recent jobs
|
||||
let recent_result = match conn.query(
|
||||
"SELECT id, job_type, sync_direction, status::text, records_processed, records_failed, created_at::text, started_at::text, completed_at::text
|
||||
FROM sync_jobs ORDER BY created_at DESC LIMIT 5",
|
||||
&[],
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let recent_jobs: Vec<_> = recent_result
|
||||
.into_iter()
|
||||
.map(|row| job_to_json(&row))
|
||||
.collect();
|
||||
|
||||
let response = json!({
|
||||
"is_running": running > 0,
|
||||
"current_job": recent_jobs.iter().find(|job| job["status"] == "running").cloned(),
|
||||
"recent_jobs": recent_jobs,
|
||||
"stats": {
|
||||
"running": running,
|
||||
"completed_today": completed_today,
|
||||
"failed_today": failed_today
|
||||
}
|
||||
});
|
||||
|
||||
Ok((StatusCode::OK, Json(response)).into_response())
|
||||
}
|
||||
|
||||
pub async fn sync_jobs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
info!(request_id = %request_id, "Get sync jobs");
|
||||
|
||||
let mut conn = match state.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database connection error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let result = conn.query(
|
||||
"SELECT id, job_type, sync_direction, status::text, records_processed, records_failed, created_at::text, started_at::text, completed_at::text
|
||||
FROM sync_jobs ORDER BY created_at DESC LIMIT 20",
|
||||
&[],
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
let jobs: Vec<_> = rows.into_iter().map(|r| job_to_json(&r)).collect();
|
||||
Ok((StatusCode::OK, Json(json!({ "jobs": jobs }))).into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Database error");
|
||||
Err(ApiError::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_sync(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: SyncStartRequest,
|
||||
) -> impl IntoResponse {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
info!(request_id = %request_id, job_type = %req.job_type, direction = %req.sync_direction, "Start sync");
|
||||
|
||||
let mut conn = match state.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database connection error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let user_id = match &req.session_id {
|
||||
Some(session_id) => {
|
||||
match conn.query_opt(
|
||||
"SELECT user_id FROM sessions WHERE id = $1 AND expires_at > CURRENT_TIMESTAMP",
|
||||
&[&session_id],
|
||||
) {
|
||||
Ok(Some(row)) => row.get::<_, i32>(0),
|
||||
Ok(None) => {
|
||||
error!(request_id = %request_id, session_id = %session_id, "Session not found");
|
||||
return Err(ApiError::Authentication("Session not found or expired".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Session query error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
error!(request_id = %request_id, "No session ID provided");
|
||||
return Err(ApiError::Authentication("No session ID provided".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO sync_jobs (job_type, sync_direction, status, created_by, created_at) VALUES ($1, $2, 'pending'::sync_job_status, $3, NOW())",
|
||||
&[&req.job_type, &req.sync_direction, &user_id],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(request_id = %request_id, "Sync job created");
|
||||
Ok((StatusCode::OK, Json(json!({
|
||||
"message": "Sync job started",
|
||||
"job_type": req.job_type,
|
||||
"direction": req.sync_direction
|
||||
}))).into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Failed to create sync job");
|
||||
Err(ApiError::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_sync(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
info!(request_id = %request_id, "Stop sync");
|
||||
|
||||
let mut conn = match state.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database connection error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE sync_jobs SET status = 'cancelled'::sync_job_status, completed_at = NOW() WHERE status IN ('running'::sync_job_status, 'pending'::sync_job_status)",
|
||||
&[],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(request_id = %request_id, "Sync jobs stopped");
|
||||
Ok((StatusCode::OK, Json(json!({ "message": "Sync jobs stopped" }))).into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Failed to stop sync jobs");
|
||||
Err(ApiError::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn simulate_sync(
|
||||
State(state): State<Arc<AppState>>,
|
||||
data: serde_json::Value,
|
||||
) -> impl IntoResponse {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
info!(request_id = %request_id, "Simulate sync");
|
||||
|
||||
let mut conn = match state.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database connection error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let mut items: Vec<SyncItem> = Vec::new();
|
||||
let data_type = data.get("data_type").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
|
||||
// Fetch customers from database
|
||||
if data_type == "customers" {
|
||||
let rows = match conn.query(
|
||||
"SELECT sap_card_code, plesk_customer_id, plesk_subscription_id FROM customers",
|
||||
&[],
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Database error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let sap_code: String = row.get(0);
|
||||
let status = if i % 3 == 0 { "new" } else if i % 3 == 1 { "update" } else { "unchanged" };
|
||||
items.push(SyncItem {
|
||||
id: format!("sim-{}", i),
|
||||
source_id: sap_code.clone(),
|
||||
target_id: if status == "new" { None } else { Some(format!("PLESK-{}", 2000 + i)) },
|
||||
name: format!("Customer {}", sap_code),
|
||||
status: status.to_string(),
|
||||
source_data: json!({"sap_card_code": sap_code}),
|
||||
target_data: if status == "new" { None } else { Some(json!({"plesk_id": 2000 + i})) },
|
||||
diff: None,
|
||||
});
|
||||
}
|
||||
} else if data_type == "domains" {
|
||||
// Simulate domain data
|
||||
for i in 0..10 {
|
||||
let status = if i % 3 == 0 { "new" } else if i % 3 == 1 { "update" } else { "unchanged" };
|
||||
items.push(SyncItem {
|
||||
id: format!("sim-domain-{}", i),
|
||||
source_id: format!("SAP-DOM-{}", 1000 + i),
|
||||
target_id: if status == "new" { None } else { Some(format!("PLESK-DOM-{}", i)) },
|
||||
name: format!("domain{}.example.com", i),
|
||||
status: status.to_string(),
|
||||
source_data: json!({"domain_id": 1000 + i}),
|
||||
target_data: if status == "new" { None } else { Some(json!({"plesk_domain_id": i})) },
|
||||
diff: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let direction = data.get("direction").and_then(|v| v.as_str()).unwrap_or("sap_to_plesk");
|
||||
let result = SimulationResult {
|
||||
data_type: data_type.to_string(),
|
||||
direction: direction.to_string(),
|
||||
total_records: items.len(),
|
||||
new: items.iter().filter(|item| item.status == "new").count(),
|
||||
updated: items.iter().filter(|item| item.status == "update").count(),
|
||||
conflicts: items.iter().filter(|item| item.status == "conflict").count(),
|
||||
unchanged: items.iter().filter(|item| item.status == "unchanged").count(),
|
||||
deleted: 0,
|
||||
items,
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(json!(result))).into_response())
|
||||
}
|
||||
|
||||
pub async fn get_conflicts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
info!(request_id = %request_id, "Get conflicts");
|
||||
|
||||
let mut conn = match state.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database connection error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let result = conn.query(
|
||||
"SELECT id, sync_job_id, entity_type, entity_id, resolution_status, source_data, target_data, conflict_details FROM sync_conflicts ORDER BY created_at DESC LIMIT 20",
|
||||
&[],
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
let conflicts: Vec<Conflict> = rows
|
||||
.into_iter()
|
||||
.map(|row| Conflict {
|
||||
id: row.get::<_, i32>(0),
|
||||
sync_job_id: row.get::<_, i32>(1),
|
||||
entity_type: row.get::<_, String>(2),
|
||||
entity_id: row.get::<_, String>(3),
|
||||
resolution_status: row.get::<_, String>(4),
|
||||
source_data: row.get::<_, Option<String>>(5).unwrap_or_default(),
|
||||
target_data: row.get::<_, Option<String>>(6),
|
||||
conflict_details: row.get::<_, Option<String>>(7),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(json!({ "conflicts": conflicts }))).into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Database error");
|
||||
Err(ApiError::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_conflict(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: ConflictResolution,
|
||||
) -> impl IntoResponse {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
info!(request_id = %request_id, "Resolve conflict");
|
||||
|
||||
let mut conn = match state.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, "Database connection error");
|
||||
return Err(ApiError::Database(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE sync_conflicts SET resolution_status = $1, resolved_data = $2::jsonb WHERE id = $3",
|
||||
&[&req.action, &req.resolved_data.to_string(), &req.id],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(request_id = %request_id, "Conflict resolved");
|
||||
Ok((StatusCode::OK, Json(json!({ "message": "Conflict resolved" }))).into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(request_id = %request_id, error = %e, "Failed to resolve conflict");
|
||||
Err(ApiError::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn job_to_json(row: &Row) -> serde_json::Value {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"job_type": row.get::<_, String>(1),
|
||||
"sync_direction": row.get::<_, String>(2),
|
||||
"status": row.get::<_, String>(3),
|
||||
"records_processed": row.get::<_, i32>(4),
|
||||
"records_failed": row.get::<_, i32>(5),
|
||||
"created_at": row.get::<_, String>(6),
|
||||
"started_at": row.get::<_, Option<String>>(7),
|
||||
"completed_at": row.get::<_, Option<String>>(8),
|
||||
})
|
||||
}
|
||||
29
backend/src/lib.rs
Executable file
29
backend/src/lib.rs
Executable file
@@ -0,0 +1,29 @@
|
||||
pub mod alert_system;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod billing_id;
|
||||
pub mod billing_system;
|
||||
pub mod config;
|
||||
pub mod errors;
|
||||
pub mod export;
|
||||
pub mod handlers_sync;
|
||||
pub mod mfa;
|
||||
pub mod models;
|
||||
pub mod plesk_client;
|
||||
pub mod response;
|
||||
pub mod sap_client;
|
||||
pub mod scheduled;
|
||||
pub mod servers;
|
||||
pub mod state;
|
||||
pub mod sync;
|
||||
pub mod validators;
|
||||
pub mod websocket;
|
||||
|
||||
pub use config::Config;
|
||||
pub use errors::{ApiError, ApiResult, ErrorContext};
|
||||
pub use response::{
|
||||
conflict, created, error, forbidden, internal_error, no_content, not_found, paginated, success,
|
||||
unauthorized, validation_error,
|
||||
};
|
||||
pub use state::AppState;
|
||||
pub use validators::*;
|
||||
183
backend/src/main.rs
Executable file
183
backend/src/main.rs
Executable file
@@ -0,0 +1,183 @@
|
||||
extern crate sap_sync_backend;
|
||||
|
||||
mod routes;
|
||||
|
||||
use argon2::password_hash::PasswordHasher;
|
||||
use argon2::Argon2;
|
||||
use postgres::NoTls;
|
||||
use r2d2::Pool;
|
||||
use r2d2_postgres::PostgresConnectionManager;
|
||||
use rouille::{router, Request, Response};
|
||||
use std::sync::Arc;
|
||||
|
||||
use routes::AppState;
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let port = std::env::var("APP__SERVER__PORT")
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse::<u16>()
|
||||
.unwrap_or(3001);
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgresql://sap_user:papsync123@localhost:5432/sap_sync".to_string());
|
||||
|
||||
let admin_username = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
|
||||
let admin_email =
|
||||
std::env::var("ADMIN_EMAIL").unwrap_or_else(|_| "admin@sap-sync.local".to_string());
|
||||
let admin_password =
|
||||
std::env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "Admin123!".to_string());
|
||||
|
||||
let manager = PostgresConnectionManager::new(database_url.parse().unwrap_or_default(), NoTls);
|
||||
let pool = match Pool::builder().max_size(15).build(manager) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create pool: {}", e);
|
||||
panic!("Failed to create database pool");
|
||||
}
|
||||
};
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
pool,
|
||||
admin_username,
|
||||
admin_email,
|
||||
admin_password,
|
||||
});
|
||||
|
||||
create_admin_user(&state).expect("Failed to create admin user");
|
||||
|
||||
log::info!("Listening on 0.0.0.0:{}", port);
|
||||
rouille::start_server(("0.0.0.0", port), move |request| {
|
||||
handle_request(request, &state)
|
||||
});
|
||||
}
|
||||
|
||||
fn create_admin_user(state: &Arc<AppState>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut conn = state.pool.get()?;
|
||||
|
||||
if conn
|
||||
.query_opt(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
&[&state.admin_username],
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let salt = argon2::password_hash::SaltString::generate(rand::thread_rng());
|
||||
let password_hash = Argon2::default()
|
||||
.hash_password(state.admin_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Failed to hash admin password: {}", e))?
|
||||
.to_string();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, email, password_hash, role, is_active, mfa_enabled, failed_login_attempts) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
&[&state.admin_username, &state.admin_email, &password_hash, &"admin", &true, &false, &0i32],
|
||||
)?;
|
||||
|
||||
log::info!("Admin user created: {}", state.admin_username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_request(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
router!(request,
|
||||
|
||||
// Health & Config
|
||||
(GET) (/api/health) => { routes::health::get_health(request, state) },
|
||||
(GET) (/api/config) => { routes::health::get_config(request, state) },
|
||||
(PUT) (/api/config) => { routes::health::put_config(request, state) },
|
||||
|
||||
// Authentication
|
||||
(POST) (/api/auth/login) => { routes::auth::login(request, state) },
|
||||
(POST) (/api/auth/logout) => { routes::auth::logout(request, state) },
|
||||
(GET) (/api/auth/me) => { routes::auth::me(request, state) },
|
||||
(POST) (/api/auth/change-password) => { routes::auth::change_password(request, state) },
|
||||
(POST) (/api/auth/mfa/setup) => { routes::auth::mfa_setup(request, state) },
|
||||
(POST) (/api/auth/mfa/verify) => { routes::auth::mfa_verify(request, state) },
|
||||
|
||||
// Audit
|
||||
(GET) (/api/audit/logs) => { routes::audit::get_logs(request, state) },
|
||||
(GET) (/api/audit/sync-logs) => { routes::audit::get_sync_logs(request, state) },
|
||||
(GET) (/api/audit/export) => { routes::audit::export(request, state) },
|
||||
|
||||
// Direct connection tests
|
||||
(POST) (/api/sap/test) => { routes::servers::test_sap_direct(request, state) },
|
||||
(POST) (/api/plesk/test) => { routes::servers::test_plesk_direct(request, state) },
|
||||
|
||||
// Plesk servers
|
||||
(GET) (/api/servers/plesk) => { routes::servers::list_plesk(request, state) },
|
||||
(POST) (/api/servers/plesk) => { routes::servers::create_plesk(request, state) },
|
||||
(GET) (/api/servers/plesk/{id: String}) => { routes::servers::get_plesk(request, state, &id) },
|
||||
(PUT) (/api/servers/plesk/{id: String}) => { routes::servers::update_plesk(request, state, &id) },
|
||||
(DELETE) (/api/servers/plesk/{id: String}) => { routes::servers::delete_plesk(request, state, &id) },
|
||||
(POST) (/api/servers/plesk/{id: String}/test) => { routes::servers::test_plesk(request, state, &id) },
|
||||
|
||||
// SAP servers
|
||||
(GET) (/api/servers/sap) => { routes::servers::list_sap(request, state) },
|
||||
(POST) (/api/servers/sap) => { routes::servers::create_sap(request, state) },
|
||||
(GET) (/api/servers/sap/{id: String}) => { routes::servers::get_sap(request, state, &id) },
|
||||
(PUT) (/api/servers/sap/{id: String}) => { routes::servers::update_sap(request, state, &id) },
|
||||
(DELETE) (/api/servers/sap/{id: String}) => { routes::servers::delete_sap(request, state, &id) },
|
||||
(POST) (/api/servers/sap/{id: String}/test) => { routes::servers::test_sap(request, state, &id) },
|
||||
|
||||
// Sync
|
||||
(GET) (/api/sync/status) => { routes::sync::get_status(request, state) },
|
||||
(POST) (/api/sync/start) => { routes::sync::start(request, state) },
|
||||
(POST) (/api/sync/stop) => { routes::sync::stop(request, state) },
|
||||
(GET) (/api/sync/jobs) => { routes::sync::list_jobs(request, state) },
|
||||
(POST) (/api/sync/simulate) => { routes::sync::simulate(request, state) },
|
||||
(GET) (/api/sync/conflicts) => { routes::sync::list_conflicts(request, state) },
|
||||
(POST) (/api/sync/conflicts/{id: i32}/resolve) => { routes::sync::resolve_conflict(request, state, id) },
|
||||
|
||||
// Billing & Pricing
|
||||
(GET) (/api/pricing) => { routes::billing::list_pricing(request, state) },
|
||||
(POST) (/api/pricing) => { routes::billing::create_pricing(request, state) },
|
||||
(GET) (/api/billing/records) => { routes::billing::list_records(request, state) },
|
||||
(POST) (/api/billing/generate) => { routes::billing::generate(request, state) },
|
||||
(GET) (/api/billing/preview/{id: i32}) => { routes::billing::preview(request, state, id) },
|
||||
(POST) (/api/billing/send-to-sap/{id: i32}) => { routes::billing::send_to_sap_by_id(request, state, id) },
|
||||
(POST) (/api/billing/send-to-sap) => { routes::billing::send_to_sap(request, state) },
|
||||
|
||||
// Reports / Exports
|
||||
(GET) (/api/reports/export/{format: String}) => { routes::reports::export(request, state, &format) },
|
||||
|
||||
// Alerts
|
||||
(GET) (/api/alerts/thresholds) => { routes::alerts::list_thresholds(request, state) },
|
||||
(POST) (/api/alerts/thresholds) => { routes::alerts::create_threshold(request, state) },
|
||||
(PUT) (/api/alerts/thresholds/{id: i32}) => { routes::alerts::update_threshold(request, state, id) },
|
||||
(DELETE) (/api/alerts/thresholds/{id: i32}) => { routes::alerts::delete_threshold(request, state, id) },
|
||||
(GET) (/api/alerts/history) => { routes::alerts::get_history(request, state) },
|
||||
|
||||
// Webhooks
|
||||
(GET) (/api/webhooks) => { routes::webhooks::list(request, state) },
|
||||
(POST) (/api/webhooks) => { routes::webhooks::create(request, state) },
|
||||
(DELETE) (/api/webhooks/{id: i32}) => { routes::webhooks::delete(request, state, id) },
|
||||
|
||||
// Schedules
|
||||
(GET) (/api/schedules) => { routes::schedules::list(request, state) },
|
||||
(POST) (/api/schedules) => { routes::schedules::create(request, state) },
|
||||
(PUT) (/api/schedules/{id: i32}) => { routes::schedules::update(request, state, id) },
|
||||
(DELETE) (/api/schedules/{id: i32}) => { routes::schedules::delete(request, state, id) },
|
||||
|
||||
// Setup
|
||||
(GET) (/api/setup/status) => { routes::setup::get_status(request, state) },
|
||||
(POST) (/api/config/test-plesk) => { routes::setup::test_plesk(request, state) },
|
||||
(POST) (/api/config/plesk2fa) => { routes::setup::plesk_2fa(request, state) },
|
||||
(POST) (/api/config/test-sap) => { routes::setup::test_sap(request, state) },
|
||||
(POST) (/api/config/setup) => { routes::setup::save_config(request, state) },
|
||||
(POST) (/api/setup) => { routes::setup::save_config(request, state) },
|
||||
|
||||
// Root
|
||||
(GET) (/) => {
|
||||
Response::html("<h1>SAP Sync API</h1><p>Use /api/health to check status</p>")
|
||||
},
|
||||
|
||||
_ => {
|
||||
Response::empty_404()
|
||||
}
|
||||
)
|
||||
}
|
||||
83
backend/src/mfa.rs
Executable file
83
backend/src/mfa.rs
Executable file
@@ -0,0 +1,83 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use totp_lite::{totp_custom, Sha1};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MfaSetupRequest {
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MfaSetupResponse {
|
||||
pub method: String,
|
||||
pub secret: String,
|
||||
pub qr_code_url: Option<String>,
|
||||
pub backup_codes: Vec<String>,
|
||||
pub test_code: Option<String>,
|
||||
}
|
||||
|
||||
pub fn generate_totp_secret() -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut secret = String::with_capacity(16);
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..16 {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
secret.push(CHARSET[idx] as char);
|
||||
}
|
||||
secret
|
||||
}
|
||||
|
||||
pub fn generate_qr_code_url(secret: &str, issuer: &str, account: &str) -> String {
|
||||
format!(
|
||||
"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={}",
|
||||
urlencoding::encode(&format!(
|
||||
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits=6&period=30",
|
||||
urlencoding::encode(issuer),
|
||||
urlencoding::encode(account),
|
||||
secret,
|
||||
urlencoding::encode(issuer)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_totp(secret: &str, code: &str) -> Result<usize, &'static str> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let expected = totp_custom::<Sha1>(30, 6, secret.as_bytes(), now);
|
||||
if expected == code {
|
||||
Ok(0)
|
||||
} else {
|
||||
Err("Invalid code")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_backup_codes() -> Vec<String> {
|
||||
(0..8)
|
||||
.map(|_| {
|
||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut code = String::with_capacity(8);
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..8 {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
code.push(CHARSET[idx] as char);
|
||||
}
|
||||
format!("{}-{}", &code[0..4], &code[4..8])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn hash_backup_code(code: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EmailOtpRequest {
|
||||
pub email: String,
|
||||
pub code: String,
|
||||
}
|
||||
272
backend/src/models.rs
Executable file
272
backend/src/models.rs
Executable file
@@ -0,0 +1,272 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Plesk configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleskConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub api_key: String,
|
||||
#[serde(default)]
|
||||
pub use_https: bool,
|
||||
#[serde(default)]
|
||||
pub verify_ssl: bool,
|
||||
#[serde(default)]
|
||||
pub two_factor_enabled: bool,
|
||||
pub two_factor_method: String,
|
||||
pub two_factor_secret: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// SAP configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SapConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub company_db: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub use_ssl: bool,
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
/// Sync settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncSettings {
|
||||
pub sync_direction: String,
|
||||
pub sync_interval_minutes: u32,
|
||||
pub conflict_resolution: String,
|
||||
#[serde(default)]
|
||||
pub auto_sync_enabled: bool,
|
||||
}
|
||||
|
||||
/// User database model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserDb {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub password_hash: String,
|
||||
pub mfa_enabled: bool,
|
||||
pub failed_login_attempts: i32,
|
||||
pub locked_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Plesk test result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PleskTestResult {
|
||||
Success { message: String },
|
||||
Requires2FA { session_id: String, method: String },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// Setup configuration containing all subsystem configurations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SetupConfig {
|
||||
pub plesk: PleskConfig,
|
||||
pub sap: SapConfig,
|
||||
pub sync: SyncSettings,
|
||||
}
|
||||
|
||||
/// Form for starting a sync job
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncStartForm {
|
||||
pub job_type: String,
|
||||
pub sync_direction: String,
|
||||
}
|
||||
|
||||
/// Form for updating configuration
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConfigUpdate {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Form for two-factor verification
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TwoFactorVerify {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Sync job status enum
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SyncJobStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Form for changing password
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasswordChangeForm {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Billing record ID
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BillingRecordId {
|
||||
pub id: i32,
|
||||
}
|
||||
|
||||
/// Test configuration (kept for backward compatibility with tests)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
#[test]
|
||||
fn test_plesk_config_defaults() {
|
||||
let config = PleskConfig {
|
||||
host: "plesk.example.com".to_string(),
|
||||
port: 8443,
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
api_key: "".to_string(),
|
||||
use_https: true,
|
||||
verify_ssl: true,
|
||||
two_factor_enabled: false,
|
||||
two_factor_method: "none".to_string(),
|
||||
two_factor_secret: None,
|
||||
session_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(config.port, 8443);
|
||||
assert!(config.use_https);
|
||||
assert!(config.verify_ssl);
|
||||
assert!(!config.two_factor_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sap_config() {
|
||||
let config = SapConfig {
|
||||
host: "sap.example.com".to_string(),
|
||||
port: 50000,
|
||||
company_db: "SBODEMO".to_string(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
use_ssl: true,
|
||||
timeout_seconds: 30,
|
||||
};
|
||||
|
||||
assert_eq!(config.port, 50000);
|
||||
assert_eq!(config.company_db, "SBODEMO");
|
||||
assert!(config.use_ssl);
|
||||
assert_eq!(config.timeout_seconds, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_settings() {
|
||||
let settings = SyncSettings {
|
||||
sync_direction: "bidirectional".to_string(),
|
||||
sync_interval_minutes: 60,
|
||||
conflict_resolution: "timestamp_based".to_string(),
|
||||
auto_sync_enabled: true,
|
||||
};
|
||||
|
||||
assert_eq!(settings.sync_direction, "bidirectional");
|
||||
assert_eq!(settings.sync_interval_minutes, 60);
|
||||
assert_eq!(settings.conflict_resolution, "timestamp_based");
|
||||
assert!(settings.auto_sync_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_db() {
|
||||
let user = UserDb {
|
||||
id: 1,
|
||||
username: "admin".to_string(),
|
||||
email: "admin@example.com".to_string(),
|
||||
role: "admin".to_string(),
|
||||
password_hash: "hash".to_string(),
|
||||
mfa_enabled: true,
|
||||
failed_login_attempts: 0,
|
||||
locked_until: None,
|
||||
};
|
||||
|
||||
assert_eq!(user.id, 1);
|
||||
assert_eq!(user.username, "admin");
|
||||
assert_eq!(user.email, "admin@example.com");
|
||||
assert_eq!(user.role, "admin");
|
||||
assert_eq!(user.password_hash, "hash");
|
||||
assert!(user.mfa_enabled);
|
||||
assert_eq!(user.failed_login_attempts, 0);
|
||||
assert!(user.locked_until.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plesk_test_result() {
|
||||
let success = PleskTestResult::Success {
|
||||
message: "Connected".to_string(),
|
||||
};
|
||||
let requires_2fa = PleskTestResult::Requires2FA {
|
||||
session_id: "session123".to_string(),
|
||||
method: "totp".to_string(),
|
||||
};
|
||||
let error = PleskTestResult::Error {
|
||||
message: "Connection failed".to_string(),
|
||||
};
|
||||
|
||||
match success {
|
||||
PleskTestResult::Success { message } => assert_eq!(message, "Connected"),
|
||||
_ => panic!("Expected Success variant"),
|
||||
}
|
||||
|
||||
match requires_2fa {
|
||||
PleskTestResult::Requires2FA { session_id, method } => {
|
||||
assert_eq!(session_id, "session123");
|
||||
assert_eq!(method, "totp");
|
||||
}
|
||||
_ => panic!("Expected Requires2FA variant"),
|
||||
}
|
||||
|
||||
match error {
|
||||
PleskTestResult::Error { message } => assert_eq!(message, "Connection failed"),
|
||||
_ => panic!("Expected Error variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setup_config() {
|
||||
let config = SetupConfig {
|
||||
plesk: PleskConfig {
|
||||
host: "plesk.example.com".to_string(),
|
||||
port: 8443,
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
api_key: "".to_string(),
|
||||
use_https: true,
|
||||
verify_ssl: true,
|
||||
two_factor_enabled: false,
|
||||
two_factor_method: "none".to_string(),
|
||||
two_factor_secret: None,
|
||||
session_id: None,
|
||||
},
|
||||
sap: SapConfig {
|
||||
host: "sap.example.com".to_string(),
|
||||
port: 50000,
|
||||
company_db: "SBODEMO".to_string(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
use_ssl: true,
|
||||
timeout_seconds: 30,
|
||||
},
|
||||
sync: SyncSettings {
|
||||
sync_direction: "bidirectional".to_string(),
|
||||
sync_interval_minutes: 60,
|
||||
conflict_resolution: "timestamp_based".to_string(),
|
||||
auto_sync_enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(config.plesk.host, "plesk.example.com");
|
||||
assert_eq!(config.sap.port, 50000);
|
||||
assert_eq!(config.sync.sync_direction, "bidirectional");
|
||||
}
|
||||
}
|
||||
384
backend/src/plesk_client.rs
Executable file
384
backend/src/plesk_client.rs
Executable file
@@ -0,0 +1,384 @@
|
||||
use crate::errors::{ConnectionError, ConnectionTestResult, PleskError};
|
||||
use crate::models::PleskConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleskServer {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub hostname: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleskCustomer {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleskSubscription {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub owner_id: i32,
|
||||
pub plan_id: i32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleskDomain {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub subscription_id: i32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleskUsageMetrics {
|
||||
pub subscription_id: i32,
|
||||
pub cpu_usage: f64,
|
||||
pub ram_usage: f64,
|
||||
pub disk_usage: f64,
|
||||
pub bandwidth_usage: f64,
|
||||
pub database_usage: f64,
|
||||
}
|
||||
|
||||
/// Validate Plesk configuration
|
||||
pub fn validate_plesk_config(config: &PleskConfig) -> Result<(), PleskError> {
|
||||
if config.host.is_empty() {
|
||||
return Err(PleskError::InvalidConfig {
|
||||
field: "host".to_string(),
|
||||
message: "Host is required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if config.port == 0 {
|
||||
return Err(PleskError::InvalidConfig {
|
||||
field: "port".to_string(),
|
||||
message: "Port must be between 1 and 65535".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if config.api_key.is_empty() && config.username.is_empty() {
|
||||
return Err(PleskError::InvalidConfig {
|
||||
field: "credentials".to_string(),
|
||||
message: "Either API key or username must be provided".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !config.username.is_empty() && config.password.is_empty() {
|
||||
return Err(PleskError::InvalidConfig {
|
||||
field: "password".to_string(),
|
||||
message: "Password is required when username is provided".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Plesk connection with comprehensive error handling
|
||||
pub fn test_plesk_connection(
|
||||
config: &PleskConfig,
|
||||
_session_id: Option<&str>,
|
||||
_timeout_secs: Option<u64>,
|
||||
) -> ConnectionTestResult {
|
||||
let start = Instant::now();
|
||||
|
||||
// Validate configuration first
|
||||
if let Err(e) = validate_plesk_config(config) {
|
||||
return ConnectionTestResult {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some(ConnectionError::from(e)),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Build the Plesk API URL
|
||||
let protocol = if config.use_https { "https" } else { "http" };
|
||||
let url = format!(
|
||||
"{}://{}:{}/enterprise/control/agent.php",
|
||||
protocol, config.host, config.port
|
||||
);
|
||||
|
||||
log::info!("Testing Plesk connection to: {}", url);
|
||||
|
||||
// Build request
|
||||
let request = if !config.api_key.is_empty() {
|
||||
ureq::get(&url).header("X-API-Key", &config.api_key)
|
||||
} else if !config.username.is_empty() {
|
||||
let credentials = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
format!("{}:{}", config.username, config.password),
|
||||
);
|
||||
ureq::get(&url).header("Authorization", &format!("Basic {}", credentials))
|
||||
} else {
|
||||
ureq::get(&url)
|
||||
};
|
||||
|
||||
// Execute request
|
||||
match request.call() {
|
||||
Ok(response) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let status = response.status();
|
||||
|
||||
if status == 200 {
|
||||
ConnectionTestResult {
|
||||
success: true,
|
||||
message: "Connected to Plesk successfully".to_string(),
|
||||
latency_ms: Some(latency),
|
||||
error: None,
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
} else if status == 401 {
|
||||
ConnectionTestResult {
|
||||
success: false,
|
||||
message: "Authentication failed".to_string(),
|
||||
latency_ms: Some(latency),
|
||||
error: Some(ConnectionError::from(PleskError::AuthenticationFailed {
|
||||
reason: "Invalid credentials".to_string(),
|
||||
})),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
} else {
|
||||
ConnectionTestResult {
|
||||
success: false,
|
||||
message: format!("Unexpected status code: {}", status),
|
||||
latency_ms: Some(latency),
|
||||
error: Some(ConnectionError::from(PleskError::ApiError {
|
||||
code: status.as_u16() as i32,
|
||||
message: format!("HTTP {}", status),
|
||||
})),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let reason = e.to_string();
|
||||
|
||||
let error = if reason.contains("timed out") || reason.contains("timeout") {
|
||||
PleskError::Timeout {
|
||||
duration_ms: latency,
|
||||
}
|
||||
} else if reason.contains("certificate")
|
||||
|| reason.contains("SSL")
|
||||
|| reason.contains("TLS")
|
||||
{
|
||||
PleskError::SslError {
|
||||
reason: reason.clone(),
|
||||
}
|
||||
} else {
|
||||
PleskError::ConnectionFailed {
|
||||
host: config.host.clone(),
|
||||
reason: reason.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
ConnectionTestResult {
|
||||
success: false,
|
||||
message: format!("Connection failed: {}", reason),
|
||||
latency_ms: Some(latency),
|
||||
error: Some(ConnectionError::from(error)),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility
|
||||
pub fn test_plesk_connection_impl(
|
||||
config: &PleskConfig,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<crate::models::PleskTestResult, String> {
|
||||
let result = test_plesk_connection(config, session_id, None);
|
||||
|
||||
if result.requires_2fa {
|
||||
return Ok(crate::models::PleskTestResult::Requires2FA {
|
||||
session_id: result.session_id.unwrap_or_default(),
|
||||
method: result
|
||||
.two_factor_method
|
||||
.unwrap_or_else(|| "totp".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if result.success {
|
||||
Ok(crate::models::PleskTestResult::Success {
|
||||
message: result.message,
|
||||
})
|
||||
} else {
|
||||
Ok(crate::models::PleskTestResult::Error {
|
||||
message: result.error.map(|e| e.message).unwrap_or(result.message),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_config() -> PleskConfig {
|
||||
PleskConfig {
|
||||
host: "plesk.example.com".to_string(),
|
||||
port: 8443,
|
||||
username: "admin".to_string(),
|
||||
password: "password123".to_string(),
|
||||
api_key: String::new(),
|
||||
use_https: true,
|
||||
verify_ssl: true,
|
||||
two_factor_enabled: false,
|
||||
two_factor_method: "none".to_string(),
|
||||
two_factor_secret: None,
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_empty_host() {
|
||||
let mut config = create_test_config();
|
||||
config.host = String::new();
|
||||
|
||||
let result = validate_plesk_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(PleskError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "host");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_invalid_port() {
|
||||
let mut config = create_test_config();
|
||||
config.port = 0;
|
||||
|
||||
let result = validate_plesk_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(PleskError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "port");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_no_credentials() {
|
||||
let mut config = create_test_config();
|
||||
config.api_key = String::new();
|
||||
config.username = String::new();
|
||||
|
||||
let result = validate_plesk_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(PleskError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "credentials");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_username_without_password() {
|
||||
let mut config = create_test_config();
|
||||
config.api_key = String::new();
|
||||
config.password = String::new();
|
||||
|
||||
let result = validate_plesk_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(PleskError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "password");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_valid_with_api_key() {
|
||||
let mut config = create_test_config();
|
||||
config.api_key = "test-api-key".to_string();
|
||||
config.username = String::new();
|
||||
config.password = String::new();
|
||||
|
||||
let result = validate_plesk_config(&config);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_valid_with_credentials() {
|
||||
let config = create_test_config();
|
||||
let result = validate_plesk_config(&config);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connection_test_invalid_host() {
|
||||
let mut config = create_test_config();
|
||||
config.host = String::new();
|
||||
|
||||
let result = test_plesk_connection(&config, None, Some(5));
|
||||
assert!(!result.success);
|
||||
assert!(result.error.is_some());
|
||||
assert!(result.latency_ms.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connection_test_unreachable_host() {
|
||||
let mut config = create_test_config();
|
||||
config.host = "192.0.2.1".to_string(); // TEST-NET, should timeout
|
||||
config.port = 1;
|
||||
|
||||
let result = test_plesk_connection(&config, None, Some(2));
|
||||
assert!(!result.success);
|
||||
assert!(result.error.is_some());
|
||||
assert!(result.latency_ms.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plesk_error_to_connection_error() {
|
||||
let error = PleskError::ConnectionFailed {
|
||||
host: "example.com".to_string(),
|
||||
reason: "Connection refused".to_string(),
|
||||
};
|
||||
|
||||
let conn_error: ConnectionError = error.into();
|
||||
assert_eq!(conn_error.error_type, "connection");
|
||||
assert_eq!(conn_error.error_code, "PLESK_CONN_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plesk_timeout_error() {
|
||||
let error = PleskError::Timeout { duration_ms: 5000 };
|
||||
let conn_error: ConnectionError = error.into();
|
||||
assert_eq!(conn_error.error_type, "timeout");
|
||||
assert_eq!(conn_error.error_code, "PLESK_TIMEOUT_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plesk_ssl_error() {
|
||||
let error = PleskError::SslError {
|
||||
reason: "Certificate verification failed".to_string(),
|
||||
};
|
||||
let conn_error: ConnectionError = error.into();
|
||||
assert_eq!(conn_error.error_type, "ssl");
|
||||
assert_eq!(conn_error.error_code, "PLESK_SSL_001");
|
||||
}
|
||||
}
|
||||
201
backend/src/response.rs
Executable file
201
backend/src/response.rs
Executable file
@@ -0,0 +1,201 @@
|
||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
/// Standardized success response
|
||||
pub fn success<T: Serialize>(data: T, request_id: String) -> impl IntoResponse {
|
||||
let response = json!({
|
||||
"success": true,
|
||||
"data": data,
|
||||
"request_id": request_id,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
});
|
||||
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
|
||||
/// Standardized error response
|
||||
pub fn error(
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
error_code: String,
|
||||
request_id: String,
|
||||
) -> impl IntoResponse {
|
||||
let response = json!({
|
||||
"success": false,
|
||||
"error": message,
|
||||
"error_code": error_code,
|
||||
"request_id": request_id,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
});
|
||||
|
||||
(status, Json(response)).into_response()
|
||||
}
|
||||
|
||||
/// Validation error response
|
||||
pub fn validation_error(message: String, request_id: String) -> impl IntoResponse {
|
||||
error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
message,
|
||||
"VAL_001".to_string(),
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Not found error response
|
||||
pub fn not_found(resource: String, request_id: String) -> impl IntoResponse {
|
||||
error(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("{} not found", resource),
|
||||
"RES_001".to_string(),
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Unauthorized error response
|
||||
pub fn unauthorized(message: String, request_id: String) -> impl IntoResponse {
|
||||
error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
message,
|
||||
"AUTH_001".to_string(),
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Forbidden error response
|
||||
pub fn forbidden(message: String, request_id: String) -> impl IntoResponse {
|
||||
error(
|
||||
StatusCode::FORBIDDEN,
|
||||
message,
|
||||
"AUTH_002".to_string(),
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Conflict error response
|
||||
pub fn conflict(message: String, request_id: String) -> impl IntoResponse {
|
||||
error(
|
||||
StatusCode::CONFLICT,
|
||||
message,
|
||||
"CON_001".to_string(),
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Internal server error response
|
||||
pub fn internal_error(message: String, request_id: String) -> impl IntoResponse {
|
||||
error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message,
|
||||
"INT_001".to_string(),
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Created response
|
||||
pub fn created<T: Serialize>(data: T, request_id: String) -> impl IntoResponse {
|
||||
let response = json!({
|
||||
"success": true,
|
||||
"data": data,
|
||||
"request_id": request_id,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
});
|
||||
|
||||
(StatusCode::CREATED, Json(response)).into_response()
|
||||
}
|
||||
|
||||
/// No content response
|
||||
pub fn no_content(request_id: String) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::NO_CONTENT,
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"request_id": request_id,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Paginated response
|
||||
pub fn paginated<T: Serialize>(
|
||||
data: Vec<T>,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
total: u64,
|
||||
request_id: String,
|
||||
) -> impl IntoResponse {
|
||||
let total_pages = total.div_ceil(page_size as u64);
|
||||
|
||||
let response = json!({
|
||||
"success": true,
|
||||
"data": data,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": total_pages
|
||||
},
|
||||
"request_id": request_id,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
});
|
||||
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::response::Response;
|
||||
|
||||
#[test]
|
||||
fn test_success_response() {
|
||||
let data = json!({"message": "test"});
|
||||
let request_id = "req-123".to_string();
|
||||
let response = success(data, request_id);
|
||||
let into_response: Response = response.into_response();
|
||||
assert_eq!(into_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_response() {
|
||||
let response = error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Test error".to_string(),
|
||||
"TEST_001".to_string(),
|
||||
"req-123".to_string(),
|
||||
);
|
||||
let into_response: Response = response.into_response();
|
||||
assert_eq!(into_response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_error() {
|
||||
let response = validation_error("Invalid input".to_string(), "req-123".to_string());
|
||||
let into_response: Response = response.into_response();
|
||||
assert_eq!(into_response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_found() {
|
||||
let response = not_found("User".to_string(), "req-123".to_string());
|
||||
let into_response: Response = response.into_response();
|
||||
assert_eq!(into_response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_created_response() {
|
||||
let data = json!({"id": 1});
|
||||
let response = created(data, "req-123".to_string());
|
||||
let into_response: Response = response.into_response();
|
||||
assert_eq!(into_response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginated_response() {
|
||||
let data = vec![json!({"id": 1}), json!({"id": 2})];
|
||||
let response = paginated(data, 1, 10, 2, "req-123".to_string());
|
||||
let into_response: Response = response.into_response();
|
||||
assert_eq!(into_response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
193
backend/src/routes/alerts.rs
Normal file
193
backend/src/routes/alerts.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
pub fn list_thresholds(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, name, subscription_id, metric_type::text, threshold_value::float8, \
|
||||
comparison_operator, action, notification_channels::text, is_active, last_triggered::text \
|
||||
FROM alert_thresholds ORDER BY created_at DESC",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let thresholds: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let channels_str: Option<String> = row.get("notification_channels");
|
||||
let channels: serde_json::Value = channels_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(json!(["email"]));
|
||||
json!({
|
||||
"id": row.get::<_, i32>("id"),
|
||||
"name": row.get::<_, String>("name"),
|
||||
"subscription_id": row.get::<_, Option<i32>>("subscription_id"),
|
||||
"metric_type": row.get::<_, String>("metric_type"),
|
||||
"threshold_value": row.get::<_, f64>("threshold_value"),
|
||||
"comparison_operator": row.get::<_, String>("comparison_operator"),
|
||||
"action": row.get::<_, String>("action"),
|
||||
"notification_channels": channels,
|
||||
"is_active": row.get::<_, bool>("is_active"),
|
||||
"last_triggered": row.get::<_, Option<String>>("last_triggered"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&thresholds)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_threshold(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let threshold: sap_sync_backend::alert_system::AlertThresholdCreate = match json_input(request)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let channels_json = serde_json::to_string(&threshold.notification_channels)
|
||||
.unwrap_or_else(|_| "[\"email\"]".to_string());
|
||||
let val_str = threshold.threshold_value.to_string();
|
||||
|
||||
match conn.query_one(
|
||||
"INSERT INTO alert_thresholds \
|
||||
(name, subscription_id, metric_type, threshold_value, comparison_operator, action, notification_channels, is_active) \
|
||||
VALUES ($1, $2, $3::text::metric_type, $4::text::numeric, $5, $6, $7::text::jsonb, $8) RETURNING id",
|
||||
&[
|
||||
&threshold.name, &threshold.subscription_id, &threshold.metric_type,
|
||||
&val_str, &threshold.comparison_operator, &threshold.action,
|
||||
&channels_json, &threshold.is_active,
|
||||
],
|
||||
) {
|
||||
Ok(r) => Response::json(&json!({
|
||||
"id": r.get::<_, i32>(0),
|
||||
"name": threshold.name, "metric_type": threshold.metric_type,
|
||||
"threshold_value": threshold.threshold_value,
|
||||
"action": threshold.action, "is_active": threshold.is_active,
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, &format!("Database error: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_threshold(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
#[derive(Deserialize)]
|
||||
struct Form {
|
||||
name: String,
|
||||
subscription_id: Option<i32>,
|
||||
metric_type: String,
|
||||
threshold_value: f64,
|
||||
comparison_operator: String,
|
||||
action: String,
|
||||
is_active: Option<bool>,
|
||||
}
|
||||
|
||||
let form: Form = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let val_str = form.threshold_value.to_string();
|
||||
let is_active = form.is_active.unwrap_or(true);
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE alert_thresholds SET name=$1, subscription_id=$2, \
|
||||
metric_type=$3::text::metric_type, threshold_value=$4::text::numeric, \
|
||||
comparison_operator=$5, action=$6, is_active=$7 WHERE id=$8",
|
||||
&[
|
||||
&form.name, &form.subscription_id, &form.metric_type,
|
||||
&val_str, &form.comparison_operator, &form.action, &is_active, &id,
|
||||
],
|
||||
) {
|
||||
Ok(0) => json_error(404, "Threshold not found"),
|
||||
Ok(_) => Response::json(&json!({"message": "Threshold updated"})),
|
||||
Err(e) => json_error(500, &format!("Update error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_threshold(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.execute("DELETE FROM alert_thresholds WHERE id = $1", &[&id]) {
|
||||
Ok(0) => json_error(404, "Threshold not found"),
|
||||
Ok(_) => Response::json(&json!({"message": "Threshold deleted"})),
|
||||
Err(e) => json_error(500, &format!("Delete error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_history(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT ah.id, ah.threshold_id, at.name as threshold_name, \
|
||||
ah.actual_value::float8 as actual_value, ah.triggered_at::text as triggered_at, \
|
||||
ah.action_taken, ah.notification_sent \
|
||||
FROM alert_history ah \
|
||||
LEFT JOIN alert_thresholds at ON ah.threshold_id = at.id \
|
||||
ORDER BY ah.triggered_at DESC LIMIT 100",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let history: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>("id"),
|
||||
"threshold_id": row.get::<_, i32>("threshold_id"),
|
||||
"threshold_name": row.get::<_, Option<String>>("threshold_name").unwrap_or_default(),
|
||||
"actual_value": row.get::<_, f64>("actual_value"),
|
||||
"triggered_at": row.get::<_, Option<String>>("triggered_at"),
|
||||
"action_taken": row.get::<_, Option<String>>("action_taken"),
|
||||
"notification_sent": row.get::<_, bool>("notification_sent"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&history)
|
||||
}
|
||||
Err(e) => json_error(500, &format!("Query error: {}", e)),
|
||||
}
|
||||
}
|
||||
137
backend/src/routes/audit.rs
Normal file
137
backend/src/routes/audit.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use rouille::{Request, Response};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
pub fn get_logs(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT a.id, a.user_id, u.username, a.session_id, a.event, \
|
||||
host(a.ip_address) as ip, a.user_agent, a.metadata::text, a.timestamp::text \
|
||||
FROM session_audit_log a \
|
||||
LEFT JOIN users u ON u.id = a.user_id \
|
||||
ORDER BY a.timestamp DESC LIMIT 100",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let logs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"user_id": row.get::<_, i32>(1),
|
||||
"username": row.get::<_, Option<String>>(2),
|
||||
"session_id": row.get::<_, Option<String>>(3),
|
||||
"event": row.get::<_, String>(4),
|
||||
"ip_address": row.get::<_, Option<String>>(5),
|
||||
"user_agent": row.get::<_, Option<String>>(6),
|
||||
"metadata": serde_json::from_str::<serde_json::Value>(
|
||||
&row.get::<_, String>(7)
|
||||
).unwrap_or(json!({})),
|
||||
"timestamp": row.get::<_, String>(8),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&logs)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Audit log query error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sync_logs(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, sync_job_id, entity_type, entity_id, action, status, \
|
||||
error_message, metadata::text, timestamp::text, resolution_status \
|
||||
FROM sync_logs ORDER BY timestamp DESC LIMIT 100",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let logs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"sync_job_id": row.get::<_, i32>(1),
|
||||
"entity_type": row.get::<_, String>(2),
|
||||
"entity_id": row.get::<_, String>(3),
|
||||
"action": row.get::<_, String>(4),
|
||||
"status": row.get::<_, String>(5),
|
||||
"error_message": row.get::<_, Option<String>>(6),
|
||||
"metadata": serde_json::from_str::<serde_json::Value>(
|
||||
&row.get::<_, String>(7)
|
||||
).unwrap_or(json!({})),
|
||||
"timestamp": row.get::<_, String>(8),
|
||||
"resolution_status": row.get::<_, Option<String>>(9),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&logs)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Sync log query error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let rows = match conn.query(
|
||||
"SELECT a.id, u.username, a.event, host(a.ip_address) as ip, \
|
||||
a.user_agent, a.timestamp::text \
|
||||
FROM session_audit_log a \
|
||||
LEFT JOIN users u ON u.id = a.user_id \
|
||||
ORDER BY a.timestamp DESC LIMIT 1000",
|
||||
&[],
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return json_error(500, &format!("Query error: {}", e)),
|
||||
};
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(Vec::new());
|
||||
let _ = wtr.write_record(["ID", "User", "Event", "IP Address", "User Agent", "Timestamp"]);
|
||||
for row in &rows {
|
||||
let _ = wtr.write_record(&[
|
||||
row.get::<_, i32>(0).to_string(),
|
||||
row.get::<_, Option<String>>(1).unwrap_or_default(),
|
||||
row.get::<_, String>(2),
|
||||
row.get::<_, Option<String>>(3).unwrap_or_default(),
|
||||
row.get::<_, Option<String>>(4).unwrap_or_default(),
|
||||
row.get::<_, String>(5),
|
||||
]);
|
||||
}
|
||||
let bytes = wtr.into_inner().unwrap_or_default();
|
||||
|
||||
let date = chrono::Utc::now().format("%Y-%m-%d");
|
||||
Response::from_data("text/csv; charset=utf-8", bytes).with_additional_header(
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"audit-logs-{}.csv\"", date),
|
||||
)
|
||||
}
|
||||
322
backend/src/routes/auth.rs
Normal file
322
backend/src/routes/auth.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use argon2::{
|
||||
password_hash::{PasswordHasher, PasswordVerifier},
|
||||
Argon2,
|
||||
};
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{
|
||||
client_ip, get_conn, get_session_cookie, json_error, require_auth, user_agent,
|
||||
};
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub fn login(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let form: LoginForm = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let user = match conn.query_opt(
|
||||
"SELECT id, username, email, role, password_hash, is_active, mfa_enabled, \
|
||||
failed_login_attempts, locked_until FROM users WHERE username = $1",
|
||||
&[&form.username],
|
||||
) {
|
||||
Ok(Some(row)) => {
|
||||
let is_active: bool = row.get(5);
|
||||
if !is_active {
|
||||
return json_error(401, "Invalid credentials");
|
||||
}
|
||||
|
||||
let locked_until: Option<chrono::NaiveDateTime> = row.get(8);
|
||||
if locked_until.is_some_and(|locked| locked > chrono::Utc::now().naive_utc()) {
|
||||
return json_error(423, "Account temporarily locked");
|
||||
}
|
||||
|
||||
let password_hash: String = row.get(4);
|
||||
let is_valid = match argon2::password_hash::PasswordHash::new(&password_hash) {
|
||||
Ok(h) => Argon2::default()
|
||||
.verify_password(form.password.as_bytes(), &h)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
if !is_valid {
|
||||
let user_id: i32 = row.get(0);
|
||||
let mut attempts: i32 = row.get(7);
|
||||
attempts += 1;
|
||||
let mut new_locked_until = locked_until;
|
||||
|
||||
if attempts >= 5 {
|
||||
new_locked_until = Some(
|
||||
chrono::Utc::now().naive_utc() + chrono::Duration::hours(1),
|
||||
);
|
||||
attempts = 0;
|
||||
}
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3",
|
||||
&[&attempts, &new_locked_until, &user_id],
|
||||
);
|
||||
return json_error(401, "Invalid credentials");
|
||||
}
|
||||
|
||||
(
|
||||
row.get::<_, i32>(0),
|
||||
row.get::<_, String>(1),
|
||||
row.get::<_, String>(2),
|
||||
row.get::<_, String>(3),
|
||||
)
|
||||
}
|
||||
Ok(None) => return json_error(401, "Invalid credentials"),
|
||||
Err(_) => return json_error(500, "Database error"),
|
||||
};
|
||||
|
||||
let (user_id, username, email, role) = user;
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let expires_at = chrono::Utc::now().naive_utc() + chrono::Duration::seconds(1800);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, user_agent) VALUES ($1, $2, $3, $4)",
|
||||
&[&session_id, &user_id, &expires_at, &"unknown"],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"UPDATE users SET failed_login_attempts = 0, locked_until = NULL, \
|
||||
last_login = CURRENT_TIMESTAMP WHERE id = $1",
|
||||
&[&user_id],
|
||||
);
|
||||
|
||||
let ip = client_ip(request);
|
||||
let ua = user_agent(request);
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO session_audit_log (user_id, session_id, event, ip_address, user_agent, metadata) \
|
||||
VALUES ($1, $2, 'login', $3, $4, '{}'::jsonb)",
|
||||
&[&user_id, &session_id, &ip, &ua],
|
||||
);
|
||||
|
||||
Response::json(&json!({
|
||||
"user": { "id": user_id, "username": username, "email": email, "role": role },
|
||||
"session_id": session_id
|
||||
}))
|
||||
.with_additional_header(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"session_id={}; Path=/; HttpOnly; SameSite=Strict; Max-Age=1800",
|
||||
session_id
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn logout(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let session_cookie = match get_session_cookie(request) {
|
||||
Some(c) => c,
|
||||
None => return json_error(401, "Authentication required"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if let Ok(Some(row)) = conn.query_opt(
|
||||
"SELECT user_id FROM sessions WHERE id = $1",
|
||||
&[&session_cookie],
|
||||
) {
|
||||
let uid: i32 = row.get(0);
|
||||
let ip = client_ip(request);
|
||||
let ua = user_agent(request);
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO session_audit_log (user_id, session_id, event, ip_address, user_agent, metadata) \
|
||||
VALUES ($1, $2, 'logout', $3, $4, '{}'::jsonb)",
|
||||
&[&uid, &session_cookie, &ip, &ua],
|
||||
);
|
||||
}
|
||||
|
||||
let _ = conn.execute("DELETE FROM sessions WHERE id = $1", &[&session_cookie]);
|
||||
|
||||
Response::json(&json!({"message": "Logged out"})).with_additional_header(
|
||||
"Set-Cookie",
|
||||
"session_id=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn me(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
match require_auth(request, &mut conn) {
|
||||
Ok(user) => Response::json(&json!({
|
||||
"id": user.id, "username": user.username,
|
||||
"email": user.email, "role": user.role,
|
||||
})),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn change_password(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let user = match require_auth(request, &mut conn) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let form: sap_sync_backend::models::PasswordChangeForm = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let current_hash: String = match conn.query_opt(
|
||||
"SELECT password_hash FROM users WHERE id = $1",
|
||||
&[&user.id],
|
||||
) {
|
||||
Ok(Some(row)) => row.get(0),
|
||||
_ => return json_error(500, "Database error"),
|
||||
};
|
||||
|
||||
let is_valid = match argon2::password_hash::PasswordHash::new(¤t_hash) {
|
||||
Ok(h) => Argon2::default()
|
||||
.verify_password(form.current_password.as_bytes(), &h)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
if !is_valid {
|
||||
return json_error(401, "Current password is incorrect");
|
||||
}
|
||||
|
||||
let salt = argon2::password_hash::SaltString::generate(rand::thread_rng());
|
||||
let new_hash = match Argon2::default().hash_password(form.new_password.as_bytes(), &salt) {
|
||||
Ok(h) => h.to_string(),
|
||||
Err(_) => return json_error(500, "Failed to hash password"),
|
||||
};
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
||||
&[&new_hash, &user.id],
|
||||
);
|
||||
Response::json(&json!({"message": "Password changed successfully"}))
|
||||
}
|
||||
|
||||
pub fn mfa_setup(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let user = match require_auth(request, &mut conn) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
use rand::RngCore;
|
||||
let mut secret_bytes = [0u8; 20];
|
||||
rand::thread_rng().fill_bytes(&mut secret_bytes);
|
||||
let secret_b32 = super::base32_encode(&secret_bytes);
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE users SET mfa_secret = $1 WHERE id = $2",
|
||||
&[&secret_b32, &user.id],
|
||||
);
|
||||
|
||||
// Generate and store hashed backup codes
|
||||
let mut backup_codes: Vec<String> = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let code = format!("{:08}", rand::thread_rng().next_u32() % 100_000_000);
|
||||
let salt = argon2::password_hash::SaltString::generate(&mut rand::thread_rng());
|
||||
if let Ok(hash) = Argon2::default().hash_password(code.as_bytes(), &salt) {
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)",
|
||||
&[&user.id, &hash.to_string()],
|
||||
);
|
||||
}
|
||||
backup_codes.push(code);
|
||||
}
|
||||
|
||||
let qr_url = format!(
|
||||
"otpauth://totp/SAP-PLEX-SYNC:{}?secret={}&issuer=SAP-PLEX-SYNC&digits=6&period=30",
|
||||
user.username, secret_b32
|
||||
);
|
||||
|
||||
Response::json(&json!({
|
||||
"method": "totp",
|
||||
"secret": secret_b32,
|
||||
"qr_code_url": qr_url,
|
||||
"backup_codes": backup_codes,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn mfa_verify(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
#[derive(Deserialize)]
|
||||
struct MfaVerifyForm {
|
||||
code: String,
|
||||
}
|
||||
|
||||
let form: MfaVerifyForm = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let session_cookie = match get_session_cookie(request) {
|
||||
Some(c) => c,
|
||||
None => return json_error(401, "Not authenticated"),
|
||||
};
|
||||
|
||||
let row = match conn.query_opt(
|
||||
"SELECT u.id, u.mfa_secret FROM users u \
|
||||
JOIN sessions s ON u.id = s.user_id \
|
||||
WHERE s.id = $1 AND s.expires_at > CURRENT_TIMESTAMP",
|
||||
&[&session_cookie],
|
||||
) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return json_error(401, "Invalid session"),
|
||||
Err(_) => return json_error(500, "Database error"),
|
||||
};
|
||||
|
||||
let user_id: i32 = row.get("id");
|
||||
let mfa_secret: Option<String> = row.get("mfa_secret");
|
||||
|
||||
let secret = match mfa_secret {
|
||||
Some(s) => s,
|
||||
None => return json_error(400, "MFA not set up"),
|
||||
};
|
||||
|
||||
let secret_bytes = match super::base32_decode(&secret) {
|
||||
Some(b) => b,
|
||||
None => return json_error(500, "Invalid MFA secret"),
|
||||
};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let expected = totp_lite::totp_custom::<totp_lite::Sha1>(30, 6, &secret_bytes, now);
|
||||
|
||||
if form.code == expected {
|
||||
let _ = conn.execute(
|
||||
"UPDATE users SET mfa_enabled = TRUE WHERE id = $1",
|
||||
&[&user_id],
|
||||
);
|
||||
Response::json(&json!({"message": "MFA enabled successfully"}))
|
||||
} else {
|
||||
json_error(400, "Invalid verification code")
|
||||
}
|
||||
}
|
||||
265
backend/src/routes/billing.rs
Normal file
265
backend/src/routes/billing.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
pub fn list_pricing(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, metric_type::text, unit, rate_per_unit::float8, is_active \
|
||||
FROM pricing_config ORDER BY metric_type",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let configs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"metric_type": row.get::<_, String>(1),
|
||||
"unit": row.get::<_, String>(2),
|
||||
"rate_per_unit": row.get::<_, f64>(3),
|
||||
"is_active": row.get::<_, bool>(4),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&configs)
|
||||
}
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_pricing(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let config: sap_sync_backend::billing_system::PricingConfig = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
if let Ok(Some(_)) = conn.query_opt(
|
||||
"SELECT id FROM pricing_config WHERE metric_type = $1::text::metric_type",
|
||||
&[&config.metric_type],
|
||||
) {
|
||||
return json_error(400, "Pricing config already exists");
|
||||
}
|
||||
|
||||
let rate_str = config.price_per_unit.to_string();
|
||||
match conn.query_one(
|
||||
"INSERT INTO pricing_config (metric_type, unit, rate_per_unit, is_active) \
|
||||
VALUES ($1::text::metric_type, $2, $3::text::numeric, $4) RETURNING id",
|
||||
&[&config.metric_type, &config.unit, &rate_str, &config.is_active],
|
||||
) {
|
||||
Ok(r) => Response::json(&json!({
|
||||
"id": r.get::<_, i32>(0),
|
||||
"metric_type": config.metric_type,
|
||||
"unit": config.unit,
|
||||
"rate_per_unit": config.price_per_unit,
|
||||
"is_active": config.is_active,
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_records(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT b.id, b.customer_id, b.subscription_id, b.period_start::text, b.period_end::text, \
|
||||
b.calculated_amount::float8, b.currency, b.invoice_status, b.created_at::text, \
|
||||
b.sent_to_sap_at IS NOT NULL as sent_to_sap, \
|
||||
COALESCE(c.name, 'Customer ' || b.customer_id::text) as customer_name \
|
||||
FROM billing_records b \
|
||||
LEFT JOIN customers c ON c.id = b.customer_id \
|
||||
ORDER BY b.created_at DESC",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let records: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"customer_id": row.get::<_, i32>(1),
|
||||
"subscription_id": row.get::<_, i32>(2),
|
||||
"period_start": row.get::<_, String>(3),
|
||||
"period_end": row.get::<_, String>(4),
|
||||
"calculated_amount": row.get::<_, f64>(5),
|
||||
"currency": row.get::<_, String>(6),
|
||||
"invoice_status": row.get::<_, String>(7),
|
||||
"created_at": row.get::<_, String>(8),
|
||||
"sent_to_sap": row.get::<_, bool>(9),
|
||||
"customer_name": row.get::<_, String>(10),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&records)
|
||||
}
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let data: serde_json::Value = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let customer_id = data.get("customer_id").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
let period_start = data.get("period_start").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let period_end = data.get("period_end").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let zero = "0".to_string();
|
||||
|
||||
match conn.query_one(
|
||||
"INSERT INTO billing_records (customer_id, subscription_id, period_start, period_end, \
|
||||
usage_data, calculated_amount, currency, invoice_status) \
|
||||
VALUES ($1, $2, $3::date, $4::date, '{}'::jsonb, $5::text::numeric, $6, $7) RETURNING id",
|
||||
&[&customer_id, &None::<i32>, &period_start, &period_end, &zero, &"EUR", &"pending"],
|
||||
) {
|
||||
Ok(r) => Response::json(&json!({"id": r.get::<_, i32>(0), "message": "Invoice generated successfully"})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preview(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let record = match conn.query_opt(
|
||||
"SELECT b.id, COALESCE(c.name, 'Customer ' || b.customer_id::text), \
|
||||
b.period_start::text, b.period_end::text, b.calculated_amount::float8, \
|
||||
b.currency, b.invoice_status, b.usage_data::text \
|
||||
FROM billing_records b \
|
||||
LEFT JOIN customers c ON c.id = b.customer_id \
|
||||
WHERE b.id = $1",
|
||||
&[&id],
|
||||
) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return json_error(404, "Billing record not found"),
|
||||
Err(e) => {
|
||||
log::error!("Billing preview query error: {}", e);
|
||||
return json_error(500, "Database error");
|
||||
}
|
||||
};
|
||||
|
||||
let amount: f64 = record.get(4);
|
||||
let usage_data_str: String = record.get(7);
|
||||
let usage_data: serde_json::Value =
|
||||
serde_json::from_str(&usage_data_str).unwrap_or(json!({}));
|
||||
|
||||
// Build line items from usage_data if available
|
||||
let line_items: Vec<serde_json::Value> = if let Some(items) = usage_data.as_array() {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
json!({
|
||||
"description": item.get("description").and_then(|v| v.as_str()).unwrap_or("Service"),
|
||||
"quantity": item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(1.0),
|
||||
"unit": item.get("unit").and_then(|v| v.as_str()).unwrap_or("unit"),
|
||||
"rate": item.get("rate").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"amount": item.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![json!({
|
||||
"description": "Hosting Services",
|
||||
"quantity": 1,
|
||||
"unit": "month",
|
||||
"rate": amount,
|
||||
"amount": amount,
|
||||
})]
|
||||
};
|
||||
|
||||
let subtotal = amount;
|
||||
let tax = (subtotal * 0.19 * 100.0).round() / 100.0; // 19% VAT
|
||||
let total = subtotal + tax;
|
||||
|
||||
Response::json(&json!({
|
||||
"customer_name": record.get::<_, String>(1),
|
||||
"period_start": record.get::<_, String>(2),
|
||||
"period_end": record.get::<_, String>(3),
|
||||
"line_items": line_items,
|
||||
"subtotal": subtotal,
|
||||
"tax": tax,
|
||||
"total": total,
|
||||
"currency": record.get::<_, String>(5),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn send_to_sap_by_id(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE billing_records SET sent_to_sap_at = NOW() WHERE id = $1",
|
||||
&[&id],
|
||||
);
|
||||
Response::json(&json!({"message": "Invoice sent to SAP successfully"}))
|
||||
}
|
||||
|
||||
pub fn send_to_sap(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let form: sap_sync_backend::models::BillingRecordId = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE billing_records SET sent_to_sap_at = NOW() WHERE id = $1",
|
||||
&[&form.id],
|
||||
);
|
||||
Response::json(&json!({"message": "Invoice sent to SAP successfully"}))
|
||||
}
|
||||
65
backend/src/routes/health.rs
Normal file
65
backend/src/routes/health.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
pub fn get_health(_request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let _conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let healthy = state.pool.state().connections > 0;
|
||||
Response::json(&json!({
|
||||
"status": "healthy",
|
||||
"database": { "status": "connected", "healthy": healthy }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn get_config(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query("SELECT key, value::text FROM config ORDER BY key", &[]) {
|
||||
Ok(rows) => {
|
||||
let config: std::collections::HashMap<String, serde_json::Value> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let key: String = row.get(0);
|
||||
let value: String = row.get(1);
|
||||
(key, serde_json::from_str(&value).unwrap_or(serde_json::Value::Null))
|
||||
})
|
||||
.collect();
|
||||
Response::json(&json!({ "config": config }))
|
||||
}
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put_config(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let form: sap_sync_backend::models::ConfigUpdate = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO config (key, value) VALUES ($1, $2::text::jsonb) \
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2::text::jsonb",
|
||||
&[&form.key, &serde_json::to_string(&form.value).unwrap_or_default()],
|
||||
);
|
||||
Response::json(&json!({"message": "Config updated"}))
|
||||
}
|
||||
87
backend/src/routes/helpers.rs
Normal file
87
backend/src/routes/helpers.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use postgres::NoTls;
|
||||
use r2d2::PooledConnection;
|
||||
use r2d2_postgres::PostgresConnectionManager;
|
||||
use rouille::{Request, Response};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::routes::AppState;
|
||||
|
||||
type PgConn = PooledConnection<PostgresConnectionManager<NoTls>>;
|
||||
|
||||
/// Standard JSON error response with HTTP status code.
|
||||
pub fn json_error(status: u16, error: &str) -> Response {
|
||||
Response::json(&json!({"error": error})).with_status_code(status)
|
||||
}
|
||||
|
||||
/// Get a database connection from the pool, returning a 500 error response on failure.
|
||||
pub fn get_conn(state: &Arc<AppState>) -> Result<PgConn, Response> {
|
||||
state
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|_| json_error(500, "Database connection error"))
|
||||
}
|
||||
|
||||
/// Authenticated user information extracted from a valid session.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AuthUser {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Validate the session cookie and return the authenticated user.
|
||||
/// Returns 401 if no cookie, expired session, or invalid session.
|
||||
pub fn require_auth(request: &Request, conn: &mut PgConn) -> Result<AuthUser, Response> {
|
||||
let session_cookie = get_session_cookie(request)
|
||||
.ok_or_else(|| json_error(401, "Authentication required"))?;
|
||||
|
||||
let row = conn
|
||||
.query_opt(
|
||||
"SELECT u.id, u.username, u.email, u.role \
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id \
|
||||
WHERE s.id = $1 AND s.expires_at > NOW()",
|
||||
&[&session_cookie],
|
||||
)
|
||||
.map_err(|_| json_error(500, "Database error"))?
|
||||
.ok_or_else(|| json_error(401, "Session not found or expired"))?;
|
||||
|
||||
Ok(AuthUser {
|
||||
id: row.get(0),
|
||||
username: row.get(1),
|
||||
email: row.get(2),
|
||||
role: row.get(3),
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the session_id cookie value from the request.
|
||||
pub fn get_session_cookie(request: &Request) -> Option<String> {
|
||||
request.header("Cookie").and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.find(|c| c.trim().starts_with("session_id="))
|
||||
.map(|c| c.trim().trim_start_matches("session_id=").to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the client IP address from proxy headers or fallback to localhost.
|
||||
pub fn client_ip(request: &Request) -> IpAddr {
|
||||
let ip_str = request
|
||||
.header("X-Real-IP")
|
||||
.or_else(|| request.header("X-Forwarded-For"))
|
||||
.unwrap_or("127.0.0.1");
|
||||
ip_str
|
||||
.parse()
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
/// Extract User-Agent header with a fallback.
|
||||
pub fn user_agent(request: &Request) -> String {
|
||||
request
|
||||
.header("User-Agent")
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
69
backend/src/routes/mod.rs
Normal file
69
backend/src/routes/mod.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
pub mod alerts;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod health;
|
||||
pub mod helpers;
|
||||
pub mod reports;
|
||||
pub mod schedules;
|
||||
pub mod servers;
|
||||
pub mod setup;
|
||||
pub mod sync;
|
||||
pub mod webhooks;
|
||||
|
||||
use postgres::NoTls;
|
||||
use r2d2::Pool;
|
||||
use r2d2_postgres::PostgresConnectionManager;
|
||||
|
||||
pub type PgPool = Pool<PostgresConnectionManager<NoTls>>;
|
||||
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub admin_username: String,
|
||||
pub admin_email: String,
|
||||
pub admin_password: String,
|
||||
}
|
||||
|
||||
/// RFC 4648 base32 encode (no padding).
|
||||
pub fn base32_encode(data: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut result = String::new();
|
||||
let mut buffer: u64 = 0;
|
||||
let mut bits_left = 0;
|
||||
for &byte in data {
|
||||
buffer = (buffer << 8) | byte as u64;
|
||||
bits_left += 8;
|
||||
while bits_left >= 5 {
|
||||
bits_left -= 5;
|
||||
result.push(ALPHABET[((buffer >> bits_left) & 0x1F) as usize] as char);
|
||||
}
|
||||
}
|
||||
if bits_left > 0 {
|
||||
buffer <<= 5 - bits_left;
|
||||
result.push(ALPHABET[(buffer & 0x1F) as usize] as char);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// RFC 4648 base32 decode.
|
||||
pub fn base32_decode(input: &str) -> Option<Vec<u8>> {
|
||||
let input = input.trim_end_matches('=');
|
||||
let mut buffer: u64 = 0;
|
||||
let mut bits_left = 0;
|
||||
let mut result = Vec::new();
|
||||
for c in input.chars() {
|
||||
let val = match c {
|
||||
'A'..='Z' => c as u64 - 'A' as u64,
|
||||
'2'..='7' => c as u64 - '2' as u64 + 26,
|
||||
'a'..='z' => c as u64 - 'a' as u64,
|
||||
_ => return None,
|
||||
};
|
||||
buffer = (buffer << 5) | val;
|
||||
bits_left += 5;
|
||||
if bits_left >= 8 {
|
||||
bits_left -= 8;
|
||||
result.push(((buffer >> bits_left) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
296
backend/src/routes/reports.rs
Normal file
296
backend/src/routes/reports.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use rouille::{Request, Response};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
/// GET /api/reports/export/{format}?type={reportType}&range={dateRange}&billing_id={id}
|
||||
pub fn export(request: &Request, state: &Arc<AppState>, format: &str) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let qs = request.raw_query_string();
|
||||
let params = parse_query(&qs);
|
||||
let report_type = params.iter().find(|(k,_)| *k == "type").map(|(_,v)| *v).unwrap_or("sync");
|
||||
let date_range = params.iter().find(|(k,_)| *k == "range").map(|(_,v)| *v).unwrap_or("7d");
|
||||
let billing_id = params.iter().find(|(k,_)| *k == "billing_id").and_then(|(_,v)| v.parse::<i32>().ok());
|
||||
|
||||
if let Some(bid) = billing_id {
|
||||
return export_billing_record(&mut conn, bid, format);
|
||||
}
|
||||
|
||||
match report_type {
|
||||
"sync" => export_sync_report(&mut conn, date_range, format),
|
||||
"usage" => export_usage_report(&mut conn, date_range, format),
|
||||
"revenue" => export_revenue_report(&mut conn, date_range, format),
|
||||
"billing" => export_billing_report(&mut conn, date_range, format),
|
||||
"audit" => export_audit_report(&mut conn, date_range, format),
|
||||
_ => export_sync_report(&mut conn, date_range, format),
|
||||
}
|
||||
}
|
||||
|
||||
fn export_sync_report(conn: &mut postgres::Client, range: &str, fmt: &str) -> Response {
|
||||
let interval = range_to_interval(range);
|
||||
let sql = format!(
|
||||
"SELECT id, job_type, sync_direction, status::text, records_processed, \
|
||||
records_failed, created_at::text, completed_at::text \
|
||||
FROM sync_jobs WHERE created_at > NOW() - INTERVAL '{}' \
|
||||
ORDER BY created_at DESC", interval);
|
||||
let rows = match conn.query(sql.as_str(), &[]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Sync report query error: {}", e);
|
||||
return json_error(500, "Database error");
|
||||
}
|
||||
};
|
||||
let headers = &["ID","Job Type","Direction","Status","Processed","Failed","Created","Completed"];
|
||||
let data: Vec<Vec<String>> = rows.iter().map(|r| vec![
|
||||
r.get::<_,i32>(0).to_string(), r.get::<_,String>(1), r.get::<_,String>(2),
|
||||
r.get::<_,String>(3), r.get::<_,i32>(4).to_string(), r.get::<_,i32>(5).to_string(),
|
||||
r.get::<_,String>(6), r.get::<_,Option<String>>(7).unwrap_or_default(),
|
||||
]).collect();
|
||||
render(headers, &data, fmt, "sync-report")
|
||||
}
|
||||
|
||||
fn export_usage_report(conn: &mut postgres::Client, range: &str, fmt: &str) -> Response {
|
||||
let interval = range_to_interval(range);
|
||||
let sql = format!(
|
||||
"SELECT id, subscription_id, metric_type::text, metric_value::float8, \
|
||||
COALESCE(unit, ''), recorded_at::text \
|
||||
FROM usage_metrics WHERE recorded_at > NOW() - INTERVAL '{}' \
|
||||
ORDER BY recorded_at DESC", interval);
|
||||
let rows = match conn.query(sql.as_str(), &[]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Usage report query error: {}", e);
|
||||
return json_error(500, "Database error");
|
||||
}
|
||||
};
|
||||
let headers = &["ID","Subscription","Metric Type","Value","Unit","Recorded At"];
|
||||
let data: Vec<Vec<String>> = rows.iter().map(|r| vec![
|
||||
r.get::<_,i32>(0).to_string(), r.get::<_,i32>(1).to_string(),
|
||||
r.get::<_,String>(2), format!("{:.2}", r.get::<_,f64>(3)),
|
||||
r.get::<_,String>(4), r.get::<_,String>(5),
|
||||
]).collect();
|
||||
render(headers, &data, fmt, "usage-report")
|
||||
}
|
||||
|
||||
fn export_revenue_report(conn: &mut postgres::Client, range: &str, fmt: &str) -> Response {
|
||||
let interval = range_to_interval(range);
|
||||
let sql = format!(
|
||||
"SELECT b.id, COALESCE(c.name, 'Customer ' || b.customer_id::text), \
|
||||
b.period_start::text, b.period_end::text, b.calculated_amount::float8, \
|
||||
b.currency, b.invoice_status, b.created_at::text \
|
||||
FROM billing_records b \
|
||||
LEFT JOIN customers c ON c.id = b.customer_id \
|
||||
WHERE b.created_at > NOW() - INTERVAL '{}' \
|
||||
ORDER BY b.created_at DESC", interval);
|
||||
let rows = match conn.query(sql.as_str(), &[]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Revenue report query error: {}", e);
|
||||
return json_error(500, "Database error");
|
||||
}
|
||||
};
|
||||
let headers = &["ID","Customer","Period Start","Period End","Amount","Currency","Status","Created"];
|
||||
let data: Vec<Vec<String>> = rows.iter().map(|r| vec![
|
||||
r.get::<_,i32>(0).to_string(), r.get::<_,String>(1),
|
||||
r.get::<_,String>(2), r.get::<_,String>(3),
|
||||
format!("{:.2}", r.get::<_,f64>(4)), r.get::<_,String>(5),
|
||||
r.get::<_,String>(6), r.get::<_,String>(7),
|
||||
]).collect();
|
||||
render(headers, &data, fmt, "revenue-report")
|
||||
}
|
||||
|
||||
fn export_billing_report(conn: &mut postgres::Client, range: &str, fmt: &str) -> Response {
|
||||
let interval = range_to_interval(range);
|
||||
let sql = format!(
|
||||
"SELECT id, customer_id, period_start::text, period_end::text, \
|
||||
calculated_amount::float8, currency, invoice_status, created_at::text \
|
||||
FROM billing_records WHERE created_at > NOW() - INTERVAL '{}' \
|
||||
ORDER BY created_at DESC", interval);
|
||||
let rows = match conn.query(sql.as_str(), &[]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Billing report query error: {}", e);
|
||||
return json_error(500, "Database error");
|
||||
}
|
||||
};
|
||||
let headers = &["ID","Customer","Period Start","Period End","Amount","Currency","Status","Created"];
|
||||
let data: Vec<Vec<String>> = rows.iter().map(|r| vec![
|
||||
r.get::<_,i32>(0).to_string(), r.get::<_,i32>(1).to_string(),
|
||||
r.get::<_,String>(2), r.get::<_,String>(3),
|
||||
format!("{:.2}", r.get::<_,f64>(4)), r.get::<_,String>(5),
|
||||
r.get::<_,String>(6), r.get::<_,String>(7),
|
||||
]).collect();
|
||||
render(headers, &data, fmt, "billing-report")
|
||||
}
|
||||
|
||||
fn export_audit_report(conn: &mut postgres::Client, range: &str, fmt: &str) -> Response {
|
||||
let interval = range_to_interval(range);
|
||||
let sql = format!(
|
||||
"SELECT a.id, u.username, a.event, host(a.ip_address) as ip, \
|
||||
a.user_agent, a.timestamp::text \
|
||||
FROM session_audit_log a LEFT JOIN users u ON u.id = a.user_id \
|
||||
WHERE a.timestamp > NOW() - INTERVAL '{}' ORDER BY a.timestamp DESC", interval);
|
||||
let rows = match conn.query(sql.as_str(), &[]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Audit report query error: {}", e);
|
||||
return json_error(500, "Database error");
|
||||
}
|
||||
};
|
||||
let headers = &["ID","User","Event","IP","User Agent","Timestamp"];
|
||||
let data: Vec<Vec<String>> = rows.iter().map(|r| vec![
|
||||
r.get::<_,i32>(0).to_string(),
|
||||
r.get::<_,Option<String>>(1).unwrap_or_default(),
|
||||
r.get::<_,String>(2),
|
||||
r.get::<_,Option<String>>(3).unwrap_or_default(),
|
||||
r.get::<_,Option<String>>(4).unwrap_or_default(),
|
||||
r.get::<_,String>(5),
|
||||
]).collect();
|
||||
render(headers, &data, fmt, "audit-report")
|
||||
}
|
||||
|
||||
fn export_billing_record(conn: &mut postgres::Client, id: i32, fmt: &str) -> Response {
|
||||
let row = match conn.query_opt(
|
||||
"SELECT b.id, b.customer_id, c.name, b.period_start::text, b.period_end::text, \
|
||||
b.calculated_amount::float8, b.currency, b.invoice_status, b.created_at::text \
|
||||
FROM billing_records b LEFT JOIN customers c ON c.id = b.customer_id WHERE b.id = $1",
|
||||
&[&id],
|
||||
) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return json_error(404, "Billing record not found"),
|
||||
Err(e) => {
|
||||
log::error!("Billing record query error: {}", e);
|
||||
return json_error(500, "Database error");
|
||||
}
|
||||
};
|
||||
let headers = &["ID","Customer ID","Customer","Start","End","Amount","Currency","Status","Created"];
|
||||
let data = vec![vec![
|
||||
row.get::<_,i32>(0).to_string(), row.get::<_,i32>(1).to_string(),
|
||||
row.get::<_,Option<String>>(2).unwrap_or_else(|| "N/A".into()),
|
||||
row.get::<_,String>(3), row.get::<_,String>(4),
|
||||
format!("{:.2}", row.get::<_,f64>(5)),
|
||||
row.get::<_,String>(6), row.get::<_,String>(7), row.get::<_,String>(8),
|
||||
]];
|
||||
render(headers, &data, fmt, &format!("invoice-{}", id))
|
||||
}
|
||||
|
||||
fn render(headers: &[&str], data: &[Vec<String>], fmt: &str, name: &str) -> Response {
|
||||
match fmt {
|
||||
"csv" => render_csv(headers, data, name),
|
||||
"xlsx" => render_xlsx(headers, data, name),
|
||||
"pdf" => render_pdf(headers, data, name),
|
||||
_ => json_error(400, &format!("Unsupported format: {}", fmt)),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_csv(headers: &[&str], data: &[Vec<String>], name: &str) -> Response {
|
||||
let mut wtr = csv::Writer::from_writer(Vec::new());
|
||||
let _ = wtr.write_record(headers);
|
||||
for row in data { let _ = wtr.write_record(row); }
|
||||
let bytes = wtr.into_inner().unwrap_or_default();
|
||||
Response::from_data("text/csv; charset=utf-8", bytes).with_additional_header(
|
||||
"Content-Disposition", format!("attachment; filename=\"{}.csv\"", name))
|
||||
}
|
||||
|
||||
fn render_xlsx(headers: &[&str], data: &[Vec<String>], name: &str) -> Response {
|
||||
let mut wb = rust_xlsxwriter::Workbook::new();
|
||||
let ws = wb.add_worksheet();
|
||||
let bold = rust_xlsxwriter::Format::new().set_bold();
|
||||
for (c, h) in headers.iter().enumerate() {
|
||||
let _ = ws.write_string_with_format(0, c as u16, *h, &bold);
|
||||
}
|
||||
for (ri, row) in data.iter().enumerate() {
|
||||
for (ci, cell) in row.iter().enumerate() {
|
||||
if let Ok(n) = cell.parse::<f64>() {
|
||||
let _ = ws.write_number((ri+1) as u32, ci as u16, n);
|
||||
} else {
|
||||
let _ = ws.write_string((ri+1) as u32, ci as u16, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
match wb.save_to_buffer() {
|
||||
Ok(buf) => Response::from_data(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buf,
|
||||
).with_additional_header("Content-Disposition", format!("attachment; filename=\"{}.xlsx\"", name)),
|
||||
Err(e) => json_error(500, &format!("XLSX error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pdf(headers: &[&str], data: &[Vec<String>], name: &str) -> Response {
|
||||
let pdf = build_pdf(headers, data, name);
|
||||
Response::from_data("application/pdf", pdf).with_additional_header(
|
||||
"Content-Disposition", format!("attachment; filename=\"{}.pdf\"", name))
|
||||
}
|
||||
|
||||
fn build_pdf(headers: &[&str], data: &[Vec<String>], title: &str) -> Vec<u8> {
|
||||
let date = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
|
||||
let esc = |s: &str| s.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
|
||||
let mut lines: Vec<String> = vec![
|
||||
esc(title), esc(&format!("Generated: {}", date)), String::new(),
|
||||
esc(&headers.join(" | ")), "-".repeat(78),
|
||||
];
|
||||
if data.is_empty() {
|
||||
lines.push("No data available.".into());
|
||||
} else {
|
||||
for row in data {
|
||||
let line = esc(&row.join(" | "));
|
||||
if line.len() > 95 { lines.push(line[..95].to_string()); } else { lines.push(line); }
|
||||
}
|
||||
}
|
||||
let lh = 14; let ys = 750;
|
||||
let mut pages: Vec<String> = Vec::new();
|
||||
let mut page = String::new(); let mut y = ys;
|
||||
for line in &lines {
|
||||
if y < 50 { pages.push(page.clone()); page.clear(); y = ys; }
|
||||
page.push_str(&format!("BT /F1 9 Tf 40 {} Td ({}) Tj ET\n", y, line));
|
||||
y -= lh;
|
||||
}
|
||||
if !page.is_empty() { pages.push(page); }
|
||||
if pages.is_empty() { pages.push("BT /F1 9 Tf 40 750 Td (Empty report) Tj ET\n".into()); }
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut off: Vec<usize> = Vec::new();
|
||||
buf.extend_from_slice(b"%PDF-1.4\n");
|
||||
off.push(buf.len());
|
||||
buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
||||
let np = pages.len();
|
||||
off.push(buf.len());
|
||||
let kids: String = (0..np).map(|i| format!("{} 0 R", 4+i*2)).collect::<Vec<_>>().join(" ");
|
||||
buf.extend_from_slice(format!("2 0 obj\n<< /Type /Pages /Kids [{}] /Count {} >>\nendobj\n", kids, np).as_bytes());
|
||||
off.push(buf.len());
|
||||
buf.extend_from_slice(b"3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n");
|
||||
for (i, c) in pages.iter().enumerate() {
|
||||
let po = 4+i*2; let so = po+1;
|
||||
off.push(buf.len());
|
||||
buf.extend_from_slice(format!("{} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>\nendobj\n", po, so).as_bytes());
|
||||
off.push(buf.len());
|
||||
buf.extend_from_slice(format!("{} 0 obj\n<< /Length {} >>\nstream\n{}endstream\nendobj\n", so, c.len(), c).as_bytes());
|
||||
}
|
||||
let xo = buf.len();
|
||||
buf.extend_from_slice(format!("xref\n0 {}\n", off.len()+1).as_bytes());
|
||||
buf.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for o in &off { buf.extend_from_slice(format!("{:010} 00000 n \n", o).as_bytes()); }
|
||||
buf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", off.len()+1, xo).as_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
fn range_to_interval(range: &str) -> &str {
|
||||
match range { "24h"=>"1 day", "7d"=>"7 days", "30d"=>"30 days", "90d"=>"90 days", "1y"=>"1 year", _=>"7 days" }
|
||||
}
|
||||
|
||||
fn parse_query(qs: &str) -> Vec<(&str, &str)> {
|
||||
qs.split('&').filter_map(|p| {
|
||||
let mut parts = p.splitn(2, '=');
|
||||
let k = parts.next()?;
|
||||
let v = parts.next().unwrap_or("");
|
||||
if k.is_empty() { None } else { Some((k, v)) }
|
||||
}).collect()
|
||||
}
|
||||
139
backend/src/routes/schedules.rs
Normal file
139
backend/src/routes/schedules.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
pub fn list(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, name, schedule_type, schedule_config::text, job_type, sync_direction, \
|
||||
is_active, last_run::text, next_run::text \
|
||||
FROM scheduled_syncs ORDER BY created_at DESC",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let syncs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let config_str: String = row.get(3);
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(&config_str).unwrap_or(json!({}));
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"name": row.get::<_, String>(1),
|
||||
"schedule_type": row.get::<_, String>(2),
|
||||
"schedule_config": config,
|
||||
"job_type": row.get::<_, String>(4),
|
||||
"sync_direction": row.get::<_, String>(5),
|
||||
"is_active": row.get::<_, bool>(6),
|
||||
"last_run": row.get::<_, Option<String>>(7),
|
||||
"next_run": row.get::<_, Option<String>>(8),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&syncs)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let data: serde_json::Value = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let schedule_type = data.get("schedule_type").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let schedule_config = data.get("schedule_config").cloned().unwrap_or_default();
|
||||
let job_type = data.get("job_type").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let sync_direction = data.get("sync_direction").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let config_str = serde_json::to_string(&schedule_config).unwrap_or_default();
|
||||
|
||||
match conn.query_one(
|
||||
"INSERT INTO scheduled_syncs \
|
||||
(name, schedule_type, schedule_config, job_type, sync_direction, \
|
||||
plesk_server_id, sap_server_id, is_active, created_at) \
|
||||
VALUES ($1, $2, $3::text::jsonb, $4, $5, $6, $7, true, NOW()) RETURNING id",
|
||||
&[&name, &schedule_type, &config_str, &job_type, &sync_direction, &None::<i32>, &None::<i32>],
|
||||
) {
|
||||
Ok(r) => Response::json(&json!({"id": r.get::<_, i32>(0), "message": "Scheduled sync created successfully"})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
#[derive(Deserialize)]
|
||||
struct Form {
|
||||
name: Option<String>,
|
||||
schedule_type: Option<String>,
|
||||
job_type: Option<String>,
|
||||
sync_direction: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
}
|
||||
|
||||
let form: Form = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE scheduled_syncs SET name=COALESCE($1, name), \
|
||||
schedule_type=COALESCE($2, schedule_type), job_type=COALESCE($3, job_type), \
|
||||
sync_direction=COALESCE($4, sync_direction), is_active=COALESCE($5, is_active) \
|
||||
WHERE id=$6",
|
||||
&[&form.name, &form.schedule_type, &form.job_type, &form.sync_direction, &form.is_active, &id],
|
||||
) {
|
||||
Ok(0) => json_error(404, "Schedule not found"),
|
||||
Ok(_) => Response::json(&json!({"message": "Schedule updated"})),
|
||||
Err(e) => json_error(500, &format!("Update error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.execute("DELETE FROM scheduled_syncs WHERE id = $1", &[&id]) {
|
||||
Ok(0) => json_error(404, "Schedule not found"),
|
||||
Ok(_) => Response::json(&json!({"message": "Schedule deleted"})),
|
||||
Err(e) => json_error(500, &format!("Delete error: {}", e)),
|
||||
}
|
||||
}
|
||||
516
backend/src/routes/servers.rs
Normal file
516
backend/src/routes/servers.rs
Normal file
@@ -0,0 +1,516 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
// ==================== Plesk Servers ====================
|
||||
|
||||
pub fn list_plesk(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, name, host, port, connection_status, is_active \
|
||||
FROM plesk_servers ORDER BY name",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let servers: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"name": row.get::<_, String>(1),
|
||||
"host": row.get::<_, String>(2),
|
||||
"port": row.get::<_, i32>(3),
|
||||
"connection_status": row.get::<_, String>(4),
|
||||
"is_active": row.get::<_, bool>(5),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&servers)
|
||||
}
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_plesk(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let data: serde_json::Value = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let name = str_field(&data, "name");
|
||||
let host = str_field(&data, "host");
|
||||
if name.is_empty() || host.is_empty() {
|
||||
return json_error(400, "Name and host are required");
|
||||
}
|
||||
|
||||
let port = data.get("port").and_then(|v| v.as_i64()).unwrap_or(8443) as i32;
|
||||
let api_key = str_field(&data, "api_key");
|
||||
let username = str_field(&data, "username");
|
||||
let password = str_field(&data, "password");
|
||||
let use_https = data.get("use_https").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let verify_ssl = data.get("verify_ssl").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
match conn.query_one(
|
||||
"INSERT INTO plesk_servers (name, host, port, api_key, username, password_hash, \
|
||||
use_https, verify_ssl, connection_status, is_active, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'unknown', true, NOW()) RETURNING id",
|
||||
&[&name, &host, &port, &api_key, &username, &password, &use_https, &verify_ssl],
|
||||
) {
|
||||
Ok(r) => Response::json(&json!({
|
||||
"id": r.get::<_, i32>(0), "name": name, "host": host, "port": port,
|
||||
"message": "Plesk server created successfully"
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Failed to create server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_plesk(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query_opt(
|
||||
"SELECT id, name, host, port, connection_status, is_active \
|
||||
FROM plesk_servers WHERE id = $1",
|
||||
&[&server_id],
|
||||
) {
|
||||
Ok(Some(row)) => Response::json(&json!({
|
||||
"id": row.get::<_, i32>(0), "name": row.get::<_, String>(1),
|
||||
"host": row.get::<_, String>(2), "port": row.get::<_, i32>(3),
|
||||
"connection_status": row.get::<_, String>(4), "is_active": row.get::<_, bool>(5),
|
||||
})),
|
||||
Ok(None) => json_error(404, "Server not found"),
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_plesk(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let data: serde_json::Value = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let name = str_field(&data, "name");
|
||||
let host = str_field(&data, "host");
|
||||
let port = data.get("port").and_then(|v| v.as_i64()).unwrap_or(8443) as i32;
|
||||
let api_key = str_field(&data, "api_key");
|
||||
let username = str_field(&data, "username");
|
||||
let password = str_field(&data, "password");
|
||||
let use_https = data.get("use_https").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let verify_ssl = data.get("verify_ssl").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE plesk_servers SET name=$1, host=$2, port=$3, api_key=$4, username=$5, \
|
||||
password_hash=$6, use_https=$7, verify_ssl=$8, updated_at=NOW() WHERE id=$9",
|
||||
&[&name, &host, &port, &api_key, &username, &password, &use_https, &verify_ssl, &server_id],
|
||||
) {
|
||||
Ok(_) => Response::json(&json!({"message": "Server updated successfully"})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Failed to update server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_plesk(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.execute("DELETE FROM plesk_servers WHERE id = $1", &[&server_id]) {
|
||||
Ok(_) => Response::json(&json!({"message": "Server deleted successfully"})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Failed to delete server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_plesk(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let config = match conn.query_opt(
|
||||
"SELECT host, port, api_key, username, password_hash, use_https, verify_ssl \
|
||||
FROM plesk_servers WHERE id = $1",
|
||||
&[&server_id],
|
||||
) {
|
||||
Ok(Some(row)) => sap_sync_backend::models::PleskConfig {
|
||||
host: row.get(0),
|
||||
port: row.get::<_, i32>(1) as u16,
|
||||
api_key: row.get(2),
|
||||
username: row.get(3),
|
||||
password: row.get(4),
|
||||
use_https: row.get(5),
|
||||
verify_ssl: row.get(6),
|
||||
two_factor_enabled: false,
|
||||
two_factor_method: "none".to_string(),
|
||||
two_factor_secret: None,
|
||||
session_id: None,
|
||||
},
|
||||
Ok(None) => return json_error(404, "Server not found"),
|
||||
Err(_) => return json_error(500, "Database error"),
|
||||
};
|
||||
|
||||
let result = sap_sync_backend::plesk_client::test_plesk_connection(&config, None, Some(10));
|
||||
|
||||
let status = if result.success { "connected" } else { "disconnected" };
|
||||
let _ = conn.execute(
|
||||
"UPDATE plesk_servers SET connection_status = $1, \
|
||||
last_connected = CASE WHEN $1 = 'connected' THEN NOW() ELSE last_connected END \
|
||||
WHERE id = $2",
|
||||
&[&status, &server_id],
|
||||
);
|
||||
|
||||
Response::json(&json!({
|
||||
"success": result.success, "message": result.message,
|
||||
"latency_ms": result.latency_ms, "error": result.error,
|
||||
"requires_2fa": result.requires_2fa, "session_id": result.session_id,
|
||||
"two_factor_method": result.two_factor_method,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== SAP Servers ====================
|
||||
|
||||
pub fn list_sap(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, name, host, port, company_db, connection_status, is_active \
|
||||
FROM sap_servers ORDER BY name",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let servers: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"name": row.get::<_, String>(1),
|
||||
"host": row.get::<_, String>(2),
|
||||
"port": row.get::<_, i32>(3),
|
||||
"company_db": row.get::<_, String>(4),
|
||||
"connection_status": row.get::<_, String>(5),
|
||||
"is_active": row.get::<_, bool>(6),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&servers)
|
||||
}
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_sap(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let data: serde_json::Value = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let name = str_field(&data, "name");
|
||||
let host = str_field(&data, "host");
|
||||
let company_db = str_field(&data, "company_db");
|
||||
if name.is_empty() || host.is_empty() || company_db.is_empty() {
|
||||
return json_error(400, "Name, host, and company database are required");
|
||||
}
|
||||
|
||||
let port = data.get("port").and_then(|v| v.as_i64()).unwrap_or(50000) as i32;
|
||||
let username = str_field(&data, "username");
|
||||
let password = str_field(&data, "password");
|
||||
let use_ssl = data.get("use_ssl").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
match conn.query_one(
|
||||
"INSERT INTO sap_servers (name, host, port, company_db, username, password_hash, \
|
||||
use_ssl, connection_status, is_active, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'unknown', true, NOW()) RETURNING id",
|
||||
&[&name, &host, &port, &company_db, &username, &password, &use_ssl],
|
||||
) {
|
||||
Ok(r) => Response::json(&json!({
|
||||
"id": r.get::<_, i32>(0), "name": name, "host": host,
|
||||
"port": port, "company_db": company_db,
|
||||
"message": "SAP server created successfully"
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Failed to create server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sap(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query_opt(
|
||||
"SELECT id, name, host, port, company_db, connection_status, is_active \
|
||||
FROM sap_servers WHERE id = $1",
|
||||
&[&server_id],
|
||||
) {
|
||||
Ok(Some(row)) => Response::json(&json!({
|
||||
"id": row.get::<_, i32>(0), "name": row.get::<_, String>(1),
|
||||
"host": row.get::<_, String>(2), "port": row.get::<_, i32>(3),
|
||||
"company_db": row.get::<_, String>(4),
|
||||
"connection_status": row.get::<_, String>(5), "is_active": row.get::<_, bool>(6),
|
||||
})),
|
||||
Ok(None) => json_error(404, "Server not found"),
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_sap(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let data: serde_json::Value = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let name = str_field(&data, "name");
|
||||
let host = str_field(&data, "host");
|
||||
let port = data.get("port").and_then(|v| v.as_i64()).unwrap_or(50000) as i32;
|
||||
let company_db = str_field(&data, "company_db");
|
||||
let username = str_field(&data, "username");
|
||||
let password = str_field(&data, "password");
|
||||
let use_ssl = data.get("use_ssl").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE sap_servers SET name=$1, host=$2, port=$3, company_db=$4, username=$5, \
|
||||
password_hash=$6, use_ssl=$7, updated_at=NOW() WHERE id=$8",
|
||||
&[&name, &host, &port, &company_db, &username, &password, &use_ssl, &server_id],
|
||||
) {
|
||||
Ok(_) => Response::json(&json!({"message": "Server updated successfully"})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Failed to update server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_sap(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.execute("DELETE FROM sap_servers WHERE id = $1", &[&server_id]) {
|
||||
Ok(_) => Response::json(&json!({"message": "Server deleted successfully"})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Failed to delete server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_sap(request: &Request, state: &Arc<AppState>, id: &str) -> Response {
|
||||
let server_id = parse_id(id);
|
||||
if server_id == 0 {
|
||||
return json_error(400, "Invalid server ID");
|
||||
}
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let config = match conn.query_opt(
|
||||
"SELECT host, port, company_db, username, password_hash, use_ssl \
|
||||
FROM sap_servers WHERE id = $1",
|
||||
&[&server_id],
|
||||
) {
|
||||
Ok(Some(row)) => sap_sync_backend::models::SapConfig {
|
||||
host: row.get(0),
|
||||
port: row.get::<_, i32>(1) as u16,
|
||||
company_db: row.get(2),
|
||||
username: row.get(3),
|
||||
password: row.get(4),
|
||||
use_ssl: row.get(5),
|
||||
timeout_seconds: 30,
|
||||
},
|
||||
Ok(None) => return json_error(404, "Server not found"),
|
||||
Err(_) => return json_error(500, "Database error"),
|
||||
};
|
||||
|
||||
let result = sap_sync_backend::sap_client::test_sap_connection(&config, Some(10));
|
||||
|
||||
let status = if result.success { "connected" } else { "disconnected" };
|
||||
let _ = conn.execute(
|
||||
"UPDATE sap_servers SET connection_status = $1, \
|
||||
last_connected = CASE WHEN $1 = 'connected' THEN NOW() ELSE last_connected END \
|
||||
WHERE id = $2",
|
||||
&[&status, &server_id],
|
||||
);
|
||||
|
||||
Response::json(&json!({
|
||||
"success": result.success, "message": result.message,
|
||||
"latency_ms": result.latency_ms, "error": result.error,
|
||||
"session_id": result.session_id,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== Direct Test (without saved server) ====================
|
||||
|
||||
pub fn test_sap_direct(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let config: sap_sync_backend::models::SapConfig = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let result = sap_sync_backend::sap_client::test_sap_connection(&config, Some(10));
|
||||
Response::json(&json!({
|
||||
"success": result.success, "message": result.message,
|
||||
"latency_ms": result.latency_ms, "error": result.error,
|
||||
"session_id": result.session_id,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn test_plesk_direct(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let config: sap_sync_backend::models::PleskConfig = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let result = sap_sync_backend::plesk_client::test_plesk_connection(&config, None, Some(10));
|
||||
Response::json(&json!({
|
||||
"success": result.success, "message": result.message,
|
||||
"latency_ms": result.latency_ms, "error": result.error,
|
||||
"requires_2fa": result.requires_2fa, "session_id": result.session_id,
|
||||
"two_factor_method": result.two_factor_method,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
fn parse_id(id: &str) -> i32 {
|
||||
id.parse::<i32>().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn str_field(data: &serde_json::Value, key: &str) -> String {
|
||||
data.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
246
backend/src/routes/setup.rs
Normal file
246
backend/src/routes/setup.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
/// Public endpoint — no auth required (setup wizard needs it before first login).
|
||||
pub fn get_status(_request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let plesk_configured = conn
|
||||
.query_opt("SELECT 1 FROM config WHERE key = 'plesk.config'", &[])
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
|| conn
|
||||
.query_opt("SELECT 1 FROM plesk_servers LIMIT 1", &[])
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
let sap_configured = conn
|
||||
.query_opt("SELECT 1 FROM config WHERE key = 'sap.config'", &[])
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
|| conn
|
||||
.query_opt("SELECT 1 FROM sap_servers LIMIT 1", &[])
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
Response::json(&json!({
|
||||
"plesk_configured": plesk_configured,
|
||||
"sap_configured": sap_configured,
|
||||
"setup_complete": plesk_configured && sap_configured,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn save_config(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let config: sap_sync_backend::models::SetupConfig = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
persist_setup(&mut conn, &config);
|
||||
Response::json(&json!({"message": "System configured successfully"}))
|
||||
}
|
||||
|
||||
pub fn test_plesk(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Form {
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
api_key: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
let form: Form = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let port = form.port.unwrap_or(8443);
|
||||
let url = format!("https://{}:{}/api/v2/server", form.host, port);
|
||||
let mut req = ureq::get(&url);
|
||||
|
||||
if let Some(ref key) = form.api_key {
|
||||
if !key.is_empty() {
|
||||
req = req.header("X-API-Key", key);
|
||||
}
|
||||
} else if let (Some(ref user), Some(ref pass)) = (form.username, form.password) {
|
||||
let creds = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
format!("{}:{}", user, pass),
|
||||
);
|
||||
req = req.header("Authorization", &format!("Basic {}", creds));
|
||||
}
|
||||
|
||||
match req.call() {
|
||||
Ok(resp) => {
|
||||
if resp.status() == ureq::http::StatusCode::OK
|
||||
|| resp.status() == ureq::http::StatusCode::CREATED
|
||||
{
|
||||
Response::json(&json!({"success": true, "message": "Plesk connection successful"}))
|
||||
} else {
|
||||
Response::json(&json!({"success": false, "error": format!("Plesk returned status {}", u16::from(resp.status()))}))
|
||||
}
|
||||
}
|
||||
Err(e) => Response::json(&json!({"success": false, "error": format!("Connection failed: {}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plesk_2fa(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Form {
|
||||
code: String,
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
api_key: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
let form: Form = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let port = form.port.unwrap_or(8443);
|
||||
let url = format!("https://{}:{}/api/v2/server", form.host, port);
|
||||
let mut req = ureq::get(&url);
|
||||
|
||||
if let Some(ref key) = form.api_key {
|
||||
if !key.is_empty() {
|
||||
req = req.header("X-API-Key", key);
|
||||
}
|
||||
} else if let (Some(ref user), Some(ref pass)) = (form.username, form.password) {
|
||||
let creds = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
format!("{}:{}", user, pass),
|
||||
);
|
||||
req = req.header("Authorization", &format!("Basic {}", creds));
|
||||
}
|
||||
req = req.header("X-Plesk-2FA-Code", &form.code);
|
||||
|
||||
match req.call() {
|
||||
Ok(resp) => {
|
||||
if resp.status() == ureq::http::StatusCode::OK
|
||||
|| resp.status() == ureq::http::StatusCode::CREATED
|
||||
{
|
||||
Response::json(&json!({"success": true, "message": "2FA verification successful"}))
|
||||
} else {
|
||||
Response::json(&json!({"success": false, "error": format!("Plesk returned status {}", u16::from(resp.status()))}))
|
||||
}
|
||||
}
|
||||
Err(e) => Response::json(&json!({"success": false, "error": format!("Connection failed: {}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_sap(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Form {
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
company_db: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
let form: Form = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let port = form.port.unwrap_or(50000);
|
||||
let scheme = if port == 443 || port == 50001 { "https" } else { "http" };
|
||||
let url = format!("{}://{}:{}/b1s/v1/Login", scheme, form.host, port);
|
||||
|
||||
let login_body = json!({
|
||||
"CompanyDB": form.company_db.unwrap_or_default(),
|
||||
"UserName": form.username.unwrap_or_default(),
|
||||
"Password": form.password.unwrap_or_default(),
|
||||
});
|
||||
|
||||
match ureq::post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.send(login_body.to_string().as_bytes())
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status: u16 = resp.status().into();
|
||||
if status == 200 {
|
||||
Response::json(&json!({"success": true, "message": "SAP connection successful"}))
|
||||
} else {
|
||||
Response::json(&json!({"success": false, "error": format!("SAP returned status {}", status)}))
|
||||
}
|
||||
}
|
||||
Err(e) => Response::json(&json!({"success": false, "error": format!("Connection failed: {}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Internal ====================
|
||||
|
||||
fn persist_setup(
|
||||
conn: &mut postgres::Client,
|
||||
config: &sap_sync_backend::models::SetupConfig,
|
||||
) {
|
||||
let plesk = serde_json::to_string(&config.plesk).unwrap_or_default();
|
||||
let sap = serde_json::to_string(&config.sap).unwrap_or_default();
|
||||
|
||||
let mut upsert = |key: &str, val: &str| {
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO config (key, value) VALUES ($1, $2::text::jsonb) \
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2::text::jsonb",
|
||||
&[&key, &val],
|
||||
);
|
||||
};
|
||||
|
||||
upsert("plesk.config", &plesk);
|
||||
upsert("sap.config", &sap);
|
||||
upsert("sync.direction", &serde_json::to_string(&config.sync.sync_direction).unwrap_or_default());
|
||||
upsert("sync.interval_minutes", &serde_json::to_string(&config.sync.sync_interval_minutes).unwrap_or_default());
|
||||
upsert("sync.conflict_resolution", &serde_json::to_string(&config.sync.conflict_resolution).unwrap_or_default());
|
||||
}
|
||||
246
backend/src/routes/sync.rs
Normal file
246
backend/src/routes/sync.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
pub fn get_status(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let running: i64 = conn
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM sync_jobs \
|
||||
WHERE status IN ('running'::sync_job_status, 'pending'::sync_job_status)",
|
||||
&[],
|
||||
)
|
||||
.map(|row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let completed_today: i64 = conn
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM sync_jobs \
|
||||
WHERE status = 'completed'::sync_job_status AND created_at::date = CURRENT_DATE",
|
||||
&[],
|
||||
)
|
||||
.map(|row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let failed_today: i64 = conn
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM sync_jobs \
|
||||
WHERE status = 'failed'::sync_job_status AND created_at::date = CURRENT_DATE",
|
||||
&[],
|
||||
)
|
||||
.map(|row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
Response::json(&json!({
|
||||
"is_running": running > 0,
|
||||
"stats": { "running": running, "completed_today": completed_today, "failed_today": failed_today }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn start(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let user = match require_auth(request, &mut conn) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let form: sap_sync_backend::sync::SyncStartRequest = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO sync_jobs (job_type, sync_direction, status, created_by, created_at) \
|
||||
VALUES ($1, $2, 'pending'::sync_job_status, $3, NOW())",
|
||||
&[&form.job_type, &form.sync_direction, &user.id],
|
||||
);
|
||||
|
||||
Response::json(&json!({
|
||||
"message": "Sync job started",
|
||||
"job_type": form.job_type,
|
||||
"direction": form.sync_direction,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn stop(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE sync_jobs SET status = 'cancelled'::sync_job_status, completed_at = NOW() \
|
||||
WHERE status IN ('running'::sync_job_status, 'pending'::sync_job_status)",
|
||||
&[],
|
||||
);
|
||||
Response::json(&json!({"message": "Sync jobs stopped"}))
|
||||
}
|
||||
|
||||
pub fn list_jobs(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, job_type, sync_direction, status::text, records_processed, records_failed, \
|
||||
created_at::text, started_at::text, completed_at::text \
|
||||
FROM sync_jobs ORDER BY created_at DESC LIMIT 20",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let jobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"job_type": row.get::<_, String>(1),
|
||||
"sync_direction": row.get::<_, String>(2),
|
||||
"status": row.get::<_, String>(3),
|
||||
"records_processed": row.get::<_, i32>(4),
|
||||
"records_failed": row.get::<_, i32>(5),
|
||||
"created_at": row.get::<_, String>(6),
|
||||
"started_at": row.get::<_, Option<String>>(7),
|
||||
"completed_at": row.get::<_, Option<String>>(8),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&json!({"jobs": jobs}))
|
||||
}
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simulate(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let form: sap_sync_backend::sync::SyncStartRequest = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
match conn.query(
|
||||
"SELECT sap_customer_id, plesk_customer_id, plesk_subscription_id FROM customers",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let len = rows.len();
|
||||
let jobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|_| {
|
||||
json!({
|
||||
"id": 0,
|
||||
"job_type": form.job_type,
|
||||
"sync_direction": form.sync_direction,
|
||||
"status": "completed",
|
||||
"records_processed": len as i32,
|
||||
"records_failed": 0,
|
||||
"created_at": chrono::Utc::now().to_rfc3339(),
|
||||
"started_at": chrono::Utc::now().to_rfc3339(),
|
||||
"completed_at": chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&json!({"jobs": jobs}))
|
||||
}
|
||||
Err(_) => json_error(500, "Database error"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_conflicts(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, sync_job_id, entity_type, entity_id, resolution_status, \
|
||||
COALESCE(metadata::text, '{}') as source_data, \
|
||||
COALESCE(conflict_details::text, '{}') as conflict_details \
|
||||
FROM sync_logs WHERE conflict_details IS NOT NULL \
|
||||
ORDER BY timestamp DESC LIMIT 100",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let conflicts: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let source_str: String = row.get("source_data");
|
||||
let conflict_str: String = row.get("conflict_details");
|
||||
json!({
|
||||
"id": row.get::<_, i32>("id"),
|
||||
"sync_job_id": row.get::<_, i32>("sync_job_id"),
|
||||
"entity_type": row.get::<_, String>("entity_type"),
|
||||
"entity_id": row.get::<_, String>("entity_id"),
|
||||
"resolution_status": row.get::<_, String>("resolution_status"),
|
||||
"source_data": serde_json::from_str::<serde_json::Value>(&source_str).unwrap_or(json!({})),
|
||||
"conflict_details": serde_json::from_str::<serde_json::Value>(&conflict_str).unwrap_or(json!({})),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&conflicts)
|
||||
}
|
||||
Err(e) => json_error(500, &format!("Query error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_conflict(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveForm {
|
||||
action: String,
|
||||
#[allow(dead_code)]
|
||||
resolved_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
let form: ResolveForm = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE sync_logs SET resolution_status = 'resolved', resolution_action = $1, \
|
||||
resolved_at = CURRENT_TIMESTAMP WHERE id = $2",
|
||||
&[&form.action, &id],
|
||||
) {
|
||||
Ok(0) => json_error(404, "Conflict not found"),
|
||||
Ok(_) => Response::json(&json!({"message": "Conflict resolved"})),
|
||||
Err(e) => json_error(500, &format!("Update error: {}", e)),
|
||||
}
|
||||
}
|
||||
93
backend/src/routes/webhooks.rs
Normal file
93
backend/src/routes/webhooks.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use rouille::{input::json_input, Request, Response};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::helpers::{get_conn, json_error, require_auth};
|
||||
use super::AppState;
|
||||
|
||||
pub fn list(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.query(
|
||||
"SELECT id, url, name, is_active, created_at::text \
|
||||
FROM webhooks WHERE is_active = true ORDER BY created_at DESC",
|
||||
&[],
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let webhooks: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"id": row.get::<_, i32>(0),
|
||||
"url": row.get::<_, String>(1),
|
||||
"name": row.get::<_, String>(2),
|
||||
"is_active": row.get::<_, bool>(3),
|
||||
"created_at": row.get::<_, String>(4),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Response::json(&webhooks)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(request: &Request, state: &Arc<AppState>) -> Response {
|
||||
let data: serde_json::Value = match json_input(request) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return json_error(400, "Invalid JSON"),
|
||||
};
|
||||
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let url = data.get("url").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(&url).to_string();
|
||||
let event_type = data.get("event_type").and_then(|v| v.as_str()).unwrap_or("sync_complete").to_string();
|
||||
let events_json = serde_json::to_string(&vec![&event_type]).unwrap_or_default();
|
||||
|
||||
match conn.query_one(
|
||||
"INSERT INTO webhooks (name, url, events, is_active) \
|
||||
VALUES ($1, $2, $3::text::jsonb, $4) RETURNING id",
|
||||
&[&name, &url, &events_json, &true],
|
||||
) {
|
||||
Ok(r) => Response::json(&json!({
|
||||
"id": r.get::<_, i32>(0), "name": name, "url": url,
|
||||
"event_type": event_type, "is_active": true,
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Database error: {}", e);
|
||||
json_error(500, "Database error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(request: &Request, state: &Arc<AppState>, id: i32) -> Response {
|
||||
let mut conn = match get_conn(state) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Err(e) = require_auth(request, &mut conn) {
|
||||
return e;
|
||||
}
|
||||
|
||||
match conn.execute("DELETE FROM webhooks WHERE id = $1", &[&id]) {
|
||||
Ok(0) => json_error(404, "Webhook not found"),
|
||||
Ok(_) => Response::json(&json!({"message": "Webhook deleted"})),
|
||||
Err(e) => json_error(500, &format!("Delete error: {}", e)),
|
||||
}
|
||||
}
|
||||
390
backend/src/sap_client.rs
Executable file
390
backend/src/sap_client.rs
Executable file
@@ -0,0 +1,390 @@
|
||||
use crate::errors::{ConnectionError, ConnectionTestResult, SapError};
|
||||
use crate::models::SapConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SapSession {
|
||||
pub session_id: String,
|
||||
pub expiration: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SapCustomer {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub address: String,
|
||||
pub city: String,
|
||||
pub country: String,
|
||||
pub phone: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SapSubscription {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SapItem {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub price: f64,
|
||||
}
|
||||
|
||||
/// Validate SAP configuration
|
||||
pub fn validate_sap_config(config: &SapConfig) -> Result<(), SapError> {
|
||||
if config.host.is_empty() {
|
||||
return Err(SapError::InvalidConfig {
|
||||
field: "host".to_string(),
|
||||
message: "Host is required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if config.port == 0 {
|
||||
return Err(SapError::InvalidConfig {
|
||||
field: "port".to_string(),
|
||||
message: "Port must be between 1 and 65535".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if config.company_db.is_empty() {
|
||||
return Err(SapError::InvalidConfig {
|
||||
field: "company_db".to_string(),
|
||||
message: "Company database is required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if config.username.is_empty() {
|
||||
return Err(SapError::InvalidConfig {
|
||||
field: "username".to_string(),
|
||||
message: "Username is required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if config.password.is_empty() {
|
||||
return Err(SapError::InvalidConfig {
|
||||
field: "password".to_string(),
|
||||
message: "Password is required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test SAP B1 Service Layer connection with comprehensive error handling
|
||||
pub fn test_sap_connection(config: &SapConfig, _timeout_secs: Option<u64>) -> ConnectionTestResult {
|
||||
let start = Instant::now();
|
||||
|
||||
// Validate configuration first
|
||||
if let Err(e) = validate_sap_config(config) {
|
||||
return ConnectionTestResult {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some(ConnectionError::from(e)),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Build the SAP B1 Service Layer URL
|
||||
let protocol = if config.use_ssl { "https" } else { "http" };
|
||||
let url = format!(
|
||||
"{}://{}:{}/b1s/v1/Login",
|
||||
protocol, config.host, config.port
|
||||
);
|
||||
|
||||
log::info!("Testing SAP connection to: {}", url);
|
||||
|
||||
// Build login request body
|
||||
let login_body = serde_json::json!({
|
||||
"CompanyDB": config.company_db,
|
||||
"UserName": config.username,
|
||||
"Password": config.password,
|
||||
});
|
||||
|
||||
// Execute request
|
||||
let request = ureq::post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json");
|
||||
|
||||
let body_str = login_body.to_string();
|
||||
let body_reader = body_str.as_bytes();
|
||||
|
||||
match request.send(body_reader) {
|
||||
Ok(response) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let status = response.status();
|
||||
|
||||
if status == 200 {
|
||||
ConnectionTestResult {
|
||||
success: true,
|
||||
message: "Connected to SAP B1 successfully".to_string(),
|
||||
latency_ms: Some(latency),
|
||||
error: None,
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
} else {
|
||||
// Parse SAP error response
|
||||
let body = response.into_body().read_to_string().unwrap_or_default();
|
||||
let error_message = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error")
|
||||
.and_then(|e| e.get("message"))
|
||||
.and_then(|m| m.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or(body);
|
||||
|
||||
if status == 401 {
|
||||
ConnectionTestResult {
|
||||
success: false,
|
||||
message: "Authentication failed".to_string(),
|
||||
latency_ms: Some(latency),
|
||||
error: Some(ConnectionError::from(SapError::AuthenticationFailed {
|
||||
reason: error_message,
|
||||
})),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
} else {
|
||||
ConnectionTestResult {
|
||||
success: false,
|
||||
message: format!("SAP login failed: {}", error_message),
|
||||
latency_ms: Some(latency),
|
||||
error: Some(ConnectionError::from(SapError::ApiError {
|
||||
code: status.as_u16() as i32,
|
||||
message: error_message,
|
||||
})),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let reason = e.to_string();
|
||||
|
||||
let error = if reason.contains("timed out") || reason.contains("timeout") {
|
||||
SapError::Timeout {
|
||||
duration_ms: latency,
|
||||
}
|
||||
} else if reason.contains("certificate")
|
||||
|| reason.contains("SSL")
|
||||
|| reason.contains("TLS")
|
||||
{
|
||||
SapError::SslError {
|
||||
reason: reason.clone(),
|
||||
}
|
||||
} else {
|
||||
SapError::ConnectionFailed {
|
||||
host: config.host.clone(),
|
||||
reason: reason.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
ConnectionTestResult {
|
||||
success: false,
|
||||
message: format!("Connection failed: {}", reason),
|
||||
latency_ms: Some(latency),
|
||||
error: Some(ConnectionError::from(error)),
|
||||
requires_2fa: false,
|
||||
session_id: None,
|
||||
two_factor_method: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy test result for backward compatibility
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SapConnectionTestResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility
|
||||
pub fn test_sap_connection_impl(config: &SapConfig) -> SapConnectionTestResult {
|
||||
let result = test_sap_connection(config, None);
|
||||
|
||||
SapConnectionTestResult {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
session_id: result.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_config() -> SapConfig {
|
||||
SapConfig {
|
||||
host: "sap.example.com".to_string(),
|
||||
port: 50000,
|
||||
company_db: "SBODEMO".to_string(),
|
||||
username: "manager".to_string(),
|
||||
password: "password123".to_string(),
|
||||
use_ssl: true,
|
||||
timeout_seconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_empty_host() {
|
||||
let mut config = create_test_config();
|
||||
config.host = String::new();
|
||||
|
||||
let result = validate_sap_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(SapError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "host");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_invalid_port() {
|
||||
let mut config = create_test_config();
|
||||
config.port = 0;
|
||||
|
||||
let result = validate_sap_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(SapError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "port");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_empty_company_db() {
|
||||
let mut config = create_test_config();
|
||||
config.company_db = String::new();
|
||||
|
||||
let result = validate_sap_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(SapError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "company_db");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_empty_username() {
|
||||
let mut config = create_test_config();
|
||||
config.username = String::new();
|
||||
|
||||
let result = validate_sap_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(SapError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "username");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_empty_password() {
|
||||
let mut config = create_test_config();
|
||||
config.password = String::new();
|
||||
|
||||
let result = validate_sap_config(&config);
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(SapError::InvalidConfig { field, .. }) = result {
|
||||
assert_eq!(field, "password");
|
||||
} else {
|
||||
panic!("Expected InvalidConfig error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_valid() {
|
||||
let config = create_test_config();
|
||||
let result = validate_sap_config(&config);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connection_test_invalid_config() {
|
||||
let mut config = create_test_config();
|
||||
config.host = String::new();
|
||||
|
||||
let result = test_sap_connection(&config, Some(5));
|
||||
assert!(!result.success);
|
||||
assert!(result.error.is_some());
|
||||
assert!(result.latency_ms.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connection_test_unreachable_host() {
|
||||
let mut config = create_test_config();
|
||||
config.host = "192.0.2.1".to_string(); // TEST-NET, should timeout
|
||||
config.port = 1;
|
||||
|
||||
let result = test_sap_connection(&config, Some(2));
|
||||
assert!(!result.success);
|
||||
assert!(result.error.is_some());
|
||||
assert!(result.latency_ms.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sap_error_to_connection_error() {
|
||||
let error = SapError::ConnectionFailed {
|
||||
host: "example.com".to_string(),
|
||||
reason: "Connection refused".to_string(),
|
||||
};
|
||||
|
||||
let conn_error: ConnectionError = error.into();
|
||||
assert_eq!(conn_error.error_type, "connection");
|
||||
assert_eq!(conn_error.error_code, "SAP_CONN_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sap_timeout_error() {
|
||||
let error = SapError::Timeout { duration_ms: 5000 };
|
||||
let conn_error: ConnectionError = error.into();
|
||||
assert_eq!(conn_error.error_type, "timeout");
|
||||
assert_eq!(conn_error.error_code, "SAP_TIMEOUT_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sap_ssl_error() {
|
||||
let error = SapError::SslError {
|
||||
reason: "Certificate verification failed".to_string(),
|
||||
};
|
||||
let conn_error: ConnectionError = error.into();
|
||||
assert_eq!(conn_error.error_type, "ssl");
|
||||
assert_eq!(conn_error.error_code, "SAP_SSL_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sap_session_expired_error() {
|
||||
let error = SapError::SessionExpired;
|
||||
let conn_error: ConnectionError = error.into();
|
||||
assert_eq!(conn_error.error_type, "session");
|
||||
assert_eq!(conn_error.error_code, "SAP_SESSION_001");
|
||||
}
|
||||
}
|
||||
37
backend/src/scheduled.rs
Executable file
37
backend/src/scheduled.rs
Executable file
@@ -0,0 +1,37 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScheduledSync {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub schedule_type: String,
|
||||
pub schedule_config: serde_json::Value,
|
||||
pub job_type: String,
|
||||
pub sync_direction: String,
|
||||
pub is_active: bool,
|
||||
pub last_run: Option<String>,
|
||||
pub next_run: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ScheduledSyncCreate {
|
||||
pub name: String,
|
||||
pub schedule_type: String,
|
||||
pub schedule_config: serde_json::Value,
|
||||
pub job_type: String,
|
||||
pub sync_direction: String,
|
||||
pub plesk_server_id: Option<i32>,
|
||||
pub sap_server_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ScheduledSyncUpdate {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub schedule_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub schedule_config: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
109
backend/src/servers.rs
Executable file
109
backend/src/servers.rs
Executable file
@@ -0,0 +1,109 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleskServer {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub use_https: bool,
|
||||
pub connection_status: String,
|
||||
pub last_connected: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PleskServerConfig {
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: i32,
|
||||
pub api_key: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub use_https: bool,
|
||||
#[serde(default = "default_verify_ssl")]
|
||||
pub verify_ssl: bool,
|
||||
}
|
||||
|
||||
fn default_port() -> i32 {
|
||||
8443
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_verify_ssl() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PleskServerTest {
|
||||
pub id: Option<i32>,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub api_key: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PleskServerTestResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requires_2fa: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SapServer {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub company_db: String,
|
||||
pub connection_status: String,
|
||||
pub last_connected: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SapServerConfig {
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
#[serde(default = "default_sap_port")]
|
||||
pub port: i32,
|
||||
pub company_db: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub use_ssl: bool,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_seconds: i32,
|
||||
}
|
||||
|
||||
fn default_sap_port() -> i32 {
|
||||
50000
|
||||
}
|
||||
fn default_timeout() -> i32 {
|
||||
30
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SapServerTest {
|
||||
pub id: Option<i32>,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub company_db: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SapServerTestResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
63
backend/src/state.rs
Executable file
63
backend/src/state.rs
Executable file
@@ -0,0 +1,63 @@
|
||||
use r2d2::{Pool, PooledConnection};
|
||||
use r2d2_postgres::postgres::NoTls;
|
||||
use r2d2_postgres::PostgresConnectionManager;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub type PgPool = Pool<PostgresConnectionManager<NoTls>>;
|
||||
pub type PgConn = PooledConnection<PostgresConnectionManager<NoTls>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: Config) -> anyhow::Result<Self> {
|
||||
let manager = PostgresConnectionManager::new(config.database_url.parse()?, NoTls);
|
||||
let pool = Pool::builder()
|
||||
.max_size(10)
|
||||
.min_idle(Some(2))
|
||||
.build(manager)?;
|
||||
|
||||
Ok(Self {
|
||||
config: Arc::new(config),
|
||||
pool,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_conn(&self) -> anyhow::Result<PgConn> {
|
||||
self.pool.get().map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_id(
|
||||
state: &axum::extract::State<AppState>,
|
||||
header: &axum::http::HeaderMap,
|
||||
) -> anyhow::Result<i32> {
|
||||
let cookie = header.get("Cookie").ok_or(anyhow::anyhow!("No cookie"))?;
|
||||
let cookie_str = cookie.to_str()?;
|
||||
|
||||
let session_id = cookie_str
|
||||
.split(';')
|
||||
.find_map(|c: &str| {
|
||||
let c = c.trim();
|
||||
if c.starts_with("session_id=") {
|
||||
Some(c.trim_start_matches("session_id=").to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or(anyhow::anyhow!("No session cookie"))?;
|
||||
|
||||
let mut conn = state.get_conn()?;
|
||||
let row = postgres::GenericClient::query_one(
|
||||
&mut *conn,
|
||||
"SELECT user_id FROM sessions WHERE id = $1 AND expires_at > CURRENT_TIMESTAMP",
|
||||
&[&session_id],
|
||||
)?;
|
||||
|
||||
Ok(row.get(0))
|
||||
}
|
||||
98
backend/src/sync.rs
Executable file
98
backend/src/sync.rs
Executable file
@@ -0,0 +1,98 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncJob {
|
||||
pub id: i32,
|
||||
pub job_type: String,
|
||||
pub sync_direction: String,
|
||||
pub status: String,
|
||||
pub records_processed: i32,
|
||||
pub records_failed: i32,
|
||||
pub created_at: String,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncStartRequest {
|
||||
pub job_type: String,
|
||||
pub sync_direction: String,
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub plesk_server_id: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub sap_server_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SyncStatus {
|
||||
pub is_running: bool,
|
||||
pub current_job: Option<SyncJob>,
|
||||
pub recent_jobs: Vec<SyncJob>,
|
||||
pub stats: SyncStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SyncStats {
|
||||
pub running: i64,
|
||||
pub completed_today: i64,
|
||||
pub failed_today: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SyncItem {
|
||||
pub id: String,
|
||||
pub source_id: String,
|
||||
pub target_id: Option<String>,
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub source_data: serde_json::Value,
|
||||
pub target_data: Option<serde_json::Value>,
|
||||
pub diff: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimulationResult {
|
||||
pub data_type: String,
|
||||
pub direction: String,
|
||||
pub total_records: usize,
|
||||
pub new: usize,
|
||||
pub updated: usize,
|
||||
pub conflicts: usize,
|
||||
pub unchanged: usize,
|
||||
pub deleted: usize,
|
||||
pub items: Vec<SyncItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Conflict {
|
||||
pub id: i32,
|
||||
pub sync_job_id: i32,
|
||||
pub entity_type: String,
|
||||
pub entity_id: String,
|
||||
pub resolution_status: String,
|
||||
pub source_data: String,
|
||||
pub target_data: Option<String>,
|
||||
pub conflict_details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SimulationRequest {
|
||||
#[serde(default = "default_data_type")]
|
||||
pub data_type: String,
|
||||
#[serde(default)]
|
||||
pub direction: Option<String>,
|
||||
}
|
||||
|
||||
fn default_data_type() -> String {
|
||||
"customers".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConflictResolution {
|
||||
pub id: i32,
|
||||
pub action: String,
|
||||
pub resolved_data: serde_json::Value,
|
||||
}
|
||||
290
backend/src/validators.rs
Executable file
290
backend/src/validators.rs
Executable file
@@ -0,0 +1,290 @@
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
/// Login form validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct LoginForm {
|
||||
#[validate(length(min = 3, max = 50))]
|
||||
pub username: String,
|
||||
|
||||
#[validate(length(min = 8))]
|
||||
pub password: String,
|
||||
|
||||
#[validate(email)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Password change form validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct PasswordChangeForm {
|
||||
#[validate(length(min = 8))]
|
||||
pub current_password: String,
|
||||
|
||||
#[validate(length(min = 8))]
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Sync start request validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct SyncStartRequest {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub job_type: String,
|
||||
|
||||
#[validate(length(min = 1, max = 20))]
|
||||
pub sync_direction: String,
|
||||
|
||||
#[validate(range(min = 1, max = 1000000))]
|
||||
pub plesk_server_id: Option<i32>,
|
||||
|
||||
#[validate(range(min = 1, max = 1000000))]
|
||||
pub sap_server_id: Option<i32>,
|
||||
}
|
||||
|
||||
/// Setup configuration validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct SetupConfig {
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub plesk_host: String,
|
||||
|
||||
#[validate(range(min = 1, max = 65535))]
|
||||
pub plesk_port: u16,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub plesk_username: String,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub plesk_password: String,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub sap_host: String,
|
||||
|
||||
#[validate(range(min = 1, max = 65535))]
|
||||
pub sap_port: u16,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub sap_username: String,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub sap_password: String,
|
||||
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub sync_direction: String,
|
||||
|
||||
#[validate(range(min = 1, max = 1440))]
|
||||
pub sync_interval_minutes: u32,
|
||||
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub conflict_resolution: String,
|
||||
}
|
||||
|
||||
/// Billing record request validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct BillingRecordRequest {
|
||||
#[validate(range(min = 1))]
|
||||
pub customer_id: i32,
|
||||
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub period_start: String,
|
||||
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub period_end: String,
|
||||
}
|
||||
|
||||
/// Alert threshold validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct AlertThreshold {
|
||||
#[validate(range(min = 1))]
|
||||
pub subscription_id: i32,
|
||||
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub metric_type: String,
|
||||
|
||||
#[validate(range(min = 0.0))]
|
||||
pub threshold_value: f64,
|
||||
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub action_type: String,
|
||||
}
|
||||
|
||||
/// Webhook configuration validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct WebhookConfig {
|
||||
#[validate(length(min = 1, max = 500))]
|
||||
pub url: String,
|
||||
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub event_type: String,
|
||||
}
|
||||
|
||||
/// Pricing configuration validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct PricingConfig {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub metric_type: String,
|
||||
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub unit: String,
|
||||
|
||||
#[validate(range(min = 0.0))]
|
||||
pub price_per_unit: f64,
|
||||
|
||||
#[validate(custom = "Self::validate_is_active")]
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
impl PricingConfig {
|
||||
fn validate_is_active(is_active: &bool) -> Result<(), validator::ValidationError> {
|
||||
if !is_active {
|
||||
return Err(validator::ValidationError::new("is_active"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Customer mapping validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct CustomerMapping {
|
||||
#[validate(length(min = 1))]
|
||||
pub sap_customer_code: String,
|
||||
|
||||
#[validate(range(min = 1))]
|
||||
pub plesk_customer_id: i32,
|
||||
|
||||
#[validate(range(min = 1))]
|
||||
pub plesk_subscription_id: i32,
|
||||
}
|
||||
|
||||
/// Subscription validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct Subscription {
|
||||
#[validate(length(min = 1))]
|
||||
pub sap_subscription_id: String,
|
||||
|
||||
#[validate(range(min = 1))]
|
||||
pub plesk_subscription_id: i32,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub name: String,
|
||||
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Two-factor verification validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct TwoFactorVerify {
|
||||
#[validate(length(min = 6, max = 6))]
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Server configuration validation
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub hostname: String,
|
||||
|
||||
#[validate(range(min = 1, max = 65535))]
|
||||
pub port: u16,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub username: String,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub password: String,
|
||||
|
||||
pub use_ssl: bool,
|
||||
pub verify_ssl: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_login_form() {
|
||||
let form = LoginForm {
|
||||
username: "testuser".to_string(),
|
||||
password: "Test1234!".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
};
|
||||
assert!(form.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_login_form() {
|
||||
let form = LoginForm {
|
||||
username: "ab".to_string(), // Too short
|
||||
password: "short".to_string(), // Too short
|
||||
email: "invalid".to_string(), // Invalid email
|
||||
};
|
||||
let errors = form.validate().unwrap_err();
|
||||
assert!(!errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_sync_request() {
|
||||
let request = SyncStartRequest {
|
||||
job_type: "full_sync".to_string(),
|
||||
sync_direction: "bidirectional".to_string(),
|
||||
plesk_server_id: Some(1),
|
||||
sap_server_id: Some(1),
|
||||
};
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_sync_request() {
|
||||
let request = SyncStartRequest {
|
||||
job_type: "".to_string(), // Empty
|
||||
sync_direction: "invalid_direction".to_string(), // Too long
|
||||
plesk_server_id: Some(9999999), // Too large
|
||||
sap_server_id: None,
|
||||
};
|
||||
let errors = request.validate().unwrap_err();
|
||||
assert!(!errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_pricing_config() {
|
||||
let config = PricingConfig {
|
||||
metric_type: "cpu_usage".to_string(),
|
||||
unit: "percent".to_string(),
|
||||
price_per_unit: 0.5,
|
||||
is_active: true,
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_pricing_config() {
|
||||
let config = PricingConfig {
|
||||
metric_type: "".to_string(), // Empty
|
||||
unit: "".to_string(), // Empty
|
||||
price_per_unit: -1.0, // Negative
|
||||
is_active: false, // Inactive
|
||||
};
|
||||
let errors = config.validate().unwrap_err();
|
||||
assert!(!errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_billing_record() {
|
||||
let record = BillingRecordRequest {
|
||||
customer_id: 1,
|
||||
period_start: "2026-01-01".to_string(),
|
||||
period_end: "2026-01-31".to_string(),
|
||||
};
|
||||
assert!(record.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_billing_record() {
|
||||
let record = BillingRecordRequest {
|
||||
customer_id: 0, // Zero
|
||||
period_start: "".to_string(), // Empty
|
||||
period_end: "invalid".to_string(), // Invalid date format
|
||||
};
|
||||
let errors = record.validate().unwrap_err();
|
||||
assert!(!errors.is_empty());
|
||||
}
|
||||
}
|
||||
27
backend/src/websocket.rs
Executable file
27
backend/src/websocket.rs
Executable file
@@ -0,0 +1,27 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SyncProgress {
|
||||
pub job_id: i32,
|
||||
pub job_type: String,
|
||||
pub sync_direction: String,
|
||||
pub progress_percentage: f64,
|
||||
pub records_processed: i32,
|
||||
pub records_failed: i32,
|
||||
pub current_entity: Option<String>,
|
||||
pub estimated_completion: Option<String>,
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WsSubscribeRequest {
|
||||
pub job_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WsMessage {
|
||||
pub kind: String,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
Reference in New Issue
Block a user