diff --git a/backend/internal/database.js b/backend/internal/database.js new file mode 100644 index 0000000..2a58cc5 --- /dev/null +++ b/backend/internal/database.js @@ -0,0 +1,81 @@ +import db from "../db.js"; +import { debug, express as logger } from "../logger.js"; + +const internalDatabase = { + /** + * Get all tables in the database (SQLite specific, but Knex supports raw queries for others too) + */ + async getTables() { + const knex = db(); + + // Attempt SQLite first, fallback to generic if using mysql/mariadb + try { + // For SQLite + const tables = await knex.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); + return tables.map(t => t.name).sort(); + } catch (e) { + // For MySQL/MariaDB + const tables = await knex.raw("SHOW TABLES"); + return tables[0].map(t => Object.values(t)[0]).sort(); + } + }, + + /** + * Get table schema and paginated rows + */ + async getTableData(tableName, limit = 50, offset = 0) { + const knex = db(); + + // 1. Get Schema/PRAGMA + let schema = []; + try { + const info = await knex.raw(`PRAGMA table_info("${tableName}")`); + schema = info; // SQLite structure + } catch (e) { + // MySQL fallback + const info = await knex.raw(`DESCRIBE \`${tableName}\``); + schema = info[0]; + } + + // 2. Count total rows + const countResult = await knex(tableName).count("id as count").first(); + const total = parseInt(countResult.count || 0, 10); + + // 3. Get rows + const rows = await knex(tableName) + .select("*") + .limit(limit) + .offset(offset) + // Try ordering by ID or created_on if possible, fallback to whatever db returns + .orderBy( + schema.find(col => col.name === 'created_on' || col.Field === 'created_on') ? 'created_on' : + (schema.find(col => col.name === 'id' || col.Field === 'id') ? 'id' : undefined) || '1', + 'desc' + ) + .catch(() => knex(tableName).select("*").limit(limit).offset(offset)); // If order fails + + return { + name: tableName, + schema, + 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 + }; + } +}; + +export default internalDatabase; diff --git a/backend/internal/wireguard.js b/backend/internal/wireguard.js index e47ddcf..b8a6fee 100644 --- a/backend/internal/wireguard.js +++ b/backend/internal/wireguard.js @@ -205,7 +205,7 @@ const internalWireguard = { /** * Get all clients with live status and interface name correlation */ - async getClients(knex, access) { + async getClients(knex, access, accessData) { await this.getOrCreateInterface(knex); // Ensure structure exists const query = knex("wg_client") @@ -214,7 +214,7 @@ const internalWireguard = { .orderBy("wg_client.created_on", "desc"); // Filter by owner if not admin - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("wg_client.owner_user_id", access.token.getUserId(1)); } @@ -265,7 +265,7 @@ const internalWireguard = { /** * Create a new WireGuard client */ - async createClient(knex, data, access) { + async createClient(knex, data, access, accessData) { const iface = data.interface_id ? await knex("wg_interface").where("id", data.interface_id).first() : await this.getOrCreateInterface(knex); @@ -307,9 +307,9 @@ const internalWireguard = { /** * Delete a WireGuard client */ - async deleteClient(knex, clientId, access) { + async deleteClient(knex, clientId, access, accessData) { const query = knex("wg_client").where("id", clientId); - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("owner_user_id", access.token.getUserId(1)); } const client = await query.first(); @@ -326,9 +326,9 @@ const internalWireguard = { /** * Toggle a WireGuard client enabled/disabled */ - async toggleClient(knex, clientId, enabled, access) { + async toggleClient(knex, clientId, enabled, access, accessData) { const query = knex("wg_client").where("id", clientId); - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("owner_user_id", access.token.getUserId(1)); } const client = await query.first(); @@ -349,9 +349,9 @@ const internalWireguard = { /** * Update a WireGuard client */ - async updateClient(knex, clientId, data, access) { + async updateClient(knex, clientId, data, access, accessData) { const query = knex("wg_client").where("id", clientId); - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("owner_user_id", access.token.getUserId(1)); } const client = await query.first(); @@ -412,7 +412,7 @@ const internalWireguard = { /** * Create a new WireGuard Interface Endpoint */ - async createInterface(knex, data, access) { + async createInterface(knex, data, access, accessData) { const existingIfaces = await knex("wg_interface").select("name", "listen_port"); const newIndex = existingIfaces.length; @@ -468,9 +468,9 @@ const internalWireguard = { /** * Update an existing Interface */ - async updateInterface(knex, id, data, access) { + async updateInterface(knex, id, data, access, accessData) { const query = knex("wg_interface").where("id", id); - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("owner_user_id", access.token.getUserId(1)); } const iface = await query.first(); @@ -491,9 +491,9 @@ const internalWireguard = { /** * Delete an interface */ - async deleteInterface(knex, id, access) { + async deleteInterface(knex, id, access, accessData) { const query = knex("wg_interface").where("id", id); - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("owner_user_id", access.token.getUserId(1)); } const iface = await query.first(); @@ -516,10 +516,10 @@ const internalWireguard = { /** * Update Peering Links between WireGuard Interfaces */ - async updateInterfaceLinks(knex, id, linkedServers, access) { + async updateInterfaceLinks(knex, id, linkedServers, access, accessData) { // Verify ownership const query = knex("wg_interface").where("id", id); - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("owner_user_id", access.token.getUserId(1)); } const iface = await query.first(); @@ -544,10 +544,10 @@ const internalWireguard = { /** * Get the WireGuard interfaces info */ - async getInterfacesInfo(knex, access) { + async getInterfacesInfo(knex, access, accessData) { const query = knex("wg_interface").select("*"); // Filter by owner if not admin - if (access && !access.token.hasScope("admin")) { + if (access && (!accessData || accessData.permission_visibility !== "all")) { query.andWhere("owner_user_id", access.token.getUserId(1)); } const ifaces = await query; diff --git a/backend/routes/api/database.js b/backend/routes/api/database.js new file mode 100644 index 0000000..f5b4f22 --- /dev/null +++ b/backend/routes/api/database.js @@ -0,0 +1,82 @@ +import express from "express"; +import internalDatabase from "../../internal/database.js"; +import jwtdecode from "../../lib/express/jwt-decode.js"; +import { debug, express as logger } from "../../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +router.use(jwtdecode()); + +/** + * Middleware to strictly ensure only Super Admins can access this route + */ +const requireSuperAdmin = async (req, res, next) => { + try { + const accessData = await res.locals.access.can("proxy_hosts:list"); + if (!accessData || accessData.permission_visibility !== "all") { + return res.status(403).json({ error: { message: "Forbidden: Super Admin only" } }); + } + next(); + } catch (err) { + next(err); + } +}; + +router.use(requireSuperAdmin); + +/** + * GET /api/database/tables + * List all tables in the database + */ +router.get("/tables", async (req, res, next) => { + try { + const tables = await internalDatabase.getTables(); + res.status(200).json(tables); + } catch (err) { + debug(logger, `GET ${req.path} error: ${err.message}`); + next(err); + } +}); + +/** + * GET /api/database/tables/:name + * Get table schema and data rows + */ +router.get("/tables/:name", async (req, res, next) => { + try { + const limit = parseInt(req.query.limit, 10) || 50; + const offset = parseInt(req.query.offset, 10) || 0; + const name = req.params.name; + + const data = await internalDatabase.getTableData(name, limit, offset); + res.status(200).json(data); + } catch (err) { + debug(logger, `GET ${req.path} error: ${err.message}`); + next(err); + } +}); + +/** + * 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; diff --git a/backend/routes/main.js b/backend/routes/main.js index a91cf2e..dab5d1e 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -16,6 +16,7 @@ import tokensRoutes from "./tokens.js"; import usersRoutes from "./users.js"; import versionRoutes from "./version.js"; import wireguardRoutes from "./wireguard.js"; +import databaseRoutes from "./api/database.js"; const router = express.Router({ caseSensitive: true, @@ -56,6 +57,7 @@ router.use("/nginx/streams", streamsRoutes); router.use("/nginx/access-lists", accessListsRoutes); router.use("/nginx/certificates", certificatesHostsRoutes); router.use("/wireguard", wireguardRoutes); +router.use("/database", databaseRoutes); /** * API 404 for all other routes diff --git a/backend/routes/wireguard.js b/backend/routes/wireguard.js index 5f47b1b..d7eea38 100644 --- a/backend/routes/wireguard.js +++ b/backend/routes/wireguard.js @@ -22,7 +22,8 @@ router.get("/", async (_req, res, next) => { try { const knex = db(); const access = res.locals.access; - const ifaces = await internalWireguard.getInterfacesInfo(knex, access); + const accessData = await access.can("proxy_hosts:list"); + const ifaces = await internalWireguard.getInterfacesInfo(knex, access, accessData); res.status(200).json(ifaces); } catch (err) { next(err); @@ -37,7 +38,8 @@ router.post("/", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const iface = await internalWireguard.createInterface(knex, req.body, access); + const accessData = await access.can("proxy_hosts:create"); + const iface = await internalWireguard.createInterface(knex, req.body, access, accessData); await internalAuditLog.add(access, { action: "created", object_type: "wireguard-server", @@ -58,7 +60,8 @@ router.put("/:id", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access); + const accessData = await access.can("proxy_hosts:update"); + const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access, accessData); await internalAuditLog.add(access, { action: "updated", object_type: "wireguard-server", @@ -79,7 +82,8 @@ router.delete("/:id", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const result = await internalWireguard.deleteInterface(knex, req.params.id, access); + const accessData = await access.can("proxy_hosts:delete"); + const result = await internalWireguard.deleteInterface(knex, req.params.id, access, accessData); await internalAuditLog.add(access, { action: "deleted", object_type: "wireguard-server", @@ -100,7 +104,8 @@ router.post("/:id/links", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access); + const accessData = await access.can("proxy_hosts:update"); + const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access, accessData); await internalAuditLog.add(access, { action: "updated", object_type: "wireguard-server-links", @@ -121,7 +126,8 @@ router.get("/client", async (_req, res, next) => { try { const knex = db(); const access = res.locals.access; - const clients = await internalWireguard.getClients(knex, access); + const accessData = await access.can("proxy_hosts:list"); + const clients = await internalWireguard.getClients(knex, access, accessData); res.status(200).json(clients); } catch (err) { next(err); @@ -136,7 +142,8 @@ router.post("/client", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const client = await internalWireguard.createClient(knex, req.body, access); + const accessData = await access.can("proxy_hosts:create"); + const client = await internalWireguard.createClient(knex, req.body, access, accessData); await internalAuditLog.add(access, { action: "created", object_type: "wireguard-client", @@ -157,8 +164,9 @@ router.get("/client/:id", 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 (!access.token.hasScope("admin")) { + if (accessData.permission_visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } const client = await query.first(); @@ -179,7 +187,8 @@ router.put("/client/:id", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access); + const accessData = await access.can("proxy_hosts:update"); + const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access, accessData); await internalAuditLog.add(access, { action: "updated", object_type: "wireguard-client", @@ -200,7 +209,8 @@ router.delete("/client/:id", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const result = await internalWireguard.deleteClient(knex, req.params.id, access); + const accessData = await access.can("proxy_hosts:delete"); + const result = await internalWireguard.deleteClient(knex, req.params.id, access, accessData); await internalAuditLog.add(access, { action: "deleted", object_type: "wireguard-client", @@ -221,7 +231,8 @@ router.post("/client/:id/enable", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const client = await internalWireguard.toggleClient(knex, req.params.id, true, access); + const accessData = await access.can("proxy_hosts:update"); + const client = await internalWireguard.toggleClient(knex, req.params.id, true, access, accessData); await internalAuditLog.add(access, { action: "enabled", object_type: "wireguard-client", @@ -242,7 +253,8 @@ router.post("/client/:id/disable", async (req, res, next) => { try { const knex = db(); const access = res.locals.access; - const client = await internalWireguard.toggleClient(knex, req.params.id, false, access); + const accessData = await access.can("proxy_hosts:update"); + const client = await internalWireguard.toggleClient(knex, req.params.id, false, access, accessData); await internalAuditLog.add(access, { action: "disabled", object_type: "wireguard-client", @@ -263,8 +275,9 @@ router.get("/client/:id/configuration", 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 (!access.token.hasScope("admin")) { + if (accessData.permission_visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } const client = await query.first(); @@ -289,8 +302,9 @@ router.get("/client/:id/qrcode.svg", 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 (!access.token.hasScope("admin")) { + if (accessData.permission_visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } const client = await query.first(); @@ -313,8 +327,9 @@ router.get("/client/:id/configuration.zip", 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 (!access.token.hasScope("admin")) { + if (accessData.permission_visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } const client = await query.first(); diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 9b5152a..d570ce3 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -26,6 +26,7 @@ const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts")); const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts")); const Streams = lazy(() => import("src/pages/Nginx/Streams")); const WireGuard = lazy(() => import("src/pages/WireGuard")); +const DatabaseManager = lazy(() => import("src/pages/DatabaseManager")); function Router() { const health = useHealth(); @@ -73,6 +74,7 @@ function Router() { } /> } /> } /> + } /> diff --git a/frontend/src/api/backend/database.ts b/frontend/src/api/backend/database.ts new file mode 100644 index 0000000..ca63a1d --- /dev/null +++ b/frontend/src/api/backend/database.ts @@ -0,0 +1,21 @@ +import * as api from "./base"; + +export async function getTables(): Promise { + return await api.get({ + url: "/database/tables", + }); +} + +export async function getTableData(tableName: string, offset: number = 0, limit: number = 50): Promise { + return await api.get({ + url: `/database/tables/${tableName}`, + params: { offset, limit }, + }); +} + +export async function executeQuery(query: string): Promise { + return await api.post({ + url: "/database/query", + data: { query }, + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 8c58687..a308bad 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -62,3 +62,4 @@ export * from "./uploadCertificate"; export * from "./validateCertificate"; export * from "./twoFactor"; export * from "./wireguard"; +export * from "./database"; diff --git a/frontend/src/components/SiteMenu.tsx b/frontend/src/components/SiteMenu.tsx index 815281a..e272568 100644 --- a/frontend/src/components/SiteMenu.tsx +++ b/frontend/src/components/SiteMenu.tsx @@ -102,10 +102,23 @@ const menuItems: MenuItem[] = [ permissionSection: ADMIN, }, { - to: "/settings", icon: IconSettings, - label: "settings", + label: "tools", permissionSection: ADMIN, + items: [ + { + to: "/settings", + label: "settings", + permissionSection: ADMIN, + permission: VIEW, + }, + { + to: "/database", + label: "database-manager", + permissionSection: ADMIN, + permission: VIEW, + } + ], }, ]; diff --git a/frontend/src/pages/DatabaseManager/index.tsx b/frontend/src/pages/DatabaseManager/index.tsx new file mode 100644 index 0000000..4e61748 --- /dev/null +++ b/frontend/src/pages/DatabaseManager/index.tsx @@ -0,0 +1,201 @@ +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 { useQuery } from "@tanstack/react-query"; +import { executeQuery, 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(null); + const [query, setQuery] = useState("SELECT * FROM user LIMIT 10;"); + const [queryResult, setQueryResult] = useState(null); + const [queryLoading, setQueryLoading] = useState(false); + + const { data: tables, isLoading: tablesLoading } = useQuery({ + queryKey: ["database-tables"], + queryFn: getTables, + }); + + const { data: tableData, isLoading: tableDataLoading } = useQuery({ + queryKey: ["database-table", activeTable, 0, 50], + queryFn: () => getTableData(activeTable as string, 0, 50), + enabled: !!activeTable && activeTab === "tables", + }); + + // Select the first table by default when tables are loaded + if (tables && tables.length > 0 && !activeTable) { + 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[], schema?: any[]) => { + if (!data || data.length === 0) return
No data
; + + const columns = schema ? schema.map((s: any) => s.name || s.Field) : Object.keys(data[0]); + + return ( +
+ + + + {columns.map((col: string) => ( + + ))} + + + + {data.map((row: any, i: number) => ( + + {columns.map((col: string) => ( + + ))} + + ))} + +
{col}
+ {row[col] === null ? ( + NULL + ) : typeof row[col] === "object" ? ( + JSON.stringify(row[col]) + ) : ( + String(row[col]) + )} +
+
+ ); + }; + + return ( + + +
+ +

Database Manager

+ + Super Admin Only + +
+ + + + {activeTab === "tables" && ( + + + + Tables +
+ {tablesLoading &&
} + {tables?.map((table: string) => ( + + ))} +
+
+ + + + +
{activeTable || "Select a table"}
+ {tableData && {tableData.total} rows} +
+ + {tableDataLoading ? ( +
+ ) : ( + tableData && renderTableData(tableData.rows, tableData.schema) + )} +
+
+ +
+ )} + + {activeTab === "query" && ( + + Execute SQL Query + +
+ 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" + }} + /> +
+
+ +
+ + {queryResult && ( +
+
Results
+ {Array.isArray(queryResult) ? renderTableData(queryResult) : ( +
+											{JSON.stringify(queryResult, null, 2)}
+										
+ )} +
+ )} +
+
+ )} +
+
+ ); +}