Files
2026-08-10 20:15:00 +01:00

511 lines
15 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const axios = require("axios");
const YAML = require("yaml");
const configStore = require("./ConfigStore.js");
const { CALLBACK_PATH } = require("./DiscordOAuth.js");
const DISCORD_API = "https://discord.com/api/v10";
const CONFIG_DIR = path.join(__dirname, "../Configs");
const API_FILE = path.join(CONFIG_DIR, "api.yml");
const SUPPORTBOT_FILE = path.join(CONFIG_DIR, "supportbot.yml");
const PLACEHOLDER_PATTERNS = [
/DISCORD_CLIENT_ID/i,
/DISCORD_CLIENT_SECRET/i,
/YOUR_DISCORD_USER_ID/i,
/CHANGE_ME/i,
/BOT_TOKEN/i,
/PUT_RANDOM_STRING/i,
];
function isEmpty(value) {
if (value == null) return true;
if (typeof value === "boolean") return false;
if (typeof value === "number") return false;
return String(value).trim() === "";
}
function isPlaceholder(value) {
if (isEmpty(value)) return true;
const s = String(value).trim();
return PLACEHOLDER_PATTERNS.some((p) => p.test(s));
}
function isDiscordSnowflake(value) {
return /^\d{17,20}$/.test(String(value).trim());
}
function isValidBotToken(value) {
if (isPlaceholder(value)) return false;
const s = String(value).trim();
return s.length >= 50 && s.includes(".");
}
function isValidSecretKey(value) {
if (isPlaceholder(value)) return false;
return String(value).trim().length >= 16;
}
function readApiRoot() {
if (!fs.existsSync(API_FILE)) return null;
try {
const raw = YAML.parse(fs.readFileSync(API_FILE, "utf8"));
return raw?.API ?? raw ?? null;
} catch {
return null;
}
}
function readSupportbotRoot() {
if (!fs.existsSync(SUPPORTBOT_FILE)) return {};
try {
return YAML.parse(fs.readFileSync(SUPPORTBOT_FILE, "utf8")) || {};
} catch {
return {};
}
}
function getOwnerIds(oauth) {
const ids = [];
for (const id of oauth?.OwnerUserIds || []) {
const s = String(id).trim();
if (s) ids.push(s);
}
if (ids.length === 0) {
for (const id of oauth?.AllowedUserIds || []) {
const s = String(id).trim();
if (s) ids.push(s);
}
}
return ids;
}
function buildSteps(apiRoot, supportbot) {
const oauth = apiRoot?.OAuth || {};
const token = supportbot?.General?.Token;
const dbConfig = supportbot?.Database || {};
return [
{
id: "botToken",
title: "Bot token",
description: "From the Discord Developer Portal → your application → Bot → Reset Token.",
complete: isValidBotToken(token),
},
{
id: "database",
title: "Database storage",
description: "Choose SQLite or MySQL database engine for storing bot data.",
complete: Boolean(dbConfig.Driver),
},
{
id: "secretKey",
title: "Dashboard session secret",
description: "Random string used to sign login cookies. Keep it private.",
complete: isValidSecretKey(apiRoot?.SecretKey),
},
{
id: "oauth",
title: "Discord OAuth app",
description: "Same application as your bot — Client ID and Client Secret from OAuth2.",
complete:
!isPlaceholder(oauth.ClientId) &&
!isPlaceholder(oauth.ClientSecret) &&
isDiscordSnowflake(oauth.ClientId),
},
{
id: "redirectUri",
title: "OAuth redirect URL",
description: `Add this exact URL under OAuth2 → Redirects in the Discord portal.`,
complete: isValidRedirectUriConfigured(oauth.RedirectUri),
},
{
id: "owners",
title: "Dashboard owners",
description: "Discord user IDs that can sign in and manage the dashboard (at least one).",
complete: getOwnerIds(oauth).length > 0 && getOwnerIds(oauth).every(isDiscordSnowflake),
},
{
id: "emeraldApi",
title: "Emerald API",
description: "Optional. Used for the one-click addon installer.",
complete: true,
},
];
}
function isValidRedirectUriConfigured(uri) {
if (isPlaceholder(uri)) return false;
try {
const u = new URL(String(uri).trim());
if (!["http:", "https:"].includes(u.protocol)) return false;
return u.pathname.replace(/\/$/, "") === CALLBACK_PATH.replace(/\/$/, "");
} catch {
return false;
}
}
function isSetupComplete() {
const apiRoot = readApiRoot();
if (!apiRoot?.Enabled) return false;
const oauth = apiRoot.OAuth || {};
if (!oauth.Enabled) return false;
const steps = buildSteps(apiRoot, readSupportbotRoot());
return steps.every((s) => s.complete);
}
function getSetupStatus(req) {
const apiRoot = readApiRoot() || {};
const steps = buildSteps(apiRoot, readSupportbotRoot());
const complete = steps.every((s) => s.complete);
let suggestedRedirectUri = null;
if (req) {
const proto =
req.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
req.protocol ||
"http";
const host = req.get("x-forwarded-host")?.split(",")[0]?.trim() || req.get("host");
if (host) {
suggestedRedirectUri = `${proto}://${host}${CALLBACK_PATH}`;
}
}
if (!suggestedRedirectUri) {
suggestedRedirectUri = `http://localhost:${apiRoot.Port || 3000}${CALLBACK_PATH}`;
}
return {
complete,
steps,
suggestedRedirectUri,
oauthCallbackPath: CALLBACK_PATH,
};
}
async function validateBotToken(token) {
const trimmed = String(token || "").trim();
if (!isValidBotToken(trimmed)) {
return { ok: false, error: "Enter a valid bot token from the Discord Developer Portal." };
}
try {
const [userRes, appRes] = await Promise.all([
axios.get(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bot ${trimmed}` },
timeout: 12_000,
}),
axios.get(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${trimmed}` },
timeout: 12_000,
}),
]);
return {
ok: true,
bot: {
id: userRes.data.id,
username: userRes.data.username,
discriminator: userRes.data.discriminator,
},
applicationId: String(appRes.data.id),
};
} catch (err) {
const status = err.response?.status;
if (status === 401) {
return { ok: false, error: "Discord rejected this bot token (401 Unauthorized)." };
}
return {
ok: false,
error: err.response?.data?.message || err.message || "Could not verify bot token.",
};
}
}
async function validateOAuthCredentials(clientId, clientSecret, botToken) {
const id = String(clientId || "").trim();
const secret = String(clientSecret || "").trim();
if (!isDiscordSnowflake(id)) {
return { ok: false, error: "Client ID must be a numeric Discord application ID." };
}
if (isPlaceholder(secret) || secret.length < 20) {
return { ok: false, error: "Enter the OAuth Client Secret from the Discord portal." };
}
if (botToken) {
const botCheck = await validateBotToken(botToken);
if (botCheck.ok && botCheck.applicationId && botCheck.applicationId !== id) {
return {
ok: false,
error: `Client ID does not match the bot application (${botCheck.applicationId}). Use the same app's credentials.`,
};
}
}
try {
await axios.post(
`${DISCORD_API}/oauth2/token`,
new URLSearchParams({ grant_type: "client_credentials", scope: "identify" }),
{
auth: { username: id, password: secret },
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: 12_000,
},
);
return { ok: true, clientId: id };
} catch (err) {
const status = err.response?.status;
if (botToken) {
const botCheck = await validateBotToken(botToken);
if (botCheck.ok && botCheck.applicationId === id) {
return {
ok: true,
clientId: id,
warning:
"Could not verify the client secret with Discord (token endpoint). ID matches your bot — double-check the secret in the portal.",
};
}
}
if (status === 401 || status === 400) {
return { ok: false, error: "Invalid Client ID or Client Secret." };
}
return {
ok: false,
error: err.response?.data?.error_description || err.message || "OAuth validation failed.",
};
}
}
function validateRedirectUri(uri) {
const trimmed = String(uri || "").trim();
if (!isValidRedirectUriConfigured(trimmed)) {
return {
ok: false,
error: `Redirect URL must end with ${CALLBACK_PATH} and use http or https.`,
};
}
return { ok: true, redirectUri: trimmed };
}
async function validateOwnerUserIds(ownerIds, botToken) {
const ids = (Array.isArray(ownerIds) ? ownerIds : [ownerIds])
.map((id) => String(id).trim())
.filter(Boolean);
if (ids.length === 0) {
return { ok: false, error: "Add at least one Discord user ID." };
}
const invalid = ids.filter((id) => !isDiscordSnowflake(id));
if (invalid.length) {
return { ok: false, error: `Invalid user ID: ${invalid[0]}` };
}
if (!botToken || !isValidBotToken(botToken)) {
return { ok: true, ownerUserIds: ids, warning: "User IDs format OK (bot token not verified yet)." };
}
const users = [];
try {
for (const id of ids) {
const res = await axios.get(`${DISCORD_API}/users/${id}`, {
headers: { Authorization: `Bot ${String(botToken).trim()}` },
timeout: 12_000,
});
users.push({
id: res.data.id,
username: res.data.username,
globalName: res.data.global_name || null,
});
}
return { ok: true, ownerUserIds: ids, users };
} catch (err) {
if (err.response?.status === 404) {
return { ok: false, error: "One or more user IDs were not found. Check Developer Mode → Copy User ID." };
}
if (err.response?.status === 403) {
return {
ok: false,
error: "Bot could not look up users. Ensure the bot is in a server with those users, or verify IDs manually.",
};
}
return { ok: false, error: err.message || "Failed to verify owner user IDs." };
}
}
function validateSecretKey(secretKey) {
if (!isValidSecretKey(secretKey)) {
return { ok: false, error: "Use at least 16 characters (not CHANGE_ME)." };
}
return { ok: true };
}
async function validateDatabase(dbDriver, dbHost, dbPort, dbUser, dbPassword, dbDatabase) {
const driver = String(dbDriver || "sqlite").toLowerCase();
if (driver === "mysql" || driver === "mariadb") {
const host = String(dbHost || "").trim();
const port = Number(dbPort) || 3306;
const user = String(dbUser || "").trim();
const password = String(dbPassword || "");
const database = String(dbDatabase || "").trim();
if (isEmpty(host)) return { ok: false, error: "MySQL Host is required." };
if (isEmpty(user)) return { ok: false, error: "MySQL User is required." };
if (isEmpty(database)) return { ok: false, error: "MySQL Database Name is required." };
try {
const mysql = require("mysql2/promise");
const connection = await mysql.createConnection({
host,
port,
user,
password,
database,
connectTimeout: 5000,
});
await connection.end();
return { ok: true, driver: "mysql" };
} catch (err) {
return {
ok: false,
error: `MySQL Connection Failed: ${err.message || err.code || "Could not connect to database host."}`,
};
}
}
return { ok: true, driver: "sqlite" };
}
async function validateSetupPayload(payload) {
const results = {};
if (payload.botToken != null) {
results.botToken = await validateBotToken(payload.botToken);
}
if (payload.dbDriver != null) {
results.database = await validateDatabase(
payload.dbDriver,
payload.dbHost,
payload.dbPort,
payload.dbUser,
payload.dbPassword,
payload.dbDatabase,
);
}
if (payload.secretKey != null) {
results.secretKey = validateSecretKey(payload.secretKey);
}
if (payload.clientId != null && payload.clientSecret != null) {
results.oauth = await validateOAuthCredentials(
payload.clientId,
payload.clientSecret,
payload.botToken,
);
}
if (payload.redirectUri != null) {
results.redirectUri = validateRedirectUri(payload.redirectUri);
}
if (payload.ownerUserIds != null) {
results.owners = await validateOwnerUserIds(payload.ownerUserIds, payload.botToken);
}
if (payload.emeraldApiKey != null) {
const key = String(payload.emeraldApiKey).trim();
if (key === "") {
results.emeraldApi = { ok: true };
} else if (key.length < 10) {
results.emeraldApi = { ok: false, error: "Invalid API key." };
} else {
results.emeraldApi = { ok: true };
}
}
const allOk = Object.values(results).every((r) => r?.ok);
return { ok: allOk, results };
}
function ensureApiFile() {
if (fs.existsSync(API_FILE)) return;
const example = path.join(CONFIG_DIR, "api.yml.example");
if (fs.existsSync(example)) {
fs.copyFileSync(example, API_FILE);
configStore.reloadFile("api");
} else {
throw new Error("Configs/api.yml is missing. Copy Configs/api.yml.example to api.yml.");
}
}
function applySetup(payload) {
ensureApiFile();
const ownerIds = (Array.isArray(payload.ownerUserIds)
? payload.ownerUserIds
: String(payload.ownerUserIds || "").split(/[\s,]+/)
)
.map((id) => String(id).trim())
.filter(Boolean);
const apiDoc = YAML.parseDocument(fs.readFileSync(API_FILE, "utf8"));
if (!apiDoc.get("API")) apiDoc.set("API", {});
apiDoc.setIn(["API", "Enabled"], true);
apiDoc.setIn(["API", "SecretKey"], String(payload.secretKey).trim());
if (payload.emeraldApiKey != null) {
apiDoc.setIn(["API", "EmeraldAPIKey"], String(payload.emeraldApiKey).trim());
}
if (payload.port != null && payload.port !== "") {
apiDoc.setIn(["API", "Port"], Number(payload.port) || 3000);
}
if (!apiDoc.getIn(["API", "OAuth"])) apiDoc.setIn(["API", "OAuth"], {});
apiDoc.setIn(["API", "OAuth", "Enabled"], true);
apiDoc.setIn(["API", "OAuth", "ClientId"], String(payload.clientId).trim());
apiDoc.setIn(["API", "OAuth", "ClientSecret"], String(payload.clientSecret).trim());
apiDoc.setIn(["API", "OAuth", "RedirectUri"], String(payload.redirectUri).trim());
apiDoc.setIn(["API", "OAuth", "OwnerUserIds"], ownerIds);
apiDoc.setIn(["API", "OAuth", "AllowedUserIds"], []);
fs.writeFileSync(API_FILE, apiDoc.toString());
const sbDoc = YAML.parseDocument(fs.readFileSync(SUPPORTBOT_FILE, "utf8"));
if (payload.botToken != null && String(payload.botToken).trim() !== "") {
sbDoc.setIn(["General", "Token"], String(payload.botToken).trim());
}
if (payload.dbDriver != null) {
const driver = String(payload.dbDriver).toLowerCase() === "mysql" ? "mysql" : "sqlite";
sbDoc.setIn(["Database", "Driver"], driver);
if (driver === "mysql") {
sbDoc.setIn(["Database", "MySQL", "Host"], String(payload.dbHost || "127.0.0.1").trim());
sbDoc.setIn(["Database", "MySQL", "Port"], Number(payload.dbPort) || 3306);
sbDoc.setIn(["Database", "MySQL", "User"], String(payload.dbUser || "").trim());
sbDoc.setIn(["Database", "MySQL", "Password"], String(payload.dbPassword || ""));
sbDoc.setIn(["Database", "MySQL", "Database"], String(payload.dbDatabase || "").trim());
} else {
sbDoc.setIn(["Database", "SQLite", "File"], "./Data/supportbot.db");
}
}
fs.writeFileSync(SUPPORTBOT_FILE, sbDoc.toString());
configStore.reloadFile("api");
configStore.reloadFile("supportbot");
return {
ownerUserIds: ownerIds,
api: configStore.api?.API || configStore.api,
};
}
module.exports = {
isSetupComplete,
getSetupStatus,
validateBotToken,
validateOAuthCredentials,
validateRedirectUri,
validateOwnerUserIds,
validateSecretKey,
validateSetupPayload,
applySetup,
};