217 lines
6.6 KiB
JavaScript
217 lines
6.6 KiB
JavaScript
const express = require("express");
|
|
const crypto = require("crypto");
|
|
const session = require("express-session");
|
|
const BetterSqlite3Store = require("better-sqlite3-session-store")(session);
|
|
|
|
const { db } = require("./src/services/db");
|
|
const { getSetting, setSetting } = require("./src/services/settings");
|
|
const {
|
|
buildDiscordAuthUrl,
|
|
exchangeDiscordCode,
|
|
fetchDiscordUser,
|
|
fetchDiscordGuildMember
|
|
} = require("./src/services/auth");
|
|
const { getRoleFlags, hasAccess } = require("./src/services/rbac");
|
|
const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager");
|
|
const { requestRestart } = require("./src/services/updater");
|
|
|
|
function ensureSessionSecret() {
|
|
let secret = getSetting("session_secret");
|
|
if (!secret) {
|
|
secret = crypto.randomBytes(32).toString("hex");
|
|
setSetting("session_secret", secret);
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function isConfigured() {
|
|
return Boolean(
|
|
getSetting("discord_client_id") &&
|
|
getSetting("discord_client_secret") &&
|
|
getSetting("discord_guild_id")
|
|
);
|
|
}
|
|
|
|
function renderPage(title, content) {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${title}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; background: #f5f3ef; color: #1c1f23; margin: 0; }
|
|
header { padding: 20px 28px; background: #1c1f23; color: #fff; }
|
|
main { padding: 24px 28px; max-width: 900px; margin: 0 auto; }
|
|
.card { background: #fff; border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 10px 25px rgba(20, 24, 30, 0.08); }
|
|
.button { background: #0f6a78; color: #fff; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-block; }
|
|
.button.danger { background: #c24b3b; }
|
|
.muted { color: #5a616a; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<strong>Safe Mode</strong>
|
|
</header>
|
|
<main>
|
|
${content}
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function buildSnapshotTable(snapshots) {
|
|
if (!snapshots.length) {
|
|
return "<p class=\"muted\">No snapshots available.</p>";
|
|
}
|
|
const rows = snapshots
|
|
.map((snap) => {
|
|
const label = snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Bot core";
|
|
const when = new Date(snap.createdAt).toLocaleString();
|
|
return `
|
|
<tr>
|
|
<td>${label}</td>
|
|
<td>${when}</td>
|
|
<td>
|
|
<form method="post" action="/rollback/${snap.id}">
|
|
<button class="button danger" type="submit">Rollback</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
})
|
|
.join("");
|
|
return `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Snapshot</th>
|
|
<th>Created</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
const app = express();
|
|
const sessionStore = new BetterSqlite3Store({ client: db });
|
|
app.use(
|
|
session({
|
|
secret: ensureSessionSecret(),
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
store: sessionStore
|
|
})
|
|
);
|
|
app.use(express.urlencoded({ extended: false }));
|
|
|
|
app.get("/", (req, res) => {
|
|
if (!isConfigured()) {
|
|
return res.send(
|
|
renderPage(
|
|
"Safe Mode",
|
|
`<section class="card"><h2>Discord not configured</h2><p class="muted">Discord settings are required to enter safe mode.</p></section>`
|
|
)
|
|
);
|
|
}
|
|
if (!req.session.user) {
|
|
return res.send(
|
|
renderPage(
|
|
"Safe Mode",
|
|
`<section class="card"><h2>Login required</h2><p class="muted">Authenticate with Discord to access rollback tools.</p><a class="button" href="/auth/discord">Login with Discord</a></section>`
|
|
)
|
|
);
|
|
}
|
|
if (!hasAccess(req.session.user, "admin")) {
|
|
return res.send(
|
|
renderPage(
|
|
"Safe Mode",
|
|
`<section class="card"><h2>Access denied</h2><p class="muted">You do not have administrator access.</p></section>`
|
|
)
|
|
);
|
|
}
|
|
const snapshots = listSnapshots();
|
|
const table = buildSnapshotTable(snapshots);
|
|
res.send(
|
|
renderPage(
|
|
"Safe Mode",
|
|
`<section class="card"><h2>Rollback snapshots</h2><p class="muted">Use these snapshots to roll back failed updates. The server will restart after rollback.</p>${table}</section>`
|
|
)
|
|
);
|
|
});
|
|
|
|
app.get("/auth/discord", (req, res) => {
|
|
if (!isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
req.session.discordState = state;
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri = `${baseUrl}/auth/discord/callback`;
|
|
const url = buildDiscordAuthUrl(state, redirectUri);
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.get("/auth/discord/callback", async (req, res) => {
|
|
const { code, state } = req.query;
|
|
if (!code || state !== req.session.discordState) {
|
|
return res.send(renderPage("Safe Mode", "<section class=\"card\">Invalid login state.</section>"));
|
|
}
|
|
try {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri = `${baseUrl}/auth/discord/callback`;
|
|
const token = await exchangeDiscordCode(code, redirectUri);
|
|
const user = await fetchDiscordUser(token.access_token);
|
|
const guildId = getSetting("discord_guild_id");
|
|
const member = guildId
|
|
? await fetchDiscordGuildMember(token.access_token, guildId)
|
|
: null;
|
|
const roles = member?.roles || [];
|
|
const flags = getRoleFlags(roles);
|
|
req.session.user = {
|
|
id: user.id,
|
|
username: user.global_name || user.username,
|
|
roles,
|
|
...flags
|
|
};
|
|
res.redirect("/");
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.send(renderPage("Safe Mode", "<section class=\"card\">Login failed.</section>"));
|
|
}
|
|
});
|
|
|
|
app.post("/rollback/:id", (req, res) => {
|
|
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
|
|
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
|
|
}
|
|
try {
|
|
restoreSnapshot(req.params.id);
|
|
res.send(
|
|
renderPage(
|
|
"Safe Mode",
|
|
"<section class=\"card\"><h2>Rollback complete</h2><p class=\"muted\">Restarting the bot now...</p></section>"
|
|
)
|
|
);
|
|
requestRestart();
|
|
} catch (error) {
|
|
res.send(
|
|
renderPage(
|
|
"Safe Mode",
|
|
`<section class=\"card\"><h2>Rollback failed</h2><p class=\"muted\">${error.message}</p></section>`
|
|
)
|
|
);
|
|
}
|
|
});
|
|
|
|
const port = Number(process.env.SAFE_MODE_PORT || 3001);
|
|
app.listen(port, () => {
|
|
console.log(`Safe mode listening on http://localhost:${port}`);
|
|
});
|