feat: Connect dashboard to backend and update tab system to be similar to a browser (neither are fully finished)
This commit is contained in:
@@ -189,3 +189,18 @@ export const snippets = sqliteTable("snippets", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const recentActivity = sqliteTable("recent_activity", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: text("type").notNull(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
hostName: text("host_name").notNull(),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
265
src/backend/homepage.ts
Normal file
265
src/backend/homepage.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { getDb } from "./database/db/index.js";
|
||||
import { recentActivity, sshData } from "./database/db/schema.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { homepageLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../types/index.js";
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
// Track server start time
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
// Get server uptime
|
||||
app.get("/uptime", async (req, res) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
res.json({
|
||||
uptimeMs,
|
||||
uptimeSeconds,
|
||||
formatted: `${days}d ${hours}h ${minutes}m`,
|
||||
});
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to get uptime", err);
|
||||
res.status(500).json({ error: "Failed to get uptime" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent activity for current user
|
||||
app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(limit),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to get recent activity", err);
|
||||
res.status(500).json({ error: "Failed to get recent activity" });
|
||||
}
|
||||
});
|
||||
|
||||
// Log new activity
|
||||
app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { type, hostId, hostName } = req.body;
|
||||
|
||||
if (!type || !hostId || !hostName) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: type, hostId, hostName",
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "terminal" && type !== "file_manager") {
|
||||
return res.status(400).json({
|
||||
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the host belongs to the user
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Check if this activity already exists in recent history (within last 5 minutes)
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const recentSimilar = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
eq(recentActivity.hostId, hostId),
|
||||
eq(recentActivity.type, type),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(1),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (
|
||||
recentSimilar.length > 0 &&
|
||||
recentSimilar[0].timestamp >= fiveMinutesAgo
|
||||
) {
|
||||
// Activity already logged recently, don't duplicate
|
||||
return res.json({
|
||||
message: "Activity already logged recently",
|
||||
id: recentSimilar[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
// Insert new activity
|
||||
const result = (await SimpleDBOps.insert(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
userId,
|
||||
)) as { id: number };
|
||||
|
||||
// Keep only the last 100 activities per user to prevent bloat
|
||||
const allActivities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (allActivities.length > 100) {
|
||||
const toDelete = allActivities.slice(100);
|
||||
for (const activity of toDelete) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to log activity", err);
|
||||
res.status(500).json({ error: "Failed to log activity" });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset recent activity for current user
|
||||
app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete all activities for the user
|
||||
const activities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
for (const activity of activities) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
}
|
||||
|
||||
res.json({ message: "Recent activity cleared" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to reset activity", err);
|
||||
res.status(500).json({ error: "Failed to reset activity" });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 30006;
|
||||
app.listen(PORT, async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
homepageLogger.success(`Homepage API listening on port ${PORT}`, {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
});
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
@@ -101,6 +102,11 @@ interface PendingTOTPSession {
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
@@ -365,6 +371,56 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({ status: "success", message: "SSH connection established" });
|
||||
|
||||
// Log activity to homepage API
|
||||
if (hostId && userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "file_manager",
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.info("File manager activity logged", {
|
||||
operation: "activity_log",
|
||||
userId,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.warn("Failed to log file manager activity", {
|
||||
operation: "activity_log_error",
|
||||
userId,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
@@ -435,6 +491,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
userId,
|
||||
};
|
||||
|
||||
fileLogger.info("Created TOTP session", {
|
||||
@@ -548,6 +609,61 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
status: "success",
|
||||
message: "TOTP verified, SSH connection established",
|
||||
});
|
||||
|
||||
// Log activity to homepage API
|
||||
if (session.hostId && session.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.id, session.hostId!),
|
||||
eq(sshData.userId, session.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
session.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${session.username}@${session.ip}:${session.port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "file_manager",
|
||||
hostId: session.hostId,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.info("File manager activity logged (TOTP)", {
|
||||
operation: "activity_log",
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
hostName,
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.warn("Failed to log file manager activity (TOTP)", {
|
||||
operation: "activity_log_error",
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
session.client.on("error", (err) => {
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
type ConnectConfig,
|
||||
} from "ssh2";
|
||||
import { parse as parseUrl } from "url";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
@@ -565,6 +566,62 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||
);
|
||||
|
||||
// Log activity to homepage API
|
||||
if (id && hostConfig.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
// Fetch host name from database
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.id, id),
|
||||
eq(sshData.userId, hostConfig.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
hostConfig.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "terminal",
|
||||
hostId: id,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
sshLogger.info("Terminal activity logged", {
|
||||
operation: "activity_log",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
hostName,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to log terminal activity", {
|
||||
operation: "activity_log_error",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -104,6 +104,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
await import("./ssh/server-stats.js");
|
||||
await import("./homepage.js");
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
|
||||
@@ -253,5 +253,6 @@ export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
|
||||
|
||||
class SimpleDBOps {
|
||||
static async insert<T extends Record<string, unknown>>(
|
||||
|
||||
Reference in New Issue
Block a user