Initial commit
Some checks failed
CI/CD Pipeline / Backend Tests (push) Failing after 27s
CI/CD Pipeline / Frontend Tests (push) Failing after 15s
CI/CD Pipeline / Docker Build (push) Has been skipped
CI/CD Pipeline / Security Scan (push) Has been skipped

This commit is contained in:
2026-04-15 01:41:49 +02:00
commit 5b447acd1c
773 changed files with 74653 additions and 0 deletions

5
backend/.cargo/config.toml Executable file
View 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
View File

@@ -0,0 +1,9 @@
target
.git
.gitignore
*.md
.env
.env.*
.DS_Store
*.log
Cargo.lock

88
backend/Cargo.toml Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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
View 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
View 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(&current_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")
}
}

View 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"}))
}

View 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"}))
}

View 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
View 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)
}

View 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()
}

View 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)),
}
}

View 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
View 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
View 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)),
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}