feat(database): add native SQLite database manager and fix wireguard admin visibility
This commit is contained in:
parent
3f0d529d14
commit
b99b623355
10 changed files with 453 additions and 35 deletions
81
backend/internal/database.js
Normal file
81
backend/internal/database.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -205,7 +205,7 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Get all clients with live status and interface name correlation
|
* 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
|
await this.getOrCreateInterface(knex); // Ensure structure exists
|
||||||
|
|
||||||
const query = knex("wg_client")
|
const query = knex("wg_client")
|
||||||
|
|
@ -214,7 +214,7 @@ const internalWireguard = {
|
||||||
.orderBy("wg_client.created_on", "desc");
|
.orderBy("wg_client.created_on", "desc");
|
||||||
|
|
||||||
// Filter by owner if not admin
|
// 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));
|
query.andWhere("wg_client.owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,7 +265,7 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Create a new WireGuard client
|
* Create a new WireGuard client
|
||||||
*/
|
*/
|
||||||
async createClient(knex, data, access) {
|
async createClient(knex, data, access, accessData) {
|
||||||
const iface = data.interface_id
|
const iface = data.interface_id
|
||||||
? await knex("wg_interface").where("id", data.interface_id).first()
|
? await knex("wg_interface").where("id", data.interface_id).first()
|
||||||
: await this.getOrCreateInterface(knex);
|
: await this.getOrCreateInterface(knex);
|
||||||
|
|
@ -307,9 +307,9 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Delete a WireGuard client
|
* Delete a WireGuard client
|
||||||
*/
|
*/
|
||||||
async deleteClient(knex, clientId, access) {
|
async deleteClient(knex, clientId, access, accessData) {
|
||||||
const query = knex("wg_client").where("id", clientId);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const client = await query.first();
|
const client = await query.first();
|
||||||
|
|
@ -326,9 +326,9 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Toggle a WireGuard client enabled/disabled
|
* 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);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const client = await query.first();
|
const client = await query.first();
|
||||||
|
|
@ -349,9 +349,9 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Update a WireGuard client
|
* 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);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const client = await query.first();
|
const client = await query.first();
|
||||||
|
|
@ -412,7 +412,7 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Create a new WireGuard Interface Endpoint
|
* 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 existingIfaces = await knex("wg_interface").select("name", "listen_port");
|
||||||
const newIndex = existingIfaces.length;
|
const newIndex = existingIfaces.length;
|
||||||
|
|
||||||
|
|
@ -468,9 +468,9 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Update an existing Interface
|
* 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);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const iface = await query.first();
|
const iface = await query.first();
|
||||||
|
|
@ -491,9 +491,9 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Delete an interface
|
* Delete an interface
|
||||||
*/
|
*/
|
||||||
async deleteInterface(knex, id, access) {
|
async deleteInterface(knex, id, access, accessData) {
|
||||||
const query = knex("wg_interface").where("id", id);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const iface = await query.first();
|
const iface = await query.first();
|
||||||
|
|
@ -516,10 +516,10 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Update Peering Links between WireGuard Interfaces
|
* Update Peering Links between WireGuard Interfaces
|
||||||
*/
|
*/
|
||||||
async updateInterfaceLinks(knex, id, linkedServers, access) {
|
async updateInterfaceLinks(knex, id, linkedServers, access, accessData) {
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const query = knex("wg_interface").where("id", id);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const iface = await query.first();
|
const iface = await query.first();
|
||||||
|
|
@ -544,10 +544,10 @@ const internalWireguard = {
|
||||||
/**
|
/**
|
||||||
* Get the WireGuard interfaces info
|
* Get the WireGuard interfaces info
|
||||||
*/
|
*/
|
||||||
async getInterfacesInfo(knex, access) {
|
async getInterfacesInfo(knex, access, accessData) {
|
||||||
const query = knex("wg_interface").select("*");
|
const query = knex("wg_interface").select("*");
|
||||||
// Filter by owner if not admin
|
// 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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const ifaces = await query;
|
const ifaces = await query;
|
||||||
|
|
|
||||||
82
backend/routes/api/database.js
Normal file
82
backend/routes/api/database.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -16,6 +16,7 @@ import tokensRoutes from "./tokens.js";
|
||||||
import usersRoutes from "./users.js";
|
import usersRoutes from "./users.js";
|
||||||
import versionRoutes from "./version.js";
|
import versionRoutes from "./version.js";
|
||||||
import wireguardRoutes from "./wireguard.js";
|
import wireguardRoutes from "./wireguard.js";
|
||||||
|
import databaseRoutes from "./api/database.js";
|
||||||
|
|
||||||
const router = express.Router({
|
const router = express.Router({
|
||||||
caseSensitive: true,
|
caseSensitive: true,
|
||||||
|
|
@ -56,6 +57,7 @@ router.use("/nginx/streams", streamsRoutes);
|
||||||
router.use("/nginx/access-lists", accessListsRoutes);
|
router.use("/nginx/access-lists", accessListsRoutes);
|
||||||
router.use("/nginx/certificates", certificatesHostsRoutes);
|
router.use("/nginx/certificates", certificatesHostsRoutes);
|
||||||
router.use("/wireguard", wireguardRoutes);
|
router.use("/wireguard", wireguardRoutes);
|
||||||
|
router.use("/database", databaseRoutes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 404 for all other routes
|
* API 404 for all other routes
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ router.get("/", async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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);
|
res.status(200).json(ifaces);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|
@ -37,7 +38,8 @@ router.post("/", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "created",
|
action: "created",
|
||||||
object_type: "wireguard-server",
|
object_type: "wireguard-server",
|
||||||
|
|
@ -58,7 +60,8 @@ router.put("/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "updated",
|
action: "updated",
|
||||||
object_type: "wireguard-server",
|
object_type: "wireguard-server",
|
||||||
|
|
@ -79,7 +82,8 @@ router.delete("/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "deleted",
|
action: "deleted",
|
||||||
object_type: "wireguard-server",
|
object_type: "wireguard-server",
|
||||||
|
|
@ -100,7 +104,8 @@ router.post("/:id/links", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "updated",
|
action: "updated",
|
||||||
object_type: "wireguard-server-links",
|
object_type: "wireguard-server-links",
|
||||||
|
|
@ -121,7 +126,8 @@ router.get("/client", async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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);
|
res.status(200).json(clients);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|
@ -136,7 +142,8 @@ router.post("/client", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "created",
|
action: "created",
|
||||||
object_type: "wireguard-client",
|
object_type: "wireguard-client",
|
||||||
|
|
@ -157,8 +164,9 @@ router.get("/client/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
const access = res.locals.access;
|
||||||
|
const accessData = await access.can("proxy_hosts:get");
|
||||||
const query = knex("wg_client").where("id", req.params.id);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const client = await query.first();
|
const client = await query.first();
|
||||||
|
|
@ -179,7 +187,8 @@ router.put("/client/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "updated",
|
action: "updated",
|
||||||
object_type: "wireguard-client",
|
object_type: "wireguard-client",
|
||||||
|
|
@ -200,7 +209,8 @@ router.delete("/client/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "deleted",
|
action: "deleted",
|
||||||
object_type: "wireguard-client",
|
object_type: "wireguard-client",
|
||||||
|
|
@ -221,7 +231,8 @@ router.post("/client/:id/enable", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "enabled",
|
action: "enabled",
|
||||||
object_type: "wireguard-client",
|
object_type: "wireguard-client",
|
||||||
|
|
@ -242,7 +253,8 @@ router.post("/client/:id/disable", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
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, {
|
await internalAuditLog.add(access, {
|
||||||
action: "disabled",
|
action: "disabled",
|
||||||
object_type: "wireguard-client",
|
object_type: "wireguard-client",
|
||||||
|
|
@ -263,8 +275,9 @@ router.get("/client/:id/configuration", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
const access = res.locals.access;
|
||||||
|
const accessData = await access.can("proxy_hosts:get");
|
||||||
const query = knex("wg_client").where("id", req.params.id);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const client = await query.first();
|
const client = await query.first();
|
||||||
|
|
@ -289,8 +302,9 @@ router.get("/client/:id/qrcode.svg", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
const access = res.locals.access;
|
||||||
|
const accessData = await access.can("proxy_hosts:get");
|
||||||
const query = knex("wg_client").where("id", req.params.id);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const client = await query.first();
|
const client = await query.first();
|
||||||
|
|
@ -313,8 +327,9 @@ router.get("/client/:id/configuration.zip", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const knex = db();
|
const knex = db();
|
||||||
const access = res.locals.access;
|
const access = res.locals.access;
|
||||||
|
const accessData = await access.can("proxy_hosts:get");
|
||||||
const query = knex("wg_client").where("id", req.params.id);
|
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));
|
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||||
}
|
}
|
||||||
const client = await query.first();
|
const client = await query.first();
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts"));
|
||||||
const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts"));
|
const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts"));
|
||||||
const Streams = lazy(() => import("src/pages/Nginx/Streams"));
|
const Streams = lazy(() => import("src/pages/Nginx/Streams"));
|
||||||
const WireGuard = lazy(() => import("src/pages/WireGuard"));
|
const WireGuard = lazy(() => import("src/pages/WireGuard"));
|
||||||
|
const DatabaseManager = lazy(() => import("src/pages/DatabaseManager"));
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const health = useHealth();
|
const health = useHealth();
|
||||||
|
|
@ -73,6 +74,7 @@ function Router() {
|
||||||
<Route path="/nginx/stream" element={<Streams />} />
|
<Route path="/nginx/stream" element={<Streams />} />
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/wireguard" element={<WireGuard />} />
|
<Route path="/wireguard" element={<WireGuard />} />
|
||||||
|
<Route path="/database" element={<DatabaseManager />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</SiteContainer>
|
</SiteContainer>
|
||||||
|
|
|
||||||
21
frontend/src/api/backend/database.ts
Normal file
21
frontend/src/api/backend/database.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as api from "./base";
|
||||||
|
|
||||||
|
export async function getTables(): Promise<string[]> {
|
||||||
|
return await api.get({
|
||||||
|
url: "/database/tables",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTableData(tableName: string, offset: number = 0, limit: number = 50): Promise<any> {
|
||||||
|
return await api.get({
|
||||||
|
url: `/database/tables/${tableName}`,
|
||||||
|
params: { offset, limit },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeQuery(query: string): Promise<any> {
|
||||||
|
return await api.post({
|
||||||
|
url: "/database/query",
|
||||||
|
data: { query },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -62,3 +62,4 @@ export * from "./uploadCertificate";
|
||||||
export * from "./validateCertificate";
|
export * from "./validateCertificate";
|
||||||
export * from "./twoFactor";
|
export * from "./twoFactor";
|
||||||
export * from "./wireguard";
|
export * from "./wireguard";
|
||||||
|
export * from "./database";
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,23 @@ const menuItems: MenuItem[] = [
|
||||||
permissionSection: ADMIN,
|
permissionSection: ADMIN,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/settings",
|
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
|
label: "tools",
|
||||||
|
permissionSection: ADMIN,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
to: "/settings",
|
||||||
label: "settings",
|
label: "settings",
|
||||||
permissionSection: ADMIN,
|
permissionSection: ADMIN,
|
||||||
|
permission: VIEW,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/database",
|
||||||
|
label: "database-manager",
|
||||||
|
permissionSection: ADMIN,
|
||||||
|
permission: VIEW,
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
201
frontend/src/pages/DatabaseManager/index.tsx
Normal file
201
frontend/src/pages/DatabaseManager/index.tsx
Normal file
|
|
@ -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<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"],
|
||||||
|
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 <div className="text-muted p-3">No data</div>;
|
||||||
|
|
||||||
|
const columns = schema ? schema.map((s: any) => s.name || s.Field) : Object.keys(data[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<Table striped bordered hover size="sm" className="mb-0 text-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col: string) => (
|
||||||
|
<th key={col}>{col}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row: any, i: number) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((col: string) => (
|
||||||
|
<td key={`${i}-${col}`}>
|
||||||
|
{row[col] === null ? (
|
||||||
|
<span className="text-muted">NULL</span>
|
||||||
|
) : typeof row[col] === "object" ? (
|
||||||
|
JSON.stringify(row[col])
|
||||||
|
) : (
|
||||||
|
String(row[col])
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HasPermission section={ADMIN} permission={VIEW}>
|
||||||
|
<Container className="my-4" fluid>
|
||||||
|
<div className="d-flex align-items-center mb-4">
|
||||||
|
<IconDatabase size={28} className="me-2 text-primary" />
|
||||||
|
<h2 className="mb-0">Database Manager</h2>
|
||||||
|
<Badge bg="danger" className="ms-3 rounded-pill text-uppercase" style={{ letterSpacing: "1px" }}>
|
||||||
|
Super Admin Only
|
||||||
|
</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-light 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-light 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, tableData.schema)
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "query" && (
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<Card.Header className="bg-light 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"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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) ? renderTableData(queryResult) : (
|
||||||
|
<pre className="p-3 bg-light rounded border">
|
||||||
|
{JSON.stringify(queryResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</HasPermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue