diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 18bef53..5c1a5e4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "mylinspirer-admin", "version": "0.1.0", "dependencies": { + "class-variance-authority": "^0.7.1", "solid-js": "^1.8.0" }, "devDependencies": { @@ -1423,6 +1424,27 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2d7971b..aedfb91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "class-variance-authority": "^0.7.1", "solid-js": "^1.8.0" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 12a3ac0..7445af5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,10 +3,11 @@ import RulesList from './components/RulesList'; import CommandQueue from './components/CommandQueue'; import Login from './components/Login'; import ChangePassword from './components/ChangePassword'; +import RequestLog from './components/RequestLog'; import { authStore } from './api/auth'; const App: Component = () => { - const [activeTab, setActiveTab] = createSignal<'rules' | 'commands'>('rules'); + const [activeTab, setActiveTab] = createSignal<'rules' | 'commands' | 'logs'>('rules'); const [isAuthenticated, setIsAuthenticated] = createSignal(false); const [showChangePassword, setShowChangePassword] = createSignal(false); @@ -73,6 +74,16 @@ const App: Component = () => { > Command Queue + setActiveTab('logs')} + class={`py-4 px-1 border-b-2 font-medium text-sm ${ + activeTab() === 'logs' + ? 'border-indigo-500 text-indigo-600' + : 'border-transparent text-gray-500 hover:text-gray-700' + }`} + > + Request Log + @@ -80,6 +91,7 @@ const App: Component = () => { {activeTab() === 'rules' && } {activeTab() === 'commands' && } + {activeTab() === 'logs' && } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f6cbfc3..24463ce 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { InterceptionRule, Command, CreateRuleRequest, UpdateRuleRequest, UpdateCommandRequest } from '../types'; +import type { InterceptionRule, Command, CreateRuleRequest, UpdateRuleRequest, UpdateCommandRequest, RequestLog } from '../types'; import { authStore } from './auth'; const API_BASE = '/admin/api'; @@ -103,3 +103,10 @@ export const authApi = { }), }), }; + +// Logs API +export const logsApi = { + list: () => request('/logs'), +}; + +export const fetchLogs = logsApi.list; diff --git a/frontend/src/components/ChangePassword.tsx b/frontend/src/components/ChangePassword.tsx index 3f15101..9170606 100644 --- a/frontend/src/components/ChangePassword.tsx +++ b/frontend/src/components/ChangePassword.tsx @@ -1,6 +1,10 @@ import { Component, createSignal } from 'solid-js'; import { authApi } from '../api/client'; import { authStore } from '../api/auth'; +import { Button } from './ui/Button'; +import { Card, CardContent, CardFooter, CardHeader } from './ui/Card'; +import { Input } from './ui/Input'; +import { Modal, ModalContent } from './ui/Modal'; interface ChangePasswordProps { onClose: () => void; @@ -59,84 +63,81 @@ const ChangePassword: Component = (props) => { }; return ( - - - + + + Change Password - + - - {error() && ( - - {error()} + + + {error() && ( + + {error()} + + )} + + {success() && ( + + Password changed successfully! You will be logged out... + + )} + + + + Old Password + + setOldPassword(e.currentTarget.value)} + disabled={loading()} + /> - )} - {success() && ( - - Password changed successfully! You will be logged out... + + + New Password + + setNewPassword(e.currentTarget.value)} + disabled={loading()} + /> - )} - - - Old Password - - setOldPassword(e.currentTarget.value)} - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" - disabled={loading()} - /> - - - - - New Password - - setNewPassword(e.currentTarget.value)} - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" - disabled={loading()} - /> - - - - - Confirm New Password - - setConfirmPassword(e.currentTarget.value)} - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" - disabled={loading()} - /> - - - - + + Confirm New Password + + setConfirmPassword(e.currentTarget.value)} + disabled={loading()} + /> + + + + Cancel - - + {loading() ? 'Changing...' : 'Change Password'} - - + + - - + + ); }; diff --git a/frontend/src/components/CommandQueue.tsx b/frontend/src/components/CommandQueue.tsx index a64a23a..f21b73b 100644 --- a/frontend/src/components/CommandQueue.tsx +++ b/frontend/src/components/CommandQueue.tsx @@ -1,5 +1,7 @@ import { Component, createResource, For, Show } from 'solid-js'; import { commandsApi } from '../api/client'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; const CommandQueue: Component = () => { const [commands, { refetch }] = createResource(commandsApi.list); @@ -22,7 +24,7 @@ const CommandQueue: Component = () => { Command Queue - + Loading...}> @@ -56,18 +58,20 @@ const CommandQueue: Component = () => { - updateCommandStatus(cmd.id, 'verified')} - class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700" > Verify - - + updateCommandStatus(cmd.id, 'rejected')} - class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700" > Reject - + @@ -75,7 +79,7 @@ const CommandQueue: Component = () => { )} - + ); }; diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 53ca327..2fb8654 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,4 +1,7 @@ import { Component, createSignal } from 'solid-js'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; +import { Input } from './ui/Input'; interface LoginProps { onLoginSuccess: (token: string) => void; @@ -39,7 +42,7 @@ const Login: Component = (props) => { return ( - + My Linspirer Admin Login @@ -49,12 +52,11 @@ const Login: Component = (props) => { Password - setPassword(e.currentTarget.value)} - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="Enter admin password" disabled={isLoading()} required @@ -67,19 +69,19 @@ const Login: Component = (props) => { )} - {isLoading() ? 'Logging in...' : 'Login'} - + Default password: admin123 - + ); }; diff --git a/frontend/src/components/RequestDetails.tsx b/frontend/src/components/RequestDetails.tsx new file mode 100644 index 0000000..540b5ac --- /dev/null +++ b/frontend/src/components/RequestDetails.tsx @@ -0,0 +1,48 @@ +import { Component, createSignal, For } from 'solid-js'; +import type { RequestLog } from '../types'; +import { Card } from './ui/Card'; + +const TreeView: Component<{ data: any; name: string }> = (props) => { + const [isOpen, setIsOpen] = createSignal(true); + const isObject = typeof props.data === 'object' && props.data !== null; + + return ( + + + setIsOpen(!isOpen())}> + {isObject ? (isOpen() ? '▼' : '►') : ''} + + {props.name} + + {isOpen() && isObject && ( + + + {([key, value]) => { + if (typeof value === 'object' && value !== null && 'modified' in value && 'value' in value) { + return + } + return + }} + + + )} + {isOpen() && !isObject && {JSON.stringify(props.data)}} + + ); +}; + + +const RequestDetails: Component<{ log: RequestLog }> = (props) => { + return ( + + Request Details + {/* TODO: interception method */} + + + + + + ); +}; + +export default RequestDetails; diff --git a/frontend/src/components/RequestLog.tsx b/frontend/src/components/RequestLog.tsx new file mode 100644 index 0000000..32f047d --- /dev/null +++ b/frontend/src/components/RequestLog.tsx @@ -0,0 +1,58 @@ +import { Component, createSignal, onMount, For, Show } from 'solid-js'; +import { fetchLogs } from '../api/client'; +import type { RequestLog as RequestLogType } from '../types'; +import RequestDetails from './RequestDetails'; +import { Card } from './ui/Card'; + +const RequestLog: Component = () => { + const [logs, setLogs] = createSignal([]); + const [selectedLog, setSelectedLog] = createSignal(null); + + onMount(async () => { + try { + const fetchedLogs = await fetchLogs(); + setLogs(fetchedLogs); + } catch (error) { + console.error('Error fetching logs:', error); + } + }); + + const handleLogClick = (log: RequestLogType) => { + setSelectedLog(log); + }; + + return ( + + + Request Log + + + + + {(log) => ( + handleLogClick(log)} + > + + {log.method} + {new Date(log.created_at).toLocaleTimeString()} + + + )} + + + + + + + {(log) => + + + } + + + ); +}; + +export default RequestLog; diff --git a/frontend/src/components/RulesList.tsx b/frontend/src/components/RulesList.tsx index f576159..011045b 100644 --- a/frontend/src/components/RulesList.tsx +++ b/frontend/src/components/RulesList.tsx @@ -1,6 +1,11 @@ import { Component, createSignal, createResource, For, Show } from 'solid-js'; import { rulesApi } from '../api/client'; import type { InterceptionRule } from '../types'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; +import { Input } from './ui/Input'; +import { Select } from './ui/Select'; +import { Textarea } from './ui/Textarea'; const RulesList: Component = () => { const [rules, { refetch }] = createResource(rulesApi.list); @@ -105,7 +110,7 @@ const RulesList: Component = () => { Interception Rules - { if (showEditor()) { cancelEdit(); @@ -113,14 +118,13 @@ const RulesList: Component = () => { setShowEditor(true); } }} - class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700" > {showEditor() ? 'Cancel' : '+ New Rule'} - + - + {editingId() !== null ? 'Edit Rule' : 'Create New Rule'} @@ -129,11 +133,10 @@ const RulesList: Component = () => { Method Name - setEditingMethod(e.currentTarget.value)} - class="w-full border border-gray-300 rounded-md px-3 py-2" placeholder="com.linspirer.method.name" /> @@ -142,14 +145,13 @@ const RulesList: Component = () => { Action - setEditingAction(e.currentTarget.value as any)} - class="w-full border border-gray-300 rounded-md px-3 py-2" > Passthrough (Forward to server) Replace (Custom response) - + @@ -157,27 +159,25 @@ const RulesList: Component = () => { Custom Response (JSON) - setEditingResponse(e.currentTarget.value)} rows={10} - class="w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm" placeholder='{"code": 0, "type": "object", "data": {...}}' /> - {editingId() !== null ? 'Update Rule' : 'Create Rule'} - + - + - + @@ -225,16 +225,18 @@ const RulesList: Component = () => { - startEdit(rule)} - class="text-indigo-600 hover:text-indigo-900 mr-4"> + class="mr-4"> Edit - - + deleteRule(rule.id)} - class="text-red-600 hover:text-red-900"> + > Delete - + )} @@ -243,7 +245,7 @@ const RulesList: Component = () => { - + ); }; diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..10c230c --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,41 @@ +import { Component, JSX, splitProps } from 'solid-js'; +import { cva, type VariantProps } from 'class-variance-authority'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed', + { + variants: { + variant: { + primary: 'bg-indigo-600 text-white hover:bg-indigo-700', + secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50', + danger: 'bg-red-600 text-white hover:bg-red-700', + success: 'bg-green-600 text-white hover:bg-green-700', + ghost: 'text-indigo-600 hover:text-indigo-900', + link: 'text-red-600 hover:text-red-900', + }, + size: { + sm: 'px-3 py-1', + md: 'px-4 py-2', + lg: 'w-full px-4 py-2', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + } +); + +export interface ButtonProps extends JSX.ButtonHTMLAttributes, VariantProps {} + +const Button: Component = (props) => { + const [local, others] = splitProps(props, ['variant', 'size', 'class']); + return ( + + ); +}; + +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..c165146 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,59 @@ +import { Component, JSX, splitProps } from 'solid-js'; +import { cva, type VariantProps } from 'class-variance-authority'; + +const cardVariants = cva('bg-white shadow rounded-lg', { + variants: { + variant: { + primary: 'overflow-hidden', + withPadding: 'p-6', + withPaddingAndDivider: 'p-4 divide-y divide-gray-200', + }, + }, + defaultVariants: { + variant: 'primary', + }, +}); + +export interface CardProps extends JSX.HTMLAttributes, VariantProps {} + +const Card: Component = (props) => { + const [local, others] = splitProps(props, ['variant', 'class']); + return ( + + ); +}; + +const CardHeader: Component> = (props) => { + const [local, others] = splitProps(props, ['class']); + return ( + + ); +}; + +const CardContent: Component> = (props) => { + const [local, others] = splitProps(props, ['class']); + return ( + + ); +}; + +const CardFooter: Component> = (props) => { + const [local, others] = splitProps(props, ['class']); + return ( + + ); +}; + +export { Card, CardHeader, CardContent, CardFooter, cardVariants }; diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..7a57a63 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,30 @@ +import { Component, JSX, splitProps } from 'solid-js'; +import { cva, type VariantProps } from 'class-variance-authority'; + +const inputVariants = cva( + 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500', + { + variants: { + variant: { + primary: '', + }, + }, + defaultVariants: { + variant: 'primary', + }, + } +); + +export interface InputProps extends JSX.InputHTMLAttributes, VariantProps {} + +const Input: Component = (props) => { + const [local, others] = splitProps(props, ['variant', 'class']); + return ( + + ); +}; + +export { Input, inputVariants }; diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..f9343a3 --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -0,0 +1,23 @@ +import { Component, JSX, splitProps } from 'solid-js'; + +const Modal: Component> = (props) => { + const [local, others] = splitProps(props, ['class']); + return ( + + ); +}; + +const ModalContent: Component> = (props) => { + const [local, others] = splitProps(props, ['class']); + return ( + + ); +}; + +export { Modal, ModalContent }; diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 0000000..ad0f40e --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,30 @@ +import { Component, JSX, splitProps } from 'solid-js'; +import { cva, type VariantProps } from 'class-variance-authority'; + +const selectVariants = cva( + 'w-full border border-gray-300 rounded-md px-3 py-2', + { + variants: { + variant: { + primary: '', + }, + }, + defaultVariants: { + variant: 'primary', + }, + } +); + +export interface SelectProps extends JSX.SelectHTMLAttributes, VariantProps {} + +const Select: Component = (props) => { + const [local, others] = splitProps(props, ['variant', 'class']); + return ( + + ); +}; + +export { Select, selectVariants }; diff --git a/frontend/src/components/ui/Textarea.tsx b/frontend/src/components/ui/Textarea.tsx new file mode 100644 index 0000000..cc82573 --- /dev/null +++ b/frontend/src/components/ui/Textarea.tsx @@ -0,0 +1,30 @@ +import { Component, JSX, splitProps } from 'solid-js'; +import { cva, type VariantProps } from 'class-variance-authority'; + +const textareaVariants = cva( + 'w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm', + { + variants: { + variant: { + primary: '', + }, + }, + defaultVariants: { + variant: 'primary', + }, + } +); + +export interface TextareaProps extends JSX.TextareaHTMLAttributes, VariantProps {} + +const Textarea: Component = (props) => { + const [local, others] = splitProps(props, ['variant', 'class']); + return ( + + ); +}; + +export { Textarea, textareaVariants }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 66031c5..af585ca 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -34,3 +34,23 @@ export interface UpdateCommandRequest { status: string; notes?: string; } + +export interface Request { + headers: Record; + body: {value: any, modified: boolean}; +} + +export interface Response { + headers: Record; + body: {value: any, modified: boolean}; +} + +export interface RequestLog { + id: number; + method: string; + request_body: object; + response_body: object; + request_interception_action: string, + response_interception_action: string, + created_at: string; +} diff --git a/migrations/20251130125600_init.sql b/migrations/20251130125600_init.sql index 182a218..b5beca8 100644 --- a/migrations/20251130125600_init.sql +++ b/migrations/20251130125600_init.sql @@ -39,21 +39,8 @@ CREATE TABLE IF NOT EXISTS commands ( notes TEXT ); --- Request/Response logs (optional, can be large) -CREATE TABLE IF NOT EXISTS request_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - method TEXT, - path TEXT, - request_body TEXT, - response_body TEXT, - status_code INTEGER, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP -); - --- Indexes for performance +-- Index for performance CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status); -CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON request_logs(timestamp); -CREATE INDEX IF NOT EXISTS idx_logs_method ON request_logs(method); -- Insert default config values INSERT OR IGNORE INTO config (key, value, description) VALUES diff --git a/migrations/20251201181400_add_request_logs.sql b/migrations/20251201181400_add_request_logs.sql new file mode 100644 index 0000000..275960c --- /dev/null +++ b/migrations/20251201181400_add_request_logs.sql @@ -0,0 +1,14 @@ +-- Add request logs +CREATE TABLE request_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + method TEXT, + request_body TEXT, + response_body TEXT, + request_interception_action TEXT, + response_interception_action TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_logs_created_at ON request_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_logs_method ON request_logs(method); diff --git a/src/admin/handlers.rs b/src/admin/handlers.rs index 11bfba4..7904c7b 100644 --- a/src/admin/handlers.rs +++ b/src/admin/handlers.rs @@ -7,7 +7,10 @@ use axum::{ }; use tracing::error; -use crate::{AppState, auth, crypto, db}; +use crate::{ + AppState, auth, + db::{self, models::RequestLog}, +}; use super::models::*; @@ -348,3 +351,19 @@ pub async fn update_config( Ok(StatusCode::OK) } + +// Log handlers +pub async fn list_logs( + State(state): State>, +) -> Result>, (StatusCode, Json)> { + match db::repositories::logs::list_all(&state.db).await { + Ok(logs) => Ok(Json(logs)), + Err(e) => { + error!("Failed to list logs: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("Failed to fetch logs")), + )) + } + } +} diff --git a/src/admin/models.rs b/src/admin/models.rs index f66dfde..b904767 100644 --- a/src/admin/models.rs +++ b/src/admin/models.rs @@ -99,3 +99,21 @@ impl ApiError { Self { error: msg.into() } } } + +#[derive(Debug, Serialize)] +pub struct RequestDetails { + pub headers: Vec, + pub body: Body, +} + +#[derive(Debug, Serialize)] +pub struct Body { + pub value: Value, + pub modified: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Header { + pub value: String, + pub modified: bool, +} diff --git a/src/admin/routes.rs b/src/admin/routes.rs index a08b393..270bc9d 100644 --- a/src/admin/routes.rs +++ b/src/admin/routes.rs @@ -24,6 +24,7 @@ pub fn admin_routes(state: Arc) -> Router> { .route("/api/commands/{:id}", post(handlers::verify_command)) .route("/api/config", get(handlers::get_config)) .route("/api/config", put(handlers::update_config)) + .route("/api/logs", get(handlers::list_logs)) .layer(axum::middleware::from_fn_with_state( state, auth_middleware::auth_middleware, diff --git a/src/db/mod.rs b/src/db/mod.rs index ce8a1c0..ee60512 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2,12 +2,25 @@ pub mod models; pub mod repositories; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; -use std::str::FromStr; -use tracing::info; +use std::{path::Path, str::FromStr}; +use tracing::{info, warn}; pub async fn init_db(database_url: &str) -> anyhow::Result { info!("Initializing database at: {}", database_url); + // Create directories + { + // remove scheme from the URL + let url = database_url + .trim_start_matches("sqlite://") + .trim_start_matches("sqlite:"); + let path = url.split('?').next().unwrap_or_default(); + if let Some(dir) = Path::new(path).parent() + && let Err(err) = tokio::fs::create_dir_all(dir).await + { + warn!("Failed to create directories for database ({database_url}): {err}") + } + } // Parse connection options let options = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true); diff --git a/src/db/models.rs b/src/db/models.rs index 2935e9e..5d9a0de 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct InterceptionRule { @@ -29,23 +30,13 @@ pub struct Config { pub updated_at: String, } -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct ResponseTemplate { - pub id: i64, - pub name: String, - pub method_name: String, - pub response_json: String, - pub description: Option, - pub created_at: String, -} - #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct RequestLog { pub id: i64, - pub method: Option, - pub path: String, - pub request_body: String, - pub response_body: String, - pub status_code: i32, - pub timestamp: String, + pub method: String, + pub request_body: Value, + pub response_body: Value, + pub request_interception_action: String, + pub response_interception_action: String, + pub created_at: String, } diff --git a/src/db/repositories/logs.rs b/src/db/repositories/logs.rs new file mode 100644 index 0000000..a2ed1b8 --- /dev/null +++ b/src/db/repositories/logs.rs @@ -0,0 +1,39 @@ +use crate::db::models::RequestLog; +use serde_json::Value; +use sqlx::SqlitePool; + +pub async fn list_all(pool: &SqlitePool) -> anyhow::Result> { + Ok( + sqlx::query_as::<_, RequestLog>("SELECT * FROM request_logs ORDER BY created_at") + .fetch_all(pool) + .await?, + ) +} + +pub async fn create( + pool: &SqlitePool, + method: String, + request_body: Value, + response_body: Value, + request_interception_action: String, + response_interception_action: String, +) -> Result { + let result = sqlx::query( + "INSERT INTO request_logs ( + method, + request_body, + response_body, + request_interception_action, + response_interception_action + ) VALUES (?, ?, ?, ?, ?)", + ) + .bind(method) + .bind(request_body) + .bind(response_body) + .bind(request_interception_action) + .bind(response_interception_action) + .execute(pool) + .await?; + + Ok(result.last_insert_rowid()) +} diff --git a/src/db/repositories/mod.rs b/src/db/repositories/mod.rs index 91a5d8e..1655eb6 100644 --- a/src/db/repositories/mod.rs +++ b/src/db/repositories/mod.rs @@ -1,3 +1,4 @@ pub mod commands; pub mod config; +pub mod logs; pub mod rules; diff --git a/src/main.rs b/src/main.rs index 9aea8ec..3f49543 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ use std::{net::SocketAddr, sync::Arc}; use anyhow::Context; -use axum::{Router, routing::any}; use axum::handler::Handler; +use axum::{Router, routing::any}; use dotenvy::dotenv; use tower_http::compression::CompressionLayer; use tracing::{error, info, level_filters::LevelFilter}; @@ -88,7 +88,10 @@ async fn main() -> anyhow::Result<()> { // Admin routes .nest("/admin", admin::routes::admin_routes(state.clone())) // Proxy Linspirer APIs - .route("/public-interface.php", any(proxy::proxy_handler.layer(proxy_middleware))) + .route( + "/public-interface.php", + any(proxy::proxy_handler.layer(proxy_middleware)), + ) .layer(CompressionLayer::new().gzip(true)) .with_state(state); diff --git a/src/middleware.rs b/src/middleware.rs index 7802e1e..19514e1 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -2,7 +2,7 @@ use std::str; use std::sync::Arc; use axum::{ - extract::{OriginalUri, State}, + extract::State, http::{Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, @@ -11,7 +11,7 @@ use http_body_util::BodyExt; use serde_json::Value; use tracing::{debug, info, warn}; -use crate::{AppState, crypto}; +use crate::{AppState, crypto, db}; enum ResponseBody { Original(String), @@ -20,7 +20,6 @@ enum ResponseBody { pub async fn middleware( State(state): State>, - OriginalUri(uri): OriginalUri, req: Request, next: Next, ) -> impl IntoResponse { @@ -37,9 +36,7 @@ pub async fn middleware( } }; - let path = uri.path(); - - let (decrypted_request_for_log, method) = match str::from_utf8(&body_bytes) + let (decrypted_request, method) = match str::from_utf8(&body_bytes) .map_err(anyhow::Error::from) .and_then(|body| process_and_log_request(body, &state.key, &state.iv)) { @@ -78,9 +75,8 @@ pub async fn middleware( let resp_body_text = String::from_utf8(body_bytes.clone().to_vec()).unwrap_or_default(); // Check for generic method interception first - let response_body_to_log = if let Some(method_str) = &method { - if let Ok(Some(intercepted)) = - maybe_intercept_response(method_str, &resp_body_text, &state).await + let response_body = if let Some(method_str) = &method { + if let Ok(Some(intercepted)) = intercept_response(method_str, &resp_body_text, &state).await { info!("Intercepting response for method: {}", method_str); ResponseBody::Original(intercepted) @@ -108,28 +104,46 @@ pub async fn middleware( ResponseBody::Original(resp_body_text.clone()) }; - let (decrypted_response_for_log, final_response_body) = match response_body_to_log { + let (decrypted_response, final_response_body) = match response_body { ResponseBody::Original(body_text) => { - let decrypted = decrypt_and_format(&body_text, &state.key, &state.iv) - .unwrap_or_else(|_| "Could not decrypt or format response".to_string()); + let decrypted = + decrypt_and_format(&body_text, &state.key, &state.iv).unwrap_or_else(|_| { + Value::String("Could not decrypt or format response".to_string()) + }); (decrypted, body_text) } - ResponseBody::Modified(body_value) => { - let pretty_printed = serde_json::to_string_pretty(&body_value).unwrap_or_default(); + ResponseBody::Modified(response_body_value) => { + let pretty_printed = + serde_json::to_string_pretty(&response_body_value).unwrap_or_default(); let encrypted = crypto::encrypt(&pretty_printed, &state.key, &state.iv) .unwrap_or_else(|_| "Failed to encrypt modified response".to_string()); - (pretty_printed, encrypted) + (response_body_value, encrypted) } }; debug!( - "{}\nRequest:\n{}\nResponse:\n{}\n{}", - path, - serde_json::to_string_pretty(&decrypted_request_for_log).unwrap_or_default(), - decrypted_response_for_log, + "\nRequest:\n{}\nResponse:\n{}\n{}", + serde_json::to_string_pretty(&decrypted_request).unwrap_or_default(), + serde_json::to_string_pretty(&decrypted_response).unwrap_or_default(), "-".repeat(80), ); + if let Some(method) = method { + // TODO: interception action + if let Err(e) = db::repositories::logs::create( + &state.db, + method, + decrypted_request, + decrypted_response, + "".into(), + "".into(), + ) + .await + { + warn!("Failed to log request: {}", e); + } + } + let mut response_builder = Response::builder().status(resp_parts.status); if !resp_parts.headers.is_empty() { *response_builder.headers_mut().unwrap() = resp_parts.headers; @@ -220,13 +234,12 @@ async fn handle_getcommand_response( Ok(response_json) } -fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result { +fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result { let decrypted = crypto::decrypt(body_text, key, iv)?; - let formatted: Value = serde_json::from_str(&decrypted)?; - Ok(serde_json::to_string_pretty(&formatted)?) + Ok(serde_json::from_str(&decrypted)?) } -async fn maybe_intercept_response( +async fn intercept_response( method: &str, _original_response: &str, state: &Arc,
Default password: admin123