feat(wireguard): add isolated encrypted file manager per wg client, drop sql editor
This commit is contained in:
parent
e057aee8ba
commit
bd04298843
9 changed files with 534 additions and 165 deletions
|
|
@ -60,21 +60,6 @@ const internalDatabase = {
|
|||
total,
|
||||
rows
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute raw SQL query directly
|
||||
*/
|
||||
async executeQuery(queryStr) {
|
||||
const knex = db();
|
||||
|
||||
// Run raw query. This is dangerous and assumes the user knows what they're doing.
|
||||
const result = await knex.raw(queryStr);
|
||||
|
||||
return {
|
||||
query: queryStr,
|
||||
result: result
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
132
backend/internal/wireguard-fs.js
Normal file
132
backend/internal/wireguard-fs.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { debug, express as logger } from "../logger.js";
|
||||
|
||||
const WG_FILES_DIR = process.env.WG_FILES_DIR || "/data/wg_clients";
|
||||
|
||||
// Ensure root dir exists
|
||||
if (!fs.existsSync(WG_FILES_DIR)) {
|
||||
fs.mkdirSync(WG_FILES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Derive a 32-byte AES-256 key from the client's private key
|
||||
*/
|
||||
getKey(privateKey) {
|
||||
return crypto.createHash("sha256").update(privateKey).digest();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the absolute path to a client's isolated directory
|
||||
*/
|
||||
getClientDir(ipv4Address) {
|
||||
// Clean the IP address to prevent traversal
|
||||
const safeIp = ipv4Address.replace(/[^0-9.]/g, "");
|
||||
const dirPath = path.join(WG_FILES_DIR, safeIp);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
return dirPath;
|
||||
},
|
||||
|
||||
/**
|
||||
* List all files in a client's isolated directory
|
||||
*/
|
||||
async listFiles(ipv4Address) {
|
||||
const dir = this.getClientDir(ipv4Address);
|
||||
const files = await fs.promises.readdir(dir);
|
||||
|
||||
const result = [];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (stats.isFile()) {
|
||||
result.push({
|
||||
name: file,
|
||||
size: stats.size, // Note: Encrypted size includes 16 byte IV + pad
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Encrypt and save a file buffer to disk
|
||||
*/
|
||||
async uploadFile(ipv4Address, privateKey, filename, fileBuffer) {
|
||||
const dir = this.getClientDir(ipv4Address);
|
||||
// Prevent path traversal
|
||||
const safeFilename = path.basename(filename);
|
||||
const filePath = path.join(dir, safeFilename);
|
||||
|
||||
const key = this.getKey(privateKey);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||
|
||||
// We will write the IV to the very beginning of the file, followed by encrypted data
|
||||
const encryptedBuffer = Buffer.concat([iv, cipher.update(fileBuffer), cipher.final()]);
|
||||
|
||||
await fs.promises.writeFile(filePath, encryptedBuffer);
|
||||
return { success: true, name: safeFilename };
|
||||
},
|
||||
|
||||
/**
|
||||
* Decrypt a file and pipe it to standard response stream
|
||||
*/
|
||||
async downloadFile(ipv4Address, privateKey, filename, res) {
|
||||
const dir = this.getClientDir(ipv4Address);
|
||||
const safeFilename = path.basename(filename);
|
||||
const filePath = path.join(dir, safeFilename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
|
||||
const key = this.getKey(privateKey);
|
||||
const fileDescriptor = await fs.promises.open(filePath, "r");
|
||||
|
||||
// Read first 16 bytes to extract IV
|
||||
const ivBuffer = Buffer.alloc(16);
|
||||
await fileDescriptor.read(ivBuffer, 0, 16, 0);
|
||||
await fileDescriptor.close();
|
||||
|
||||
// Create a read stream starting AFTER the 16 byte IV
|
||||
const readStream = fs.createReadStream(filePath, { start: 16 });
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, ivBuffer);
|
||||
|
||||
// Set response headers for download
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${safeFilename}"`);
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
|
||||
// Catch error in pipeline without crashing the root process
|
||||
readStream.on("error", (err) => {
|
||||
logger.error(`Error reading encrypted file ${safeFilename}: ${err.message}`);
|
||||
if (!res.headersSent) res.status(500).end();
|
||||
});
|
||||
|
||||
decipher.on("error", (err) => {
|
||||
logger.error(`Error decrypting file ${safeFilename}: ${err.message}`);
|
||||
if (!res.headersSent) res.status(500).end();
|
||||
});
|
||||
|
||||
readStream.pipe(decipher).pipe(res);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an encrypted file
|
||||
*/
|
||||
async deleteFile(ipv4Address, filename) {
|
||||
const dir = this.getClientDir(ipv4Address);
|
||||
const safeFilename = path.basename(filename);
|
||||
const filePath = path.join(dir, safeFilename);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
|
@ -60,23 +60,4 @@ router.get("/tables/:name", async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/database/query
|
||||
* Execute a raw SQL query
|
||||
*/
|
||||
router.post("/query", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.body || !req.body.query) {
|
||||
return res.status(400).json({ error: { message: "Query is required" } });
|
||||
}
|
||||
|
||||
const result = await internalDatabase.executeQuery(req.body.query);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
debug(logger, `POST ${req.path} error: ${err.message}`);
|
||||
// We want to return SQL errors cleanly to the UI, not throw a 500 error page
|
||||
res.status(400).json({ error: { message: err.message, sql: true } });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import express from "express";
|
||||
import archiver from "archiver";
|
||||
import internalWireguard from "../internal/wireguard.js";
|
||||
import internalWireguardFs from "../internal/wireguard-fs.js";
|
||||
import internalAuditLog from "../internal/audit-log.js";
|
||||
import jwtdecode from "../lib/express/jwt-decode.js";
|
||||
import fileUpload from "express-fileupload";
|
||||
import db from "../db.js";
|
||||
|
||||
const router = express.Router({
|
||||
|
|
@ -14,6 +16,12 @@ const router = express.Router({
|
|||
// Protect all WireGuard routes
|
||||
router.use(jwtdecode());
|
||||
|
||||
// Enable File Uploads for the File Manager endpoints
|
||||
router.use(fileUpload({
|
||||
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max limit
|
||||
abortOnLimit: true
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/wireguard
|
||||
* Get WireGuard interfaces info
|
||||
|
|
@ -357,4 +365,124 @@ router.get("/client/:id/configuration.zip", async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/wireguard/client/:id/files
|
||||
* List files for a client
|
||||
*/
|
||||
router.get("/client/:id/files", async (req, res, next) => {
|
||||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:get");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: { message: "Client not found" } });
|
||||
}
|
||||
|
||||
const files = await internalWireguardFs.listFiles(client.ipv4_address);
|
||||
res.status(200).json(files);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/wireguard/client/:id/files
|
||||
* Upload an encrypted file for a client
|
||||
*/
|
||||
router.post("/client/:id/files", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.files || !req.files.file) {
|
||||
return res.status(400).json({ error: { message: "No file uploaded" } });
|
||||
}
|
||||
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:update");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: { message: "Client not found" } });
|
||||
}
|
||||
|
||||
const uploadedFile = req.files.file;
|
||||
const result = await internalWireguardFs.uploadFile(client.ipv4_address, client.private_key, uploadedFile.name, uploadedFile.data);
|
||||
|
||||
await internalAuditLog.add(access, {
|
||||
action: "uploaded-file",
|
||||
object_type: "wireguard-client",
|
||||
object_id: client.id,
|
||||
meta: { filename: uploadedFile.name }
|
||||
});
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/wireguard/client/:id/files/:filename
|
||||
* Download a decrypted file for a client
|
||||
*/
|
||||
router.get("/client/:id/files/:filename", async (req, res, next) => {
|
||||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:get");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: { message: "Client not found" } });
|
||||
}
|
||||
|
||||
await internalWireguardFs.downloadFile(client.ipv4_address, client.private_key, req.params.filename, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/wireguard/client/:id/files/:filename
|
||||
* Delete a file for a client
|
||||
*/
|
||||
router.delete("/client/:id/files/:filename", async (req, res, next) => {
|
||||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:update");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: { message: "Client not found" } });
|
||||
}
|
||||
|
||||
const result = await internalWireguardFs.deleteFile(client.ipv4_address, req.params.filename);
|
||||
|
||||
await internalAuditLog.add(access, {
|
||||
action: "deleted-file",
|
||||
object_type: "wireguard-client",
|
||||
object_id: client.id,
|
||||
meta: { filename: req.params.filename }
|
||||
});
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -12,10 +12,3 @@ export async function getTableData(tableName: string, offset: number = 0, limit:
|
|||
params: { offset, limit },
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeQuery(query: string): Promise<any> {
|
||||
return await api.post({
|
||||
url: "/database/query",
|
||||
data: { query },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,3 +83,37 @@ export function downloadWgConfig(id: number, name: string) {
|
|||
export function downloadWgConfigZip(id: number, name: string) {
|
||||
return api.download({ url: `/wireguard/client/${id}/configuration.zip` }, `${name}.zip`);
|
||||
}
|
||||
|
||||
export async function getWgClientFiles(id: number): Promise<any[]> {
|
||||
return await api.get({ url: `/wireguard/client/${id}/files` });
|
||||
}
|
||||
|
||||
export async function uploadWgClientFile(id: number, file: File): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
// Direct fetch to bypass base JSON content-type overrides for multipart formdata
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(`/api/wireguard/client/${id}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error?.message || "Upload failed");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export function downloadWgClientFile(id: number, filename: string) {
|
||||
return api.download({ url: `/wireguard/client/${id}/files/${encodeURIComponent(filename)}` }, filename);
|
||||
}
|
||||
|
||||
export async function deleteWgClientFile(id: number, filename: string): Promise<boolean> {
|
||||
return await api.del({ url: `/wireguard/client/${id}/files/${encodeURIComponent(filename)}` });
|
||||
}
|
||||
|
|
|
|||
184
frontend/src/modals/WireGuardFileManagerModal.tsx
Normal file
184
frontend/src/modals/WireGuardFileManagerModal.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { IconFolder, IconUpload, IconTrash, IconDownload } from "@tabler/icons-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Modal, Button, Table, Spinner, Badge } from "react-bootstrap";
|
||||
import { getWgClientFiles, uploadWgClientFile, deleteWgClientFile, downloadWgClientFile } from "src/api/backend";
|
||||
import { showError, showSuccess } from "src/notifications";
|
||||
import { Loading } from "src/components";
|
||||
|
||||
interface Props {
|
||||
resolve: (value: boolean) => void;
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
ipv4Address: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function WireGuardFileManagerModal({ resolve, clientId, clientName, ipv4Address }: Props) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: files, isLoading } = useQuery({
|
||||
queryKey: ["wg-client-files", clientId],
|
||||
queryFn: () => getWgClientFiles(clientId)
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadWgClientFile(clientId, file),
|
||||
onSuccess: () => {
|
||||
showSuccess("File uploaded and encrypted successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
onError: (err: any) => {
|
||||
showError(err.message || "Failed to upload file");
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (filename: string) => deleteWgClientFile(clientId, filename),
|
||||
onSuccess: () => {
|
||||
showSuccess("File deleted successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
showError(err.message || "Failed to delete file");
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
setVisible(false);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
uploadMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (filename: string) => {
|
||||
downloadWgClientFile(clientId, filename);
|
||||
};
|
||||
|
||||
const handleDelete = (filename: string) => {
|
||||
if (window.confirm(`Are you sure you want to completely delete "${filename}"?`)) {
|
||||
deleteMutation.mutate(filename);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={visible} onHide={onClose} size="lg" backdrop="static">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
<IconFolder className="me-2 text-primary" />
|
||||
Secure File Manager
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="mb-4">
|
||||
<h5 className="mb-1">Client: <strong>{clientName}</strong></h5>
|
||||
<p className="text-muted mb-0">Storage Partition: <code>/data/wg_clients/{ipv4Address}/</code></p>
|
||||
<div className="mt-2">
|
||||
<Badge bg="success" className="d-inline-flex align-items-center">
|
||||
<span className="me-1">✓</span> AES-256-CBC End-to-End Encryption Active
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 className="mb-0">Encrypted Files</h5>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="d-none"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
{uploadMutation.isPending ? (
|
||||
<Spinner size="sm" className="me-2" />
|
||||
) : (
|
||||
<IconUpload size={16} className="me-2" />
|
||||
)}
|
||||
{uploadMutation.isPending ? "Encrypting..." : "Upload File"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive border rounded" style={{ maxHeight: "400px", overflowY: "auto" }}>
|
||||
<Table striped hover size="sm" className="mb-0">
|
||||
<thead className="bg-light sticky-top">
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Encrypted Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th className="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-5">
|
||||
<Loading />
|
||||
</td>
|
||||
</tr>
|
||||
) : files && files.length > 0 ? (
|
||||
files.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td className="align-middle fw-medium">{file.name}</td>
|
||||
<td className="align-middle text-muted">{formatBytes(file.size)}</td>
|
||||
<td className="align-middle text-muted">{new Date(file.modified).toLocaleString()}</td>
|
||||
<td className="text-end">
|
||||
<div className="btn-group btn-group-sm">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
title="Download & Decrypt"
|
||||
onClick={() => handleDownload(file.name)}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
title="Delete File"
|
||||
onClick={() => handleDelete(file.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-5 text-muted">
|
||||
No files found. The partition is empty.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,13 @@
|
|||
import { IconDatabase } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Badge, Button, Card, Col, Container, Nav, Row, Table } from "react-bootstrap";
|
||||
import CodeEditor from "@uiw/react-textarea-code-editor";
|
||||
import { Badge, Card, Col, Container, Row, Table } from "react-bootstrap";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { executeQuery, getTables, getTableData } from "src/api/backend";
|
||||
import { getTables, getTableData } from "src/api/backend";
|
||||
import { HasPermission, Loading } from "src/components";
|
||||
import { showError, showSuccess } from "src/notifications";
|
||||
import { ADMIN, VIEW } from "src/modules/Permissions";
|
||||
|
||||
export default function DatabaseManager() {
|
||||
const [activeTab, setActiveTab] = useState<"tables" | "query">("tables");
|
||||
const [activeTable, setActiveTable] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("SELECT * FROM user LIMIT 10;");
|
||||
const [queryResult, setQueryResult] = useState<any>(null);
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
|
||||
const { data: tables, isLoading: tablesLoading } = useQuery({
|
||||
queryKey: ["database-tables"],
|
||||
|
|
@ -23,7 +17,7 @@ export default function DatabaseManager() {
|
|||
const { data: tableData, isLoading: tableDataLoading } = useQuery({
|
||||
queryKey: ["database-table", activeTable, 0, 50],
|
||||
queryFn: () => getTableData(activeTable as string, 0, 50),
|
||||
enabled: !!activeTable && activeTab === "tables",
|
||||
enabled: !!activeTable,
|
||||
});
|
||||
|
||||
// Select the first table by default when tables are loaded
|
||||
|
|
@ -31,19 +25,6 @@ export default function DatabaseManager() {
|
|||
setActiveTable(tables[0]);
|
||||
}
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
try {
|
||||
setQueryLoading(true);
|
||||
const res = await executeQuery(query);
|
||||
setQueryResult(res.result);
|
||||
showSuccess("Query executed successfully");
|
||||
} catch (err: any) {
|
||||
showError(err.message || "Failed to execute query");
|
||||
} finally {
|
||||
setQueryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTableData = (data: any[]) => {
|
||||
if (!data || data.length === 0) return <div className="text-muted p-3">No data</div>;
|
||||
// In SQLite, raw SQL mapping might mismatch explicit schemas, so strictly read keys from the first row.
|
||||
|
|
@ -92,109 +73,40 @@ export default function DatabaseManager() {
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
<Nav variant="pills" className="mb-4">
|
||||
<Nav.Item>
|
||||
<Nav.Link
|
||||
active={activeTab === "tables"}
|
||||
onClick={() => setActiveTab("tables")}
|
||||
className="rounded-pill px-4"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Data Viewer
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link
|
||||
active={activeTab === "query"}
|
||||
onClick={() => setActiveTab("query")}
|
||||
className="rounded-pill px-4"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
SQL Editor
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
|
||||
{activeTab === "tables" && (
|
||||
<Row>
|
||||
<Col md={3}>
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-body-tertiary text-body fw-bold">Tables</Card.Header>
|
||||
<div className="list-group list-group-flush" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tablesLoading && <div className="p-3"><Loading /></div>}
|
||||
{tables?.map((table: string) => (
|
||||
<button
|
||||
key={table}
|
||||
className={`list-group-item list-group-item-action ${activeTable === table ? "active" : ""}`}
|
||||
onClick={() => setActiveTable(table)}
|
||||
>
|
||||
{table}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col md={9}>
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-body-tertiary text-body d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0 fw-bold">{activeTable || "Select a table"}</h5>
|
||||
{tableData && <Badge bg="secondary">{tableData.total} rows</Badge>}
|
||||
</Card.Header>
|
||||
<Card.Body className="p-0" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tableDataLoading ? (
|
||||
<div className="p-5 d-flex justify-content-center"><Loading /></div>
|
||||
) : (
|
||||
tableData && renderTableData(tableData.rows)
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{activeTab === "query" && (
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-body-tertiary text-body fw-bold">Execute SQL Query</Card.Header>
|
||||
<Card.Body>
|
||||
<div className="mb-3 border rounded">
|
||||
<CodeEditor
|
||||
value={query}
|
||||
language="sql"
|
||||
placeholder="Please enter SQL code."
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
padding={15}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
backgroundColor: "#f8f9fa",
|
||||
fontFamily: "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||||
minHeight: "200px"
|
||||
}}
|
||||
/>
|
||||
<Row>
|
||||
<Col md={3}>
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-body-tertiary text-body fw-bold">Tables</Card.Header>
|
||||
<div className="list-group list-group-flush" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tablesLoading && <div className="p-3"><Loading /></div>}
|
||||
{tables?.map((table: string) => (
|
||||
<button
|
||||
key={table}
|
||||
className={`list-group-item list-group-item-action ${activeTable === table ? "active" : ""}`}
|
||||
onClick={() => setActiveTable(table)}
|
||||
>
|
||||
{table}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="d-flex justify-content-end mb-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleExecuteQuery}
|
||||
disabled={queryLoading || !query.trim()}
|
||||
>
|
||||
{queryLoading ? <span className="me-2"><Loading /></span> : null}
|
||||
Execute Query
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{queryResult && (
|
||||
<div className="mt-4">
|
||||
<h5 className="mb-3 border-bottom pb-2">Results</h5>
|
||||
{Array.isArray(queryResult) && queryResult.length > 0 ? renderTableData(queryResult) : (
|
||||
<pre className="p-3 bg-body-tertiary text-body rounded border">
|
||||
{JSON.stringify(queryResult, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col md={9}>
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-body-tertiary text-body d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0 fw-bold">{activeTable || "Select a table"}</h5>
|
||||
{tableData && <Badge bg="secondary">{tableData.total} rows</Badge>}
|
||||
</Card.Header>
|
||||
<Card.Body className="p-0" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tableDataLoading ? (
|
||||
<div className="p-5 d-flex justify-content-center"><Loading /></div>
|
||||
) : (
|
||||
tableData && renderTableData(tableData.rows)
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</HasPermission>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
IconEdit,
|
||||
IconLink,
|
||||
IconZip,
|
||||
IconFolder,
|
||||
} from "@tabler/icons-react";
|
||||
import EasyModal from "ez-modal-react";
|
||||
import { useState } from "react";
|
||||
|
|
@ -30,6 +31,7 @@ import WireGuardClientModal from "src/modals/WireGuardClientModal";
|
|||
import WireGuardServerModal from "src/modals/WireGuardServerModal";
|
||||
import WireGuardLinkedServersModal from "src/modals/WireGuardLinkedServersModal";
|
||||
import WireGuardQRModal from "src/modals/WireGuardQRModal";
|
||||
import WireGuardFileManagerModal from "src/modals/WireGuardFileManagerModal";
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === 0) return "0 B";
|
||||
|
|
@ -160,6 +162,14 @@ function WireGuard() {
|
|||
downloadWgConfigZip(id, cleanName);
|
||||
};
|
||||
|
||||
const handleManageFiles = (client: any) => {
|
||||
EasyModal.show(WireGuardFileManagerModal, {
|
||||
clientId: client.id,
|
||||
clientName: client.name,
|
||||
ipv4Address: client.ipv4Address
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-xl">
|
||||
{/* Page Header */}
|
||||
|
|
@ -407,6 +417,16 @@ function WireGuard() {
|
|||
>
|
||||
<IconQrcode size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-info"
|
||||
title="Manage Secure Files"
|
||||
onClick={() =>
|
||||
handleManageFiles(client)
|
||||
}
|
||||
>
|
||||
<IconFolder size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
|
|
|
|||
Loading…
Reference in a new issue