feat(frontend): request logs; refactor frontend components

This commit is contained in:
2025-12-01 18:48:22 +08:00
parent d783cf2591
commit a9cb9510c5
28 changed files with 649 additions and 160 deletions

View File

@@ -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",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"solid-js": "^1.8.0"
},
"devDependencies": {

View File

@@ -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
</button>
<button
onClick={() => 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
</button>
</div>
</div>
</nav>
@@ -80,6 +91,7 @@ const App: Component = () => {
<main class="max-w-7xl mx-auto px-4 py-6">
{activeTab() === 'rules' && <RulesList />}
{activeTab() === 'commands' && <CommandQueue />}
{activeTab() === 'logs' && <RequestLog />}
</main>
<Show when={showChangePassword()}>

View File

@@ -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<RequestLog[]>('/logs'),
};
export const fetchLogs = logsApi.list;

View File

@@ -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,13 +63,14 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
};
return (
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b border-gray-200">
<Modal>
<ModalContent>
<CardHeader>
<h3 class="text-lg font-medium text-gray-900">Change Password</h3>
</div>
</CardHeader>
<form onSubmit={handleSubmit} class="px-6 py-4 space-y-4">
<form onSubmit={handleSubmit}>
<CardContent>
{error() && (
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error()}
@@ -82,11 +87,10 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
<label class="block text-sm font-medium text-gray-700 mb-1">
Old Password
</label>
<input
<Input
type="password"
value={oldPassword()}
onInput={(e) => 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()}
/>
</div>
@@ -95,11 +99,10 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
<label class="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
<Input
type="password"
value={newPassword()}
onInput={(e) => 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()}
/>
</div>
@@ -108,35 +111,33 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
<label class="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password
</label>
<input
<Input
type="password"
value={confirmPassword()}
onInput={(e) => 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()}
/>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
</CardContent>
<CardFooter class="flex justify-end space-x-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={props.onClose}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
disabled={loading()}
>
Cancel
</button>
<button
</Button>
<Button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading()}
>
{loading() ? 'Changing...' : 'Change Password'}
</button>
</div>
</Button>
</CardFooter>
</form>
</div>
</div>
</ModalContent>
</Modal>
);
};

View File

@@ -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 = () => {
<div class="space-y-4">
<h2 class="text-2xl font-semibold">Command Queue</h2>
<div class="bg-white shadow rounded-lg overflow-hidden">
<Card>
<Show when={!commands.loading} fallback={<div class="p-4">Loading...</div>}>
<For each={commands()} fallback={
<div class="p-8 text-center text-gray-500">
@@ -56,18 +58,20 @@ const CommandQueue: Component = () => {
</div>
<Show when={cmd.status === 'unverified'}>
<div class="ml-4 flex flex-col gap-2">
<button
<Button
size="sm"
variant="success"
onClick={() => updateCommandStatus(cmd.id, 'verified')}
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
>
Verify
</button>
<button
</Button>
<Button
size="sm"
variant="danger"
onClick={() => updateCommandStatus(cmd.id, 'rejected')}
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
>
Reject
</button>
</Button>
</div>
</Show>
</div>
@@ -75,7 +79,7 @@ const CommandQueue: Component = () => {
)}
</For>
</Show>
</div>
</Card>
</div>
);
};

View File

@@ -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<LoginProps> = (props) => {
return (
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-md w-96">
<Card class="p-8 w-96">
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">
My Linspirer Admin Login
</h1>
@@ -49,12 +52,11 @@ const Login: Component<LoginProps> = (props) => {
<label class="block text-gray-700 text-sm font-medium mb-2" for="password">
Password
</label>
<input
<Input
id="password"
type="password"
value={password()}
onInput={(e) => 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<LoginProps> = (props) => {
</div>
)}
<button
<Button
type="submit"
size="lg"
disabled={isLoading()}
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-400"
>
{isLoading() ? 'Logging in...' : 'Login'}
</button>
</Button>
</form>
<div class="mt-4 text-center text-sm text-gray-500">
<p>Default password: admin123</p>
</div>
</div>
</Card>
</div>
);
};

View File

@@ -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 (
<div class="ml-4 my-1">
<div class="flex items-center">
<span class="cursor-pointer" onClick={() => setIsOpen(!isOpen())}>
{isObject ? (isOpen() ? '▼' : '►') : ''}
</span>
<span class="font-semibold ml-2">{props.name}</span>
</div>
{isOpen() && isObject && (
<div class="pl-4 border-l-2 border-gray-200">
<For each={Object.entries(props.data)}>
{([key, value]) => {
if (typeof value === 'object' && value !== null && 'modified' in value && 'value' in value) {
return <TreeView name={key} data={value.value} />
}
return <TreeView name={key} data={value} />
}}
</For>
</div>
)}
{isOpen() && !isObject && <div class="pl-6 text-gray-700">{JSON.stringify(props.data)}</div>}
</div>
);
};
const RequestDetails: Component<{ log: RequestLog }> = (props) => {
return (
<Card class="p-4">
<h2 class="text-xl font-bold mb-4">Request Details</h2>
{/* TODO: interception method */}
<div>
<TreeView name="Request" data={props.log.request_body} />
<TreeView name="Response" data={props.log.response_body} />
</div>
</Card>
);
};
export default RequestDetails;

View File

@@ -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<RequestLogType[]>([]);
const [selectedLog, setSelectedLog] = createSignal<RequestLogType | null>(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 (
<div class="flex space-x-4">
<div class={selectedLog() ? "w-1/3" : "w-full"}>
<h2 class="text-2xl font-semibold">Request Log</h2>
<div class="overflow-x-auto">
<Card>
<ul class="divide-y divide-gray-200">
<For each={logs()}>
{(log) => (
<li
class="p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handleLogClick(log)}
>
<div class="flex justify-between">
<span class="font-medium">{log.method}</span>
<span class="text-sm text-gray-500">{new Date(log.created_at).toLocaleTimeString()}</span>
</div>
</li>
)}
</For>
</ul>
</Card>
</div>
</div>
<Show when={selectedLog()}>
{(log) =>
<div class="w-2/3">
<RequestDetails log={log()} />
</div>}
</Show>
</div>
);
};
export default RequestLog;

View File

@@ -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 = () => {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-semibold">Interception Rules</h2>
<button
<Button
onClick={() => {
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'}
</button>
</Button>
</div>
<Show when={showEditor()}>
<div class="bg-white shadow rounded-lg p-6 mb-4">
<Card class="p-6 mb-4">
<h3 class="text-lg font-semibold mb-4">
{editingId() !== null ? 'Edit Rule' : 'Create New Rule'}
</h3>
@@ -129,11 +133,10 @@ const RulesList: Component = () => {
<label class="block text-sm font-medium text-gray-700 mb-1">
Method Name
</label>
<input
<Input
type="text"
value={editingMethod()}
onInput={(e) => setEditingMethod(e.currentTarget.value)}
class="w-full border border-gray-300 rounded-md px-3 py-2"
placeholder="com.linspirer.method.name"
/>
</div>
@@ -142,14 +145,13 @@ const RulesList: Component = () => {
<label class="block text-sm font-medium text-gray-700 mb-1">
Action
</label>
<select
<Select
value={editingAction()}
onChange={(e) => setEditingAction(e.currentTarget.value as any)}
class="w-full border border-gray-300 rounded-md px-3 py-2"
>
<option value="passthrough">Passthrough (Forward to server)</option>
<option value="replace">Replace (Custom response)</option>
</select>
</Select>
</div>
<Show when={editingAction() === 'replace'}>
@@ -157,27 +159,25 @@ const RulesList: Component = () => {
<label class="block text-sm font-medium text-gray-700 mb-1">
Custom Response (JSON)
</label>
<textarea
<Textarea
value={editingResponse()}
onInput={(e) => 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": {...}}'
/>
</div>
</Show>
<button
<Button
onClick={saveEdit}
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
>
{editingId() !== null ? 'Update Rule' : 'Create Rule'}
</button>
</div>
</Button>
</div>
</Card>
</Show>
<div class="bg-white shadow rounded-lg overflow-hidden">
<Card>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
@@ -225,16 +225,18 @@ const RulesList: Component = () => {
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
<Button
variant="ghost"
onClick={() => startEdit(rule)}
class="text-indigo-600 hover:text-indigo-900 mr-4">
class="mr-4">
Edit
</button>
<button
</Button>
<Button
variant="link"
onClick={() => deleteRule(rule.id)}
class="text-red-600 hover:text-red-900">
>
Delete
</button>
</Button>
</td>
</tr>
)}
@@ -243,7 +245,7 @@ const RulesList: Component = () => {
</tbody>
</table>
</div>
</div>
</Card>
</div>
);
};

View File

@@ -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<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
const Button: Component<ButtonProps> = (props) => {
const [local, others] = splitProps(props, ['variant', 'size', 'class']);
return (
<button
class={buttonVariants({ variant: local.variant, size: local.size, class: local.class })}
{...others}
/>
);
};
export { Button, buttonVariants };

View File

@@ -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<HTMLDivElement>, VariantProps<typeof cardVariants> {}
const Card: Component<CardProps> = (props) => {
const [local, others] = splitProps(props, ['variant', 'class']);
return (
<div
class={cardVariants({ variant: local.variant, class: local.class })}
{...others}
/>
);
};
const CardHeader: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, others] = splitProps(props, ['class']);
return (
<div
class={`px-6 py-4 border-b border-gray-200 ${local.class}`}
{...others}
/>
);
};
const CardContent: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, others] = splitProps(props, ['class']);
return (
<div
class={`px-6 py-4 space-y-4 ${local.class}`}
{...others}
/>
);
};
const CardFooter: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, others] = splitProps(props, ['class']);
return (
<div
class={`px-6 py-4 border-t border-gray-200 ${local.class}`}
{...others}
/>
);
};
export { Card, CardHeader, CardContent, CardFooter, cardVariants };

View File

@@ -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<HTMLInputElement>, VariantProps<typeof inputVariants> {}
const Input: Component<InputProps> = (props) => {
const [local, others] = splitProps(props, ['variant', 'class']);
return (
<input
class={inputVariants({ variant: local.variant, class: local.class })}
{...others}
/>
);
};
export { Input, inputVariants };

View File

@@ -0,0 +1,23 @@
import { Component, JSX, splitProps } from 'solid-js';
const Modal: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, others] = splitProps(props, ['class']);
return (
<div
class={`fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 ${local.class}`}
{...others}
/>
);
};
const ModalContent: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, others] = splitProps(props, ['class']);
return (
<div
class={`bg-white rounded-lg shadow-xl max-w-md w-full mx-4 ${local.class}`}
{...others}
/>
);
};
export { Modal, ModalContent };

View File

@@ -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<HTMLSelectElement>, VariantProps<typeof selectVariants> {}
const Select: Component<SelectProps> = (props) => {
const [local, others] = splitProps(props, ['variant', 'class']);
return (
<select
class={selectVariants({ variant: local.variant, class: local.class })}
{...others}
/>
);
};
export { Select, selectVariants };

View File

@@ -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<HTMLTextAreaElement>, VariantProps<typeof textareaVariants> {}
const Textarea: Component<TextareaProps> = (props) => {
const [local, others] = splitProps(props, ['variant', 'class']);
return (
<textarea
class={textareaVariants({ variant: local.variant, class: local.class })}
{...others}
/>
);
};
export { Textarea, textareaVariants };

View File

@@ -34,3 +34,23 @@ export interface UpdateCommandRequest {
status: string;
notes?: string;
}
export interface Request {
headers: Record<string, {value: string, modified: boolean}>;
body: {value: any, modified: boolean};
}
export interface Response {
headers: Record<string, {value: string, modified: boolean}>;
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;
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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<Arc<AppState>>,
) -> Result<Json<Vec<RequestLog>>, (StatusCode, Json<ApiError>)> {
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")),
))
}
}
}

View File

@@ -99,3 +99,21 @@ impl ApiError {
Self { error: msg.into() }
}
}
#[derive(Debug, Serialize)]
pub struct RequestDetails {
pub headers: Vec<Header>,
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,
}

View File

@@ -24,6 +24,7 @@ pub fn admin_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
.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,

View File

@@ -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<SqlitePool> {
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);

View File

@@ -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<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RequestLog {
pub id: i64,
pub method: Option<String>,
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,
}

View File

@@ -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<Vec<RequestLog>> {
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<i64, sqlx::Error> {
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())
}

View File

@@ -1,3 +1,4 @@
pub mod commands;
pub mod config;
pub mod logs;
pub mod rules;

View File

@@ -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);

View File

@@ -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<Arc<AppState>>,
OriginalUri(uri): OriginalUri,
req: Request<axum::body::Body>,
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<String> {
fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result<Value> {
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<AppState>,