Lumi/safe-mode.js
2026-05-30 20:37:42 +02:00

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}`);
});