6577 lines
211 KiB
JavaScript
6577 lines
211 KiB
JavaScript
const express = require("express");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const { Permissions } = require("discord.js");
|
|
let multer = null;
|
|
try {
|
|
multer = require("multer");
|
|
} catch {
|
|
multer = null;
|
|
}
|
|
const session = require("express-session");
|
|
const BetterSqlite3Store = require("better-sqlite3-session-store")(session);
|
|
|
|
const { db } = require("../services/db");
|
|
const { getSetting, setSetting, getAllSettings } = require("../services/settings");
|
|
const {
|
|
deleteCustomTheme,
|
|
duplicateTheme,
|
|
FONT_STACKS,
|
|
getActiveTheme,
|
|
getThemeById,
|
|
listThemes,
|
|
renameCustomTheme,
|
|
saveCustomTheme,
|
|
setActiveTheme,
|
|
valuesFromRequest
|
|
} = require("../services/themes");
|
|
const { getRoleFlags, hasAccess } = require("../services/rbac");
|
|
const {
|
|
buildDiscordAuthUrl,
|
|
exchangeDiscordCode,
|
|
fetchDiscordUser,
|
|
fetchDiscordGuildMember,
|
|
buildTwitchAuthUrl,
|
|
exchangeTwitchCode,
|
|
fetchTwitchUser,
|
|
buildYouTubeAuthUrl,
|
|
exchangeYouTubeCode,
|
|
fetchYouTubeChannel
|
|
} = require("../services/auth");
|
|
const { getPluginProfileStats } = require("../services/plugin-stats");
|
|
const {
|
|
getLeaderboardSections,
|
|
getTopCommandOptions
|
|
} = require("../services/top");
|
|
const { log, listLogs } = require("../services/logger");
|
|
const { createWebhookService } = require("../services/webhooks");
|
|
const {
|
|
getPlatformStatus,
|
|
getEnabledPlatformIds,
|
|
getLoginPlatforms,
|
|
getLinkPlatforms,
|
|
getPlatformLabel,
|
|
getPlatformBadge,
|
|
isPlatformEnabled,
|
|
isPlatformConfigured,
|
|
normalizePlatformSelection,
|
|
serializePlatformSelection
|
|
} = require("../services/platforms");
|
|
const { getClient: getTwitchClient } = require("../services/twitch");
|
|
const { getClient: getYouTubeClient } = require("../services/youtube");
|
|
const {
|
|
ensureUserForIdentity,
|
|
linkIdentityToUser,
|
|
getUserProfileById,
|
|
getUserIdentities,
|
|
updateInternalUsername,
|
|
listUsersWithIdentities,
|
|
searchKnownUsers
|
|
} = require("../services/users");
|
|
const {
|
|
getPlugins,
|
|
syncPluginRegistry,
|
|
setPluginEnabled,
|
|
removePlugin,
|
|
installFromGit,
|
|
updatePluginFromGit,
|
|
createLocalPlugin
|
|
} = require("../services/plugins");
|
|
const { checkForUpdates, pullUpdates, requestRestart } = require("../services/updater");
|
|
const {
|
|
applyBotUpdate,
|
|
applyPluginUpdate,
|
|
listSnapshots
|
|
} = require("../services/update-manager");
|
|
const { getUpdateStatus } = require("../services/update-index");
|
|
const {
|
|
applyCoreUpdate,
|
|
applyPluginUpdateFromRepo,
|
|
revertCoreSnapshot,
|
|
revertPluginSnapshot,
|
|
disablePluginForRecovery
|
|
} = require("../services/repo-update");
|
|
const {
|
|
safeModeStatus,
|
|
clearRecoveryMarker,
|
|
updateRecoveryMarker
|
|
} = require("../services/recovery-mode");
|
|
const {
|
|
generateCommandPreview,
|
|
previewParts
|
|
} = require("../services/command-preview");
|
|
const {
|
|
isDestructivePath,
|
|
issueConfirmation,
|
|
consumeConfirmation,
|
|
normalizeAction
|
|
} = require("../services/destructive-confirm");
|
|
const {
|
|
publishWebEvent,
|
|
subscribeWebEvents
|
|
} = require("../services/web-events");
|
|
const {
|
|
addSubmitterComment,
|
|
adminUpdateFeedback,
|
|
cleanupFeedback,
|
|
createFeedback,
|
|
deleteFeedback,
|
|
findSimilarFeedback,
|
|
feedbackOptions,
|
|
getFeedbackForSubmitter,
|
|
getFeedbackForAdmin,
|
|
getFeedbackAttachment,
|
|
getFeedbackForViewer,
|
|
listFeedbackForAdmin,
|
|
listMyFeedback,
|
|
listPublicFeedback,
|
|
markFeedbackViewed,
|
|
notificationSummary,
|
|
supportFeedback
|
|
} = require("../services/feedback");
|
|
|
|
function ensureSessionSecret() {
|
|
let secret = getSetting("session_secret");
|
|
if (!secret) {
|
|
secret = crypto.randomBytes(32).toString("hex");
|
|
setSetting("session_secret", secret);
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function isConfigured() {
|
|
const platforms = getPlatformStatus().filter(
|
|
(platform) => platform.supported && platform.enabled
|
|
);
|
|
if (!platforms.length) {
|
|
return false;
|
|
}
|
|
return platforms.some((platform) => platform.configured);
|
|
}
|
|
|
|
function normalizeHostName(value) {
|
|
const raw = String(value || "").toLowerCase();
|
|
if (raw.startsWith("[")) {
|
|
return raw.slice(1, raw.indexOf("]"));
|
|
}
|
|
if (raw === "::1" || raw === "::ffff:127.0.0.1") {
|
|
return raw;
|
|
}
|
|
return raw.replace(/:\d+$/, "");
|
|
}
|
|
|
|
function isLocalhostLoginAvailable(req) {
|
|
if (!req) return false;
|
|
const host = normalizeHostName(req.hostname || req.get("host"));
|
|
return host === "localhost" || host === "::1" || host === "::ffff:127.0.0.1" || host === "127.0.0.1" || host.startsWith("127.");
|
|
}
|
|
|
|
function getLocalhostLoginPlatform(req) {
|
|
if (!isLocalhostLoginAvailable(req)) return null;
|
|
return {
|
|
id: "localhost",
|
|
label: "Localhost Login",
|
|
configured: true,
|
|
enabled: true,
|
|
supported: true,
|
|
supportsLogin: true,
|
|
loginPath: "/auth/localhost"
|
|
};
|
|
}
|
|
|
|
function getPrimaryLoginPlatform(req) {
|
|
const localhostPlatform = getLocalhostLoginPlatform(req);
|
|
if (localhostPlatform) return localhostPlatform;
|
|
const platforms = getPlatformStatus().filter(
|
|
(platform) =>
|
|
platform.supported && platform.enabled && platform.supportsLogin
|
|
);
|
|
if (!platforms.length) {
|
|
return null;
|
|
}
|
|
return platforms.find((platform) => platform.configured) || platforms[0];
|
|
}
|
|
|
|
function getLoginRedirectPath(req) {
|
|
const platform = getPrimaryLoginPlatform(req);
|
|
return platform?.loginPath || "/setup";
|
|
}
|
|
|
|
function requireConfigured(req, res, next) {
|
|
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireAuth(req, res, next) {
|
|
if (!req.session.user) {
|
|
return res.redirect(getLoginRedirectPath(req));
|
|
}
|
|
next();
|
|
}
|
|
|
|
function trackModRole(db, user) {
|
|
if (!user?.id) {
|
|
return;
|
|
}
|
|
const isMod = Boolean(user.isAdmin || user.isMod);
|
|
const active = db
|
|
.prepare(
|
|
"SELECT id FROM mod_role_periods WHERE user_id = ? AND end_at IS NULL"
|
|
)
|
|
.get(user.id);
|
|
if (isMod && !active) {
|
|
db.prepare(
|
|
"INSERT INTO mod_role_periods (user_id, start_at, end_at) VALUES (?, ?, NULL)"
|
|
).run(user.id, Date.now());
|
|
} else if (!isMod && active) {
|
|
db.prepare("UPDATE mod_role_periods SET end_at = ? WHERE id = ?").run(
|
|
Date.now(),
|
|
active.id
|
|
);
|
|
}
|
|
}
|
|
|
|
function formatDuration(totalMs) {
|
|
const totalSeconds = Math.max(0, Math.floor(totalMs / 1000));
|
|
const days = Math.floor(totalSeconds / 86400);
|
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
if (days > 0) {
|
|
return `${days}d ${hours}h`;
|
|
}
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
return `${minutes}m`;
|
|
}
|
|
|
|
function requireRole(role) {
|
|
return (req, res, next) => {
|
|
if (!req.session.user) {
|
|
return res.redirect(getLoginRedirectPath(req));
|
|
}
|
|
if (!hasAccess(req.session.user, role)) {
|
|
return res.status(403).render("error", {
|
|
title: "Access denied",
|
|
message: "You do not have access to that page."
|
|
});
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
function storeDiscordUser(user) {
|
|
const avatar = user.avatar
|
|
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
|
|
: null;
|
|
const displayName = user.global_name || user.username;
|
|
return ensureUserForIdentity({
|
|
provider: "discord",
|
|
providerUserId: user.id,
|
|
displayName,
|
|
avatar
|
|
});
|
|
}
|
|
|
|
async function fetchDiscordRolesForUser(discordUserId) {
|
|
const guildId = getSetting("discord_guild_id");
|
|
const botToken = getSetting("discord_bot_token");
|
|
if (!guildId || !botToken || !discordUserId) {
|
|
return [];
|
|
}
|
|
try {
|
|
const response = await fetch(
|
|
`https://discord.com/api/guilds/${guildId}/members/${discordUserId}`,
|
|
{ headers: { Authorization: `Bot ${botToken}` } }
|
|
);
|
|
if (!response.ok) {
|
|
return [];
|
|
}
|
|
const member = await response.json();
|
|
return member?.roles || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function getPreferredAvatar(userId) {
|
|
if (!userId) {
|
|
return null;
|
|
}
|
|
const identities = db
|
|
.prepare("SELECT provider, avatar FROM user_identities WHERE user_id = ?")
|
|
.all(userId);
|
|
const preferredOrder = ["discord", "twitch", "youtube"];
|
|
for (const provider of preferredOrder) {
|
|
const match = identities.find((identity) => identity.provider === provider);
|
|
if (match?.avatar) {
|
|
return match.avatar;
|
|
}
|
|
}
|
|
const fallback = identities.find((identity) => identity.avatar);
|
|
return fallback?.avatar || null;
|
|
}
|
|
|
|
function hasExpressionTables() {
|
|
const row = db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'expression_user_stats'"
|
|
)
|
|
.get();
|
|
return Boolean(row);
|
|
}
|
|
|
|
function getExpressionUserSummary(userId) {
|
|
if (!hasExpressionTables()) {
|
|
return null;
|
|
}
|
|
const rows = db
|
|
.prepare(
|
|
"SELECT action, given_count, received_count FROM expression_user_stats WHERE user_id = ?"
|
|
)
|
|
.all(userId);
|
|
const totals = rows.reduce(
|
|
(acc, row) => {
|
|
acc.given += row.given_count;
|
|
acc.received += row.received_count;
|
|
return acc;
|
|
},
|
|
{ given: 0, received: 0 }
|
|
);
|
|
return { totals };
|
|
}
|
|
|
|
function buildUserStatsPayload(userId) {
|
|
if (!userId) {
|
|
return { stats: null, expression: null, pluginStats: [] };
|
|
}
|
|
const stats = db
|
|
.prepare("SELECT * FROM stats WHERE user_id = ?")
|
|
.get(userId);
|
|
return {
|
|
stats,
|
|
expression: getExpressionUserSummary(userId),
|
|
pluginStats: getPluginProfileStats(userId)
|
|
};
|
|
}
|
|
|
|
function buildCompareRows(leftStats, rightStats) {
|
|
const rows = [];
|
|
const pushSection = (section, leftList, rightList) => {
|
|
const leftMap = new Map((leftList || []).map((item) => [item.label, item.value]));
|
|
const rightMap = new Map((rightList || []).map((item) => [item.label, item.value]));
|
|
const labels = [];
|
|
for (const label of leftMap.keys()) {
|
|
labels.push(label);
|
|
}
|
|
for (const label of rightMap.keys()) {
|
|
if (!leftMap.has(label)) {
|
|
labels.push(label);
|
|
}
|
|
}
|
|
labels.forEach((label) => {
|
|
rows.push({
|
|
section,
|
|
label,
|
|
left: leftMap.has(label) ? leftMap.get(label) : null,
|
|
right: rightMap.has(label) ? rightMap.get(label) : null
|
|
});
|
|
});
|
|
};
|
|
|
|
const leftCommunity = [
|
|
{ label: "Messages", value: leftStats.stats?.messages ?? 0 },
|
|
{ label: "Commands", value: leftStats.stats?.commands ?? 0 }
|
|
];
|
|
const rightCommunity = [
|
|
{ label: "Messages", value: rightStats.stats?.messages ?? 0 },
|
|
{ label: "Commands", value: rightStats.stats?.commands ?? 0 }
|
|
];
|
|
pushSection("Community Interaction", leftCommunity, rightCommunity);
|
|
|
|
if (leftStats.expression || rightStats.expression) {
|
|
const leftExpression = leftStats.expression
|
|
? [
|
|
{ label: "Actions given", value: leftStats.expression.totals.given },
|
|
{ label: "Actions received", value: leftStats.expression.totals.received }
|
|
]
|
|
: [];
|
|
const rightExpression = rightStats.expression
|
|
? [
|
|
{ label: "Actions given", value: rightStats.expression.totals.given },
|
|
{ label: "Actions received", value: rightStats.expression.totals.received }
|
|
]
|
|
: [];
|
|
pushSection("Expression Interaction", leftExpression, rightExpression);
|
|
}
|
|
|
|
const pluginSections = new Map();
|
|
leftStats.pluginStats.forEach((section) => {
|
|
pluginSections.set(section.title, {
|
|
left: section.stats || [],
|
|
right: []
|
|
});
|
|
});
|
|
rightStats.pluginStats.forEach((section) => {
|
|
const entry = pluginSections.get(section.title) || { left: [], right: [] };
|
|
entry.right = section.stats || [];
|
|
pluginSections.set(section.title, entry);
|
|
});
|
|
for (const [title, lists] of pluginSections.entries()) {
|
|
pushSection(title, lists.left, lists.right);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function getExpressionLeaderboards(limit = 10) {
|
|
if (!hasExpressionTables()) {
|
|
return null;
|
|
}
|
|
const given = db
|
|
.prepare(
|
|
"SELECT user_profiles.internal_username AS username, SUM(expression_user_stats.given_count) AS total " +
|
|
"FROM expression_user_stats " +
|
|
"JOIN user_profiles ON user_profiles.id = expression_user_stats.user_id " +
|
|
"GROUP BY expression_user_stats.user_id " +
|
|
"ORDER BY total DESC LIMIT ?"
|
|
)
|
|
.all(limit);
|
|
const received = db
|
|
.prepare(
|
|
"SELECT user_profiles.internal_username AS username, SUM(expression_user_stats.received_count) AS total " +
|
|
"FROM expression_user_stats " +
|
|
"JOIN user_profiles ON user_profiles.id = expression_user_stats.user_id " +
|
|
"GROUP BY expression_user_stats.user_id " +
|
|
"ORDER BY total DESC LIMIT ?"
|
|
)
|
|
.all(limit);
|
|
return { given, received };
|
|
}
|
|
|
|
const DISCORD_PERMISSION_DETAILS = {
|
|
CREATE_INSTANT_INVITE: {
|
|
label: "Create instant invites",
|
|
description: "Create invitations to the server."
|
|
},
|
|
KICK_MEMBERS: {
|
|
label: "Kick members",
|
|
description: "Kick members from the server."
|
|
},
|
|
BAN_MEMBERS: {
|
|
label: "Ban members",
|
|
description: "Ban or unban members from the server."
|
|
},
|
|
ADMINISTRATOR: {
|
|
label: "Administrator",
|
|
description: "Bypasses all permission checks and grants every permission."
|
|
},
|
|
MANAGE_CHANNELS: {
|
|
label: "Manage channels",
|
|
description: "Create, edit, or delete channels."
|
|
},
|
|
MANAGE_GUILD: {
|
|
label: "Manage server",
|
|
description: "Edit server settings and features."
|
|
},
|
|
ADD_REACTIONS: {
|
|
label: "Add reactions",
|
|
description: "Add new reactions to messages."
|
|
},
|
|
VIEW_AUDIT_LOG: {
|
|
label: "View audit log",
|
|
description: "View the server audit log."
|
|
},
|
|
PRIORITY_SPEAKER: {
|
|
label: "Priority speaker",
|
|
description: "Use priority speaker in voice channels."
|
|
},
|
|
STREAM: {
|
|
label: "Stream",
|
|
description: "Stream video in voice channels."
|
|
},
|
|
VIEW_CHANNEL: {
|
|
label: "View channels",
|
|
description: "View channels and read messages."
|
|
},
|
|
SEND_MESSAGES: {
|
|
label: "Send messages",
|
|
description: "Send messages in text channels."
|
|
},
|
|
SEND_TTS_MESSAGES: {
|
|
label: "Send TTS messages",
|
|
description: "Send text-to-speech messages."
|
|
},
|
|
MANAGE_MESSAGES: {
|
|
label: "Manage messages",
|
|
description: "Delete messages and manage pins."
|
|
},
|
|
EMBED_LINKS: {
|
|
label: "Embed links",
|
|
description: "Send embedded link previews."
|
|
},
|
|
ATTACH_FILES: {
|
|
label: "Attach files",
|
|
description: "Attach files and media."
|
|
},
|
|
READ_MESSAGE_HISTORY: {
|
|
label: "Read message history",
|
|
description: "Read messages posted before the bot joined the channel."
|
|
},
|
|
MENTION_EVERYONE: {
|
|
label: "Mention everyone",
|
|
description: "Use @everyone, @here, and role mentions."
|
|
},
|
|
USE_EXTERNAL_EMOJIS: {
|
|
label: "Use external emojis",
|
|
description: "Use emojis from other servers."
|
|
},
|
|
VIEW_GUILD_INSIGHTS: {
|
|
label: "View server insights",
|
|
description: "Access server insights."
|
|
},
|
|
CONNECT: {
|
|
label: "Connect to voice",
|
|
description: "Join voice channels."
|
|
},
|
|
SPEAK: {
|
|
label: "Speak in voice",
|
|
description: "Speak in voice channels."
|
|
},
|
|
MUTE_MEMBERS: {
|
|
label: "Mute members",
|
|
description: "Server mute members in voice."
|
|
},
|
|
DEAFEN_MEMBERS: {
|
|
label: "Deafen members",
|
|
description: "Server deafen members in voice."
|
|
},
|
|
MOVE_MEMBERS: {
|
|
label: "Move members",
|
|
description: "Move members between voice channels."
|
|
},
|
|
USE_VAD: {
|
|
label: "Use voice activity detection",
|
|
description: "Use voice activity detection."
|
|
},
|
|
CHANGE_NICKNAME: {
|
|
label: "Change nickname",
|
|
description: "Change the bot's nickname."
|
|
},
|
|
MANAGE_NICKNAMES: {
|
|
label: "Manage nicknames",
|
|
description: "Change other members' nicknames."
|
|
},
|
|
MANAGE_ROLES: {
|
|
label: "Manage roles",
|
|
description: "Create, edit, and assign roles."
|
|
},
|
|
MANAGE_WEBHOOKS: {
|
|
label: "Manage webhooks",
|
|
description: "Create, edit, and delete webhooks."
|
|
},
|
|
MANAGE_EMOJIS_AND_STICKERS: {
|
|
label: "Manage emojis and stickers",
|
|
description: "Create, edit, and delete emojis or stickers."
|
|
},
|
|
USE_APPLICATION_COMMANDS: {
|
|
label: "Use application commands",
|
|
description: "Use slash commands and context menu commands."
|
|
},
|
|
REQUEST_TO_SPEAK: {
|
|
label: "Request to speak",
|
|
description: "Request to speak in stage channels."
|
|
},
|
|
MANAGE_EVENTS: {
|
|
label: "Manage events",
|
|
description: "Create and manage scheduled events."
|
|
},
|
|
MANAGE_THREADS: {
|
|
label: "Manage threads",
|
|
description: "Manage threads and thread settings."
|
|
},
|
|
USE_PUBLIC_THREADS: {
|
|
label: "Use public threads",
|
|
description: "Use public threads (deprecated flag)."
|
|
},
|
|
CREATE_PUBLIC_THREADS: {
|
|
label: "Create public threads",
|
|
description: "Create public threads."
|
|
},
|
|
USE_PRIVATE_THREADS: {
|
|
label: "Use private threads",
|
|
description: "Use private threads (deprecated flag)."
|
|
},
|
|
CREATE_PRIVATE_THREADS: {
|
|
label: "Create private threads",
|
|
description: "Create private threads."
|
|
},
|
|
USE_EXTERNAL_STICKERS: {
|
|
label: "Use external stickers",
|
|
description: "Use stickers from other servers."
|
|
},
|
|
SEND_MESSAGES_IN_THREADS: {
|
|
label: "Send messages in threads",
|
|
description: "Send messages in threads."
|
|
},
|
|
START_EMBEDDED_ACTIVITIES: {
|
|
label: "Start embedded activities",
|
|
description: "Start embedded activities in voice channels."
|
|
},
|
|
MODERATE_MEMBERS: {
|
|
label: "Moderate members",
|
|
description: "Timeout members."
|
|
},
|
|
VIEW_CREATOR_MONETIZATION_ANALYTICS: {
|
|
label: "View monetization analytics",
|
|
description: "View creator monetization analytics."
|
|
},
|
|
USE_SOUNDBOARD: {
|
|
label: "Use soundboard",
|
|
description: "Use the soundboard in voice."
|
|
},
|
|
SEND_VOICE_MESSAGES: {
|
|
label: "Send voice messages",
|
|
description: "Send voice messages."
|
|
}
|
|
};
|
|
|
|
const TWITCH_SCOPE_DEFS = [
|
|
{
|
|
scope: "analytics:read:extensions",
|
|
label: "Extensions analytics",
|
|
description: "Read analytics data for extensions."
|
|
},
|
|
{
|
|
scope: "analytics:read:games",
|
|
label: "Games analytics",
|
|
description: "Read analytics data for games."
|
|
},
|
|
{
|
|
scope: "bits:read",
|
|
label: "Bits",
|
|
description: "Read Bits leaderboard data."
|
|
},
|
|
{
|
|
scope: "channel:bot",
|
|
label: "Channel bot",
|
|
description: "Act as a bot within a channel."
|
|
},
|
|
{
|
|
scope: "channel:edit:commercial",
|
|
label: "Run commercials",
|
|
description: "Run commercials on a channel."
|
|
},
|
|
{
|
|
scope: "channel:manage:ads",
|
|
label: "Manage ads",
|
|
description: "Manage ad settings for a channel."
|
|
},
|
|
{
|
|
scope: "channel:manage:broadcast",
|
|
label: "Manage broadcast",
|
|
description: "Manage stream metadata and settings."
|
|
},
|
|
{
|
|
scope: "channel:manage:extensions",
|
|
label: "Manage extensions",
|
|
description: "Manage channel extensions."
|
|
},
|
|
{
|
|
scope: "channel:manage:moderators",
|
|
label: "Manage moderators",
|
|
description: "Add or remove moderators."
|
|
},
|
|
{
|
|
scope: "channel:manage:polls",
|
|
label: "Manage polls",
|
|
description: "Create and manage polls."
|
|
},
|
|
{
|
|
scope: "channel:manage:predictions",
|
|
label: "Manage predictions",
|
|
description: "Create and manage predictions."
|
|
},
|
|
{
|
|
scope: "channel:manage:redemptions",
|
|
label: "Manage redemptions",
|
|
description: "Manage channel points rewards."
|
|
},
|
|
{
|
|
scope: "channel:manage:schedule",
|
|
label: "Manage schedule",
|
|
description: "Manage channel schedule."
|
|
},
|
|
{
|
|
scope: "channel:manage:videos",
|
|
label: "Manage videos",
|
|
description: "Manage videos for a channel."
|
|
},
|
|
{
|
|
scope: "channel:manage:vips",
|
|
label: "Manage VIPs",
|
|
description: "Add or remove VIPs."
|
|
},
|
|
{
|
|
scope: "channel:read:ads",
|
|
label: "Read ads",
|
|
description: "Read ad schedule data."
|
|
},
|
|
{
|
|
scope: "channel:read:charity",
|
|
label: "Read charity",
|
|
description: "Read charity campaign data."
|
|
},
|
|
{
|
|
scope: "channel:manage:charity",
|
|
label: "Manage charity",
|
|
description: "Manage charity campaign settings."
|
|
},
|
|
{
|
|
scope: "channel:read:editors",
|
|
label: "Read editors",
|
|
description: "Read channel editor list."
|
|
},
|
|
{
|
|
scope: "channel:read:goals",
|
|
label: "Read goals",
|
|
description: "Read creator goal data."
|
|
},
|
|
{
|
|
scope: "channel:read:hype_train",
|
|
label: "Read hype train",
|
|
description: "Read hype train data."
|
|
},
|
|
{
|
|
scope: "channel:read:polls",
|
|
label: "Read polls",
|
|
description: "Read poll data."
|
|
},
|
|
{
|
|
scope: "channel:read:predictions",
|
|
label: "Read predictions",
|
|
description: "Read prediction data."
|
|
},
|
|
{
|
|
scope: "channel:read:redemptions",
|
|
label: "Read redemptions",
|
|
description: "Read channel points rewards."
|
|
},
|
|
{
|
|
scope: "channel:read:stream_key",
|
|
label: "Read stream key",
|
|
description: "Read the channel stream key."
|
|
},
|
|
{
|
|
scope: "channel:read:subscriptions",
|
|
label: "Read subscriptions",
|
|
description: "Read subscriber data."
|
|
},
|
|
{
|
|
scope: "channel:read:vips",
|
|
label: "Read VIPs",
|
|
description: "Read the VIP list."
|
|
},
|
|
{
|
|
scope: "channel:moderate",
|
|
label: "Channel moderation",
|
|
description: "Perform channel moderation actions."
|
|
},
|
|
{
|
|
scope: "chat:edit",
|
|
label: "Chat edit",
|
|
description: "Send chat messages."
|
|
},
|
|
{
|
|
scope: "chat:read",
|
|
label: "Chat read",
|
|
description: "Read chat messages."
|
|
},
|
|
{
|
|
scope: "clips:edit",
|
|
label: "Edit clips",
|
|
description: "Create or edit clips."
|
|
},
|
|
{
|
|
scope: "moderation:read",
|
|
label: "Read moderation",
|
|
description: "Read moderation data."
|
|
},
|
|
{
|
|
scope: "moderator:manage:announcements",
|
|
label: "Manage announcements",
|
|
description: "Send announcements in chat."
|
|
},
|
|
{
|
|
scope: "moderator:manage:automod",
|
|
label: "Manage AutoMod",
|
|
description: "Manage AutoMod actions."
|
|
},
|
|
{
|
|
scope: "moderator:manage:automod_settings",
|
|
label: "Manage AutoMod settings",
|
|
description: "Update AutoMod settings."
|
|
},
|
|
{
|
|
scope: "moderator:manage:banned_users",
|
|
label: "Manage banned users",
|
|
description: "Ban or unban users."
|
|
},
|
|
{
|
|
scope: "moderator:manage:blocked_terms",
|
|
label: "Manage blocked terms",
|
|
description: "Manage blocked terms."
|
|
},
|
|
{
|
|
scope: "moderator:manage:chat_messages",
|
|
label: "Manage chat messages",
|
|
description: "Delete or manage chat messages."
|
|
},
|
|
{
|
|
scope: "moderator:manage:chat_settings",
|
|
label: "Manage chat settings",
|
|
description: "Update chat settings."
|
|
},
|
|
{
|
|
scope: "moderator:manage:shield_mode",
|
|
label: "Manage shield mode",
|
|
description: "Enable or disable shield mode."
|
|
},
|
|
{
|
|
scope: "moderator:manage:shoutouts",
|
|
label: "Manage shoutouts",
|
|
description: "Send or manage shoutouts."
|
|
},
|
|
{
|
|
scope: "moderator:read:automod_settings",
|
|
label: "Read AutoMod settings",
|
|
description: "Read AutoMod settings."
|
|
},
|
|
{
|
|
scope: "moderator:read:blocked_terms",
|
|
label: "Read blocked terms",
|
|
description: "Read blocked terms."
|
|
},
|
|
{
|
|
scope: "moderator:read:chat_settings",
|
|
label: "Read chat settings",
|
|
description: "Read chat settings."
|
|
},
|
|
{
|
|
scope: "moderator:read:followers",
|
|
label: "Read followers",
|
|
description: "Read follower list."
|
|
},
|
|
{
|
|
scope: "moderator:read:shield_mode",
|
|
label: "Read shield mode",
|
|
description: "Read shield mode status."
|
|
},
|
|
{
|
|
scope: "moderator:read:shoutouts",
|
|
label: "Read shoutouts",
|
|
description: "Read shoutout settings."
|
|
},
|
|
{
|
|
scope: "moderator:read:vips",
|
|
label: "Read VIPs",
|
|
description: "Read VIP list."
|
|
},
|
|
{
|
|
scope: "user:bot",
|
|
label: "User bot",
|
|
description: "Act as a bot on behalf of a user."
|
|
},
|
|
{
|
|
scope: "user:edit",
|
|
label: "Edit user",
|
|
description: "Edit a user's profile."
|
|
},
|
|
{
|
|
scope: "user:edit:follows",
|
|
label: "Edit follows",
|
|
description: "Manage follows for a user."
|
|
},
|
|
{
|
|
scope: "user:manage:blocked_users",
|
|
label: "Manage blocked users",
|
|
description: "Block or unblock users."
|
|
},
|
|
{
|
|
scope: "user:manage:chat_color",
|
|
label: "Manage chat color",
|
|
description: "Change chat color."
|
|
},
|
|
{
|
|
scope: "user:manage:whispers",
|
|
label: "Manage whispers",
|
|
description: "Send and manage whispers."
|
|
},
|
|
{
|
|
scope: "user:read:blocked_users",
|
|
label: "Read blocked users",
|
|
description: "Read blocked users list."
|
|
},
|
|
{
|
|
scope: "user:read:broadcast",
|
|
label: "Read broadcast",
|
|
description: "Read broadcast settings."
|
|
},
|
|
{
|
|
scope: "user:read:email",
|
|
label: "Read email",
|
|
description: "Read the user's email address."
|
|
},
|
|
{
|
|
scope: "user:read:follows",
|
|
label: "Read follows",
|
|
description: "Read the user's follows."
|
|
},
|
|
{
|
|
scope: "user:read:subscriptions",
|
|
label: "Read subscriptions",
|
|
description: "Read user subscription data."
|
|
},
|
|
{
|
|
scope: "user:read:chat",
|
|
label: "Read chat",
|
|
description: "Read chat messages via API."
|
|
},
|
|
{
|
|
scope: "user:write:chat",
|
|
label: "Write chat",
|
|
description: "Send chat messages via API."
|
|
},
|
|
{
|
|
scope: "whispers:read",
|
|
label: "Read whispers",
|
|
description: "Read whispers."
|
|
},
|
|
{
|
|
scope: "whispers:edit",
|
|
label: "Send whispers",
|
|
description: "Send whispers."
|
|
}
|
|
];
|
|
|
|
function buildPrivilegeRow(id, label, description, granted) {
|
|
const safeLabel = (label || "").toString();
|
|
const safeDescription = (description || "").toString();
|
|
const grantedFlag = Boolean(granted);
|
|
return {
|
|
id,
|
|
label: safeLabel,
|
|
description: safeDescription,
|
|
granted: grantedFlag,
|
|
search: `${safeLabel} ${safeDescription} ${grantedFlag ? "granted" : "missing"}`.trim(),
|
|
sort: {
|
|
label: safeLabel.toLowerCase(),
|
|
description: safeDescription.toLowerCase(),
|
|
status: grantedFlag ? 1 : 0
|
|
}
|
|
};
|
|
}
|
|
|
|
function parseChannelList(value) {
|
|
return (value || "")
|
|
.split(/[,\s]+/g)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function buildDiscordPrivileges(discordClient) {
|
|
const rows = [];
|
|
const guildId = getSetting("discord_guild_id");
|
|
const hasClient = Boolean(discordClient && discordClient.user);
|
|
|
|
rows.push(
|
|
buildPrivilegeRow(
|
|
"discord-client-ready",
|
|
"Client connected",
|
|
"The Discord client is logged in and ready.",
|
|
hasClient
|
|
)
|
|
);
|
|
rows.push(
|
|
buildPrivilegeRow(
|
|
"discord-guild-configured",
|
|
"Guild configured",
|
|
"A server ID is configured in settings.",
|
|
Boolean(guildId)
|
|
)
|
|
);
|
|
|
|
let guild = null;
|
|
if (hasClient && guildId) {
|
|
guild = discordClient.guilds?.cache?.get(guildId) || null;
|
|
if (!guild && typeof discordClient.guilds?.fetch === "function") {
|
|
try {
|
|
guild = await discordClient.guilds.fetch(guildId);
|
|
} catch {
|
|
guild = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
rows.push(
|
|
buildPrivilegeRow(
|
|
"discord-guild-access",
|
|
"Bot in guild",
|
|
"The bot can access the configured server.",
|
|
Boolean(guild)
|
|
)
|
|
);
|
|
|
|
let permissions = null;
|
|
if (guild && discordClient?.user?.id) {
|
|
try {
|
|
const cachedMember = guild.members?.cache?.get(discordClient.user.id) || null;
|
|
const member = cachedMember || (await guild.members.fetch(discordClient.user.id));
|
|
permissions = member?.permissions || null;
|
|
} catch {
|
|
permissions = null;
|
|
}
|
|
}
|
|
|
|
const permissionFlags = Permissions?.FLAGS || {};
|
|
const canCheck = Boolean(permissions && typeof permissions.has === "function");
|
|
for (const key of Object.keys(permissionFlags)) {
|
|
const details = DISCORD_PERMISSION_DETAILS[key] || {
|
|
label: toTitleCase(key),
|
|
description: "Discord permission flag."
|
|
};
|
|
const flag = permissionFlags[key];
|
|
const granted = canCheck && flag ? permissions.has(flag) : false;
|
|
rows.push(
|
|
buildPrivilegeRow(
|
|
`discord-perm-${key.toLowerCase()}`,
|
|
details.label,
|
|
details.description,
|
|
granted
|
|
)
|
|
);
|
|
}
|
|
|
|
return {
|
|
rows,
|
|
guildName: guild?.name || null
|
|
};
|
|
}
|
|
|
|
async function buildTwitchPrivileges() {
|
|
const clientId = getSetting("twitch_client_id");
|
|
const clientSecret = getSetting("twitch_client_secret");
|
|
const redirectUri = getSetting("twitch_redirect_uri");
|
|
const botUsername = getSetting("twitch_bot_username");
|
|
const botOauth = getSetting("twitch_bot_oauth");
|
|
const channels = parseChannelList(getSetting("twitch_channels"));
|
|
const twitchClient = getTwitchClient();
|
|
const connected = Boolean(twitchClient);
|
|
const joinedChannels =
|
|
twitchClient && typeof twitchClient.getChannels === "function"
|
|
? twitchClient.getChannels()
|
|
: [];
|
|
|
|
const rows = [
|
|
buildPrivilegeRow(
|
|
"twitch-client-id",
|
|
"Client ID",
|
|
"A Twitch app client ID is configured.",
|
|
Boolean(clientId)
|
|
),
|
|
buildPrivilegeRow(
|
|
"twitch-client-secret",
|
|
"Client secret",
|
|
"A Twitch app client secret is configured.",
|
|
Boolean(clientSecret)
|
|
),
|
|
buildPrivilegeRow(
|
|
"twitch-redirect",
|
|
"OAuth redirect URL",
|
|
"The OAuth redirect URL is configured.",
|
|
Boolean(redirectUri)
|
|
),
|
|
buildPrivilegeRow(
|
|
"twitch-bot-username",
|
|
"Bot username",
|
|
"The chat bot username is configured.",
|
|
Boolean(botUsername)
|
|
),
|
|
buildPrivilegeRow(
|
|
"twitch-bot-oauth",
|
|
"Bot OAuth token",
|
|
"The chat bot OAuth token is configured.",
|
|
Boolean(botOauth)
|
|
),
|
|
buildPrivilegeRow(
|
|
"twitch-channels-configured",
|
|
"Channels configured",
|
|
"At least one channel is configured for chat.",
|
|
channels.length > 0
|
|
),
|
|
buildPrivilegeRow(
|
|
"twitch-chat-connected",
|
|
"Chat connected",
|
|
"The Twitch chat client is connected.",
|
|
connected
|
|
),
|
|
buildPrivilegeRow(
|
|
"twitch-channels-joined",
|
|
"Channels joined",
|
|
"The chat client has joined its configured channels.",
|
|
joinedChannels.length > 0
|
|
)
|
|
];
|
|
|
|
let grantedScopes = [];
|
|
if (botOauth) {
|
|
const rawToken = botOauth.startsWith("oauth:") ? botOauth.slice(6) : botOauth;
|
|
try {
|
|
const response = await fetch("https://id.twitch.tv/oauth2/validate", {
|
|
headers: { Authorization: `OAuth ${rawToken}` }
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (Array.isArray(data.scopes)) {
|
|
grantedScopes = data.scopes;
|
|
}
|
|
}
|
|
} catch {
|
|
grantedScopes = [];
|
|
}
|
|
}
|
|
|
|
const grantedSet = new Set(grantedScopes.map((scope) => scope.toLowerCase()));
|
|
const knownScopeSet = new Set();
|
|
const scopeRows = TWITCH_SCOPE_DEFS.map((def) => {
|
|
const scopeKey = def.scope.toLowerCase();
|
|
knownScopeSet.add(scopeKey);
|
|
return buildPrivilegeRow(
|
|
`twitch-scope-${slugify(def.scope)}`,
|
|
def.label,
|
|
def.description,
|
|
grantedSet.has(scopeKey)
|
|
);
|
|
});
|
|
|
|
const unknownScopes = grantedScopes.filter(
|
|
(scope) => !knownScopeSet.has(scope.toLowerCase())
|
|
);
|
|
for (const scope of unknownScopes) {
|
|
scopeRows.push(
|
|
buildPrivilegeRow(
|
|
`twitch-scope-${slugify(scope)}`,
|
|
scope,
|
|
"Granted by the token but not recognized by this version of the bot.",
|
|
true
|
|
)
|
|
);
|
|
}
|
|
|
|
return {
|
|
rows: [...rows, ...scopeRows],
|
|
channelCount: channels.length
|
|
};
|
|
}
|
|
|
|
function readJsonSafe(filePath) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseBooleanSetting(value, fallback) {
|
|
if (value === undefined || value === null || value === "") {
|
|
return fallback;
|
|
}
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
const normalized = value.toString().toLowerCase();
|
|
return ["1", "true", "yes", "on"].includes(normalized);
|
|
}
|
|
|
|
function getPluginSettingsMap(pluginId) {
|
|
const rows = db
|
|
.prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?")
|
|
.all(pluginId);
|
|
return rows.reduce((acc, row) => {
|
|
acc[row.key] = row.value;
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
function normalizeCommandTrigger(value, fallback = "") {
|
|
const raw = (value || fallback || "").toString().trim().replace(/^!+/, "");
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
}
|
|
|
|
function normalizeSubcommand(value) {
|
|
const raw = (value || "").toString().trim().replace(/^!+/, "");
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
}
|
|
|
|
function buildUsage(baseTrigger, usage) {
|
|
const raw = (usage || baseTrigger || "").toString().trim().replace(/^!+/, "");
|
|
return raw || baseTrigger;
|
|
}
|
|
|
|
function normalizeCustomPlatforms(value, availablePlatforms) {
|
|
return normalizePlatformSelection(value, availablePlatforms);
|
|
}
|
|
|
|
function parsePlatformSelectionFromBody(body, availablePlatforms) {
|
|
const raw = Array.isArray(body.platforms)
|
|
? body.platforms.join(",")
|
|
: body.platforms || body.platform;
|
|
return normalizePlatformSelection(raw, availablePlatforms);
|
|
}
|
|
|
|
function emptyCommandPreview() {
|
|
return {
|
|
preview_text: null,
|
|
preview_status: null,
|
|
preview_error: null,
|
|
preview_generated_at: null,
|
|
preview_dynamic_segments: "[]"
|
|
};
|
|
}
|
|
|
|
function buildCommandPreviewPresentation(command) {
|
|
const text = String(command.preview_text || "");
|
|
return {
|
|
status: command.preview_status || (text ? "ready" : "unavailable"),
|
|
error: command.preview_error || "Run preview to generate an example output.",
|
|
generatedAt: command.preview_generated_at || null,
|
|
isLong: text.length > 160 || text.includes("\n"),
|
|
compactParts: previewParts(text, command.preview_dynamic_segments, 160),
|
|
fullParts: previewParts(text, command.preview_dynamic_segments)
|
|
};
|
|
}
|
|
|
|
function buildPlatformLabels(platforms) {
|
|
return platforms.map((platform) => ({
|
|
key: getPlatformBadge(platform),
|
|
label: getPlatformLabel(platform)
|
|
}));
|
|
}
|
|
|
|
const LOG_LEVELS = new Set(["debug", "info", "warn", "error"]);
|
|
const LOG_LIMITS = new Set([50, 100, 250, 500]);
|
|
const DEFAULT_LOG_RANGE_MS = 24 * 60 * 60 * 1000;
|
|
const DASHBOARD_SCOPES = {
|
|
"5m": 5 * 60 * 1000,
|
|
"24h": 24 * 60 * 60 * 1000,
|
|
"7d": 7 * 24 * 60 * 60 * 1000
|
|
};
|
|
const memorySamples = [];
|
|
|
|
function normalizeLogLevel(value) {
|
|
const normalized = (value || "").toString().trim().toLowerCase();
|
|
return LOG_LEVELS.has(normalized) ? normalized : "";
|
|
}
|
|
|
|
function parseLogLevels(value) {
|
|
if (!value || value === "all") {
|
|
return [];
|
|
}
|
|
const raw = Array.isArray(value) ? value : value.toString().split(",");
|
|
return raw.map(normalizeLogLevel).filter(Boolean);
|
|
}
|
|
|
|
function parseLogRange(value) {
|
|
if (value === undefined || value === null || value === "") {
|
|
return { rangeMs: DEFAULT_LOG_RANGE_MS, rangeValue: `${DEFAULT_LOG_RANGE_MS}` };
|
|
}
|
|
const normalized = value.toString().trim().toLowerCase();
|
|
if (normalized === "all") {
|
|
return { rangeMs: null, rangeValue: "all" };
|
|
}
|
|
const parsed = Number(normalized);
|
|
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
return { rangeMs: parsed, rangeValue: `${parsed}` };
|
|
}
|
|
return { rangeMs: DEFAULT_LOG_RANGE_MS, rangeValue: `${DEFAULT_LOG_RANGE_MS}` };
|
|
}
|
|
|
|
function parseLogLimit(value, { allowAll = false } = {}) {
|
|
if (value === undefined || value === null || value === "") {
|
|
return { limit: 50, limitValue: "50" };
|
|
}
|
|
const normalized = value.toString().trim().toLowerCase();
|
|
if (allowAll && normalized === "all") {
|
|
return { limit: null, limitValue: "all" };
|
|
}
|
|
const parsed = Number(normalized);
|
|
if (!Number.isNaN(parsed) && LOG_LIMITS.has(parsed)) {
|
|
return { limit: parsed, limitValue: `${parsed}` };
|
|
}
|
|
return { limit: 50, limitValue: "50" };
|
|
}
|
|
|
|
function parseDashboardScope(value) {
|
|
const normalized = String(value || "5m").trim().toLowerCase();
|
|
return DASHBOARD_SCOPES[normalized] ? normalized : "5m";
|
|
}
|
|
|
|
function recordMemorySample(memory = process.memoryUsage(), now = Date.now()) {
|
|
const last = memorySamples[memorySamples.length - 1];
|
|
if (!last || now - last.sampled_at >= 10 * 1000) {
|
|
memorySamples.push({
|
|
sampled_at: now,
|
|
rss: memory.rss,
|
|
heap_used: memory.heapUsed,
|
|
heap_total: memory.heapTotal
|
|
});
|
|
}
|
|
const oldest = now - DASHBOARD_SCOPES["7d"];
|
|
while (memorySamples.length && memorySamples[0].sampled_at < oldest) {
|
|
memorySamples.shift();
|
|
}
|
|
}
|
|
|
|
function scopedMemorySamples(now = Date.now()) {
|
|
return Object.fromEntries(
|
|
Object.entries(DASHBOARD_SCOPES).map(([scope, duration]) => [
|
|
scope,
|
|
memorySamples.filter((sample) => sample.sampled_at >= now - duration)
|
|
])
|
|
);
|
|
}
|
|
|
|
function countLogsByLevel(logs) {
|
|
const counts = { error: 0, warn: 0, info: 0, debug: 0 };
|
|
for (const entry of logs) {
|
|
if (counts[entry.level] !== undefined) counts[entry.level] += 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function countVisibleCommands() {
|
|
const enabledPlatforms = getEnabledPlatformIds();
|
|
const commands = [];
|
|
|
|
const addCommand = (command) => {
|
|
if (!command.trigger) {
|
|
return;
|
|
}
|
|
commands.push({
|
|
trigger: command.trigger,
|
|
subcommand: command.subcommand || ""
|
|
});
|
|
};
|
|
|
|
try {
|
|
const customCommands = db
|
|
.prepare("SELECT trigger, platform FROM custom_commands WHERE enabled = 1")
|
|
.all();
|
|
for (const row of customCommands) {
|
|
const trigger = normalizeCommandTrigger(row.trigger);
|
|
const platforms = normalizeCustomPlatforms(row.platform, enabledPlatforms);
|
|
addCommand({ trigger, platforms });
|
|
}
|
|
} catch {
|
|
return 0;
|
|
}
|
|
|
|
const topOptions = getTopCommandOptions();
|
|
if (topOptions.length) {
|
|
addCommand({ trigger: "top", platforms: enabledPlatforms });
|
|
for (const option of topOptions) {
|
|
const subcommand = normalizeSubcommand(option.id);
|
|
addCommand({ trigger: "top", subcommand, platforms: enabledPlatforms });
|
|
}
|
|
}
|
|
|
|
const plugins = getPlugins().filter((plugin) => plugin.enabled);
|
|
for (const plugin of plugins) {
|
|
const cmdsPath = path.join(plugin.path, "cmds.json");
|
|
if (!fs.existsSync(cmdsPath)) {
|
|
continue;
|
|
}
|
|
const manifest = readJsonSafe(cmdsPath);
|
|
if (!manifest || !Array.isArray(manifest.commands)) {
|
|
continue;
|
|
}
|
|
const pluginSettings = getPluginSettingsMap(plugin.id);
|
|
const platformKeys = manifest.platformKeys || {};
|
|
const platformFlags = {};
|
|
for (const platform of enabledPlatforms) {
|
|
platformFlags[platform] = platformKeys[platform]
|
|
? parseBooleanSetting(pluginSettings[platformKeys[platform]], true)
|
|
: true;
|
|
}
|
|
|
|
for (const command of manifest.commands) {
|
|
if (!command || !command.trigger) {
|
|
continue;
|
|
}
|
|
const enabled = command.enabledKey
|
|
? parseBooleanSetting(pluginSettings[command.enabledKey], true)
|
|
: true;
|
|
if (!enabled) {
|
|
continue;
|
|
}
|
|
const override = command.triggerKey ? pluginSettings[command.triggerKey] : "";
|
|
const trigger = normalizeCommandTrigger(override, command.trigger);
|
|
const subcommand = normalizeSubcommand(command.subcommand);
|
|
const platforms = (Array.isArray(command.platforms) && command.platforms.length
|
|
? command.platforms
|
|
: enabledPlatforms
|
|
).filter((platform) => platformFlags[platform] && enabledPlatforms.includes(platform));
|
|
if (!platforms.length) {
|
|
continue;
|
|
}
|
|
addCommand({ trigger, subcommand, platforms });
|
|
}
|
|
}
|
|
|
|
const roots = new Set();
|
|
const subcommandTriggers = new Set();
|
|
let total = 0;
|
|
for (const command of commands) {
|
|
if (command.subcommand) {
|
|
subcommandTriggers.add(command.trigger);
|
|
total += 1;
|
|
} else {
|
|
roots.add(command.trigger);
|
|
total += 1;
|
|
}
|
|
}
|
|
for (const trigger of subcommandTriggers) {
|
|
if (!roots.has(trigger)) {
|
|
total += 1;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function normalizePageFormat(value) {
|
|
const normalized = (value || "").toString().trim().toLowerCase();
|
|
return normalized === "markdown" ? "markdown" : "html";
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
const map = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'"
|
|
};
|
|
return (value || "").toString().replace(/[&<>"']/g, (char) => map[char]);
|
|
}
|
|
|
|
function renderMarkdownInline(value) {
|
|
let output = escapeHtml(value);
|
|
output = output.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
|
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
});
|
|
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
return output;
|
|
}
|
|
|
|
function renderMarkdown(value) {
|
|
const lines = (value || "").toString().replace(/\r\n?/g, "\n").split("\n");
|
|
let html = "";
|
|
let paragraph = [];
|
|
let listType = null;
|
|
let inCode = false;
|
|
let codeLang = "";
|
|
let codeLines = [];
|
|
|
|
const flushParagraph = () => {
|
|
if (!paragraph.length) {
|
|
return;
|
|
}
|
|
html += `<p>${renderMarkdownInline(paragraph.join(" "))}</p>`;
|
|
paragraph = [];
|
|
};
|
|
|
|
const closeList = () => {
|
|
if (!listType) {
|
|
return;
|
|
}
|
|
html += `</${listType}>`;
|
|
listType = null;
|
|
};
|
|
|
|
lines.forEach((line) => {
|
|
const trimmed = line.trim();
|
|
if (inCode) {
|
|
if (trimmed.startsWith("```")) {
|
|
const codeBlock = escapeHtml(codeLines.join("\n"));
|
|
const langClass = codeLang ? ` class="language-${codeLang}"` : "";
|
|
html += `<pre><code${langClass}>${codeBlock}</code></pre>`;
|
|
inCode = false;
|
|
codeLang = "";
|
|
codeLines = [];
|
|
return;
|
|
}
|
|
codeLines.push(line);
|
|
return;
|
|
}
|
|
|
|
if (trimmed.startsWith("```")) {
|
|
flushParagraph();
|
|
closeList();
|
|
inCode = true;
|
|
codeLang = trimmed.slice(3).trim();
|
|
return;
|
|
}
|
|
|
|
if (!trimmed) {
|
|
flushParagraph();
|
|
closeList();
|
|
return;
|
|
}
|
|
|
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
|
if (headingMatch) {
|
|
flushParagraph();
|
|
closeList();
|
|
const level = headingMatch[1].length;
|
|
html += `<h${level}>${renderMarkdownInline(headingMatch[2])}</h${level}>`;
|
|
return;
|
|
}
|
|
|
|
const listMatch = trimmed.match(/^([*-]|\d+\.)\s+(.*)$/);
|
|
if (listMatch) {
|
|
flushParagraph();
|
|
const isOrdered = listMatch[1].endsWith(".");
|
|
const nextListType = isOrdered ? "ol" : "ul";
|
|
if (listType && listType !== nextListType) {
|
|
closeList();
|
|
}
|
|
if (!listType) {
|
|
listType = nextListType;
|
|
html += `<${listType}>`;
|
|
}
|
|
html += `<li>${renderMarkdownInline(listMatch[2])}</li>`;
|
|
return;
|
|
}
|
|
|
|
paragraph.push(trimmed);
|
|
});
|
|
|
|
if (inCode) {
|
|
const codeBlock = escapeHtml(codeLines.join("\n"));
|
|
const langClass = codeLang ? ` class="language-${codeLang}"` : "";
|
|
html += `<pre><code${langClass}>${codeBlock}</code></pre>`;
|
|
}
|
|
|
|
flushParagraph();
|
|
closeList();
|
|
return html || "<p></p>";
|
|
}
|
|
|
|
function buildCustomPageSrcdoc(page, theme) {
|
|
const content = (page?.content || "").toString();
|
|
const css = (page?.content_css || "").toString();
|
|
const themeCss = theme
|
|
? [
|
|
":root {",
|
|
` --ink: ${theme.light.text};`,
|
|
` --ink-soft: ${theme.light.muted};`,
|
|
` --sea: ${theme.light.accent};`,
|
|
` --sun: ${theme.light.accentAlt};`,
|
|
` --rose: ${theme.light.danger};`,
|
|
` --card: ${theme.light.surface};`,
|
|
` --surface-2: ${theme.light.surface2};`,
|
|
` --surface-3: ${theme.light.surface3};`,
|
|
` --border: ${theme.light.border};`,
|
|
` --bg-1: ${theme.light.bg1};`,
|
|
` --bg-2: ${theme.light.bg2};`,
|
|
` --bg-3: ${theme.light.bg3};`,
|
|
` --success: ${theme.light.success};`,
|
|
` --warning: ${theme.light.warning};`,
|
|
` --info: ${theme.light.info};`,
|
|
` --link: ${theme.light.link};`,
|
|
` --radius: ${theme.metrics.radius}px;`,
|
|
` --spacing-scale: ${theme.metrics.spacingScale};`,
|
|
"}",
|
|
"@media (prefers-color-scheme: dark) {",
|
|
" :root {",
|
|
` --ink: ${theme.dark.text};`,
|
|
` --ink-soft: ${theme.dark.muted};`,
|
|
` --sea: ${theme.dark.accent};`,
|
|
` --sun: ${theme.dark.accentAlt};`,
|
|
` --rose: ${theme.dark.danger};`,
|
|
` --card: ${theme.dark.surface};`,
|
|
` --surface-2: ${theme.dark.surface2};`,
|
|
` --surface-3: ${theme.dark.surface3};`,
|
|
` --border: ${theme.dark.border};`,
|
|
` --bg-1: ${theme.dark.bg1};`,
|
|
` --bg-2: ${theme.dark.bg2};`,
|
|
` --bg-3: ${theme.dark.bg3};`,
|
|
` --success: ${theme.dark.success};`,
|
|
` --warning: ${theme.dark.warning};`,
|
|
` --info: ${theme.dark.info};`,
|
|
` --link: ${theme.dark.link};`,
|
|
" }",
|
|
"}"
|
|
].join("\n")
|
|
: "";
|
|
const baseCss = [
|
|
"* { box-sizing: border-box; }",
|
|
"html, body { margin: 0; padding: 0; }",
|
|
"body {",
|
|
" font-family: \"Source Sans 3\", sans-serif;",
|
|
" color: var(--ink, #121518);",
|
|
" background: transparent;",
|
|
"}"
|
|
].join("\n");
|
|
const fullCss = [themeCss, baseCss, css].filter(Boolean).join("\n\n");
|
|
|
|
return [
|
|
"<!doctype html>",
|
|
"<html lang=\"en\">",
|
|
"<head>",
|
|
" <meta charset=\"utf-8\" />",
|
|
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />",
|
|
" <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=Source+Sans+3:wght@400;600&display=swap\" />",
|
|
" <style>",
|
|
fullCss,
|
|
" </style>",
|
|
"</head>",
|
|
"<body>",
|
|
content,
|
|
"</body>",
|
|
"</html>"
|
|
].join("\n");
|
|
}
|
|
|
|
function buildCommandUsageMap() {
|
|
const rows = db.prepare("SELECT command_id, count FROM command_usage").all();
|
|
const map = new Map();
|
|
for (const row of rows) {
|
|
map.set(row.command_id, row.count);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function slugify(value) {
|
|
const raw = (value || "").toString().toLowerCase();
|
|
const slug = raw.replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
return slug || "command";
|
|
}
|
|
|
|
function toTitleCase(value) {
|
|
const raw = (value || "").toString().replace(/[-_]+/g, " ").trim();
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
return raw.replace(/\b\w/g, (match) => match.toUpperCase());
|
|
}
|
|
|
|
function truncateText(value, maxLength) {
|
|
const raw = (value || "").toString();
|
|
if (raw.length <= maxLength) {
|
|
return raw;
|
|
}
|
|
return `${raw.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
}
|
|
|
|
function setFlash(req, type, message) {
|
|
req.session.flash = { type, message };
|
|
}
|
|
|
|
function screenshotExtension(mime) {
|
|
if (mime === "image/jpeg") return ".jpg";
|
|
if (mime === "image/webp") return ".webp";
|
|
return ".png";
|
|
}
|
|
|
|
function feedbackAttachmentExtension(file = {}) {
|
|
const original = path.extname(file.originalname || "").slice(0, 12).toLowerCase();
|
|
if ([".png", ".jpg", ".jpeg", ".webp", ".pdf", ".txt"].includes(original)) return original;
|
|
if (file.mimetype === "application/pdf") return ".pdf";
|
|
if (file.mimetype === "text/plain") return ".txt";
|
|
return screenshotExtension(file.mimetype);
|
|
}
|
|
|
|
function normalizeFeedbackRequestBody(body = {}) {
|
|
return {
|
|
...body,
|
|
target_metadata: parseBodyJson(body.target_metadata),
|
|
diagnostics: parseBodyJson(body.diagnostics)
|
|
};
|
|
}
|
|
|
|
function parseBodyJson(value) {
|
|
if (value && typeof value === "object") return value;
|
|
try {
|
|
const parsed = JSON.parse(String(value || "{}"));
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function resolveFeedbackScreenshotPath(relativePath, screenshotDir) {
|
|
const normalized = String(relativePath || "").replace(/\\/g, "/");
|
|
const prefix = "feedback/screenshots/";
|
|
if (!normalized.startsWith(prefix)) return null;
|
|
const fileName = path.basename(normalized.slice(prefix.length));
|
|
if (!fileName || fileName !== normalized.slice(prefix.length)) return null;
|
|
const target = path.resolve(screenshotDir, fileName);
|
|
const root = path.resolve(screenshotDir);
|
|
return target.startsWith(`${root}${path.sep}`) ? target : null;
|
|
}
|
|
|
|
function resolveFeedbackAttachmentPath(relativePath, attachmentDir) {
|
|
const normalized = String(relativePath || "").replace(/\\/g, "/");
|
|
const prefix = "feedback/attachments/";
|
|
if (!normalized.startsWith(prefix)) return null;
|
|
const fileName = path.basename(normalized.slice(prefix.length));
|
|
if (!fileName || fileName !== normalized.slice(prefix.length)) return null;
|
|
const target = path.resolve(attachmentDir, fileName);
|
|
const root = path.resolve(attachmentDir);
|
|
return target.startsWith(`${root}${path.sep}`) ? target : null;
|
|
}
|
|
|
|
function getThemeSettings() {
|
|
return getActiveTheme();
|
|
}
|
|
|
|
function parseJsonSetting(key, fallback) {
|
|
const value = getSetting(key, fallback);
|
|
if (Array.isArray(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return Array.isArray(parsed) ? parsed : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const DISCORD_WIDGET_CACHE_MS = 5 * 60 * 1000;
|
|
const discordWidgetCache = new Map();
|
|
|
|
function safeExternalUrl(value) {
|
|
try {
|
|
const url = new URL(String(value || ""));
|
|
return ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function safeHomepageLinkUrl(value) {
|
|
const raw = String(value || "").trim();
|
|
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
|
return raw;
|
|
}
|
|
return safeExternalUrl(raw);
|
|
}
|
|
|
|
function isLocalHostname(hostname) {
|
|
return ["localhost", "127.0.0.1", "::1"].includes(String(hostname || "").toLowerCase());
|
|
}
|
|
|
|
function isDevRequest(req = null) {
|
|
return process.env.NODE_ENV !== "production" || isLocalHostname(homepageEmbedParent(req));
|
|
}
|
|
|
|
function safeEmbedUrl(value, options = {}) {
|
|
try {
|
|
const url = new URL(String(value || "").trim());
|
|
if (url.protocol === "https:") return url.toString();
|
|
if (url.protocol === "http:" && options.allowInsecure) return url.toString();
|
|
return "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function parseDomainList(value) {
|
|
return String(value || "")
|
|
.split(",")
|
|
.map((item) => item.trim().toLowerCase().replace(/^\*\./, ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function embedDomainAllowed(embedUrl, item) {
|
|
const domains = parseDomainList(item.allowed_domains);
|
|
if (!domains.length) return true;
|
|
try {
|
|
const host = new URL(embedUrl).hostname.toLowerCase();
|
|
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isDiscordUrl(value) {
|
|
try {
|
|
const host = new URL(value).hostname.toLowerCase().replace(/^www\./, "");
|
|
return host === "discord.gg" || host === "discord.com" || host.endsWith(".discord.com");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function permissionAllows(user, permission = "public") {
|
|
const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public";
|
|
return role === "public" ? true : hasAccess(user, role);
|
|
}
|
|
|
|
function fallbackIconForUrl(url) {
|
|
try {
|
|
if (String(url || "").startsWith("/")) return "L";
|
|
const host = new URL(url).hostname.replace(/^www\./, "");
|
|
return host.slice(0, 1).toUpperCase();
|
|
} catch {
|
|
return "↗";
|
|
}
|
|
}
|
|
|
|
function homepageLinksForUser(user) {
|
|
return parseJsonSetting("homepage_link_buttons", [])
|
|
.filter((item) => item && item.enabled !== false)
|
|
.filter((item) => permissionAllows(user, item.permission))
|
|
.map((item, index) => {
|
|
const url = safeHomepageLinkUrl(item.url);
|
|
if (!url) return null;
|
|
const iconMode = String(item.icon_mode || "").trim();
|
|
const iconUrl =
|
|
iconMode === "manual" ? safeExternalUrl(item.icon_url) :
|
|
iconMode === "favicon" ? safeExternalUrl(item.fetched_favicon_url) :
|
|
iconMode === "letter" ? "" :
|
|
safeExternalUrl(item.icon_url || item.fetched_favicon_url);
|
|
return {
|
|
id: String(item.id || `link-${index}`),
|
|
label: String(item.label || item.description || "External link").slice(0, 80),
|
|
description: String(item.description || item.label || "Open link").slice(0, 160),
|
|
url,
|
|
icon_url: iconUrl,
|
|
fallback_icon: fallbackIconForUrl(url),
|
|
permission: item.permission || "public",
|
|
sort_order: Number(item.sort_order) || index
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => a.sort_order - b.sort_order);
|
|
}
|
|
|
|
async function homepageHeroForUser(user, req = null) {
|
|
const entries = parseJsonSetting("homepage_hero_entries", [])
|
|
.filter((item) => item && item.enabled !== false)
|
|
.filter((item) => permissionAllows(user, item.permission))
|
|
.sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0));
|
|
for (const item of entries) {
|
|
let hero = normalizeHomepageHero(item, {
|
|
parentHost: homepageEmbedParent(req),
|
|
allowInsecureEmbeds: isDevRequest(req)
|
|
});
|
|
if (hero?.available && hero.type === "discord_server_overview") {
|
|
hero = await enrichDiscordHero(hero, item);
|
|
}
|
|
if (hero?.available) return hero;
|
|
if (hero?.render_error && hasAccess(user, "admin")) return hero;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeHomepageHero(item, options = {}) {
|
|
const type = String(item.type || "none");
|
|
if (type === "none") {
|
|
return item.fallback_behavior === "message"
|
|
? { type, available: true, title: item.title || "No featured content", description: item.description || "" }
|
|
: null;
|
|
}
|
|
const sourceUrl = safeExternalUrl(item.source_url);
|
|
const embedUrl = safeEmbedUrl(item.embed_url, { allowInsecure: options.allowInsecureEmbeds }) || deriveHomepageEmbedUrl(type, item, options);
|
|
const imageUrl = safeExternalUrl(item.image_url);
|
|
const fallback = homepageHeroFallback(item);
|
|
const title = String(item.title || "Featured content").slice(0, 120);
|
|
const description = String(item.description || "").slice(0, 500);
|
|
if (type === "static_image" && imageUrl) return { type, available: true, title, description, image_url: imageUrl, source_url: sourceUrl };
|
|
if (type === "custom_link" && sourceUrl) return { type, available: true, title, description, source_url: sourceUrl };
|
|
if (type === "custom_embed" && embedUrl && !isDiscordUrl(embedUrl) && embedDomainAllowed(embedUrl, item)) {
|
|
return {
|
|
type,
|
|
available: true,
|
|
title,
|
|
description,
|
|
embed_url: embedUrl,
|
|
source_url: sourceUrl,
|
|
iframe_sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups",
|
|
iframe_allow: customEmbedAllow(item),
|
|
aspect_ratio: safeAspectRatio(item.aspect_ratio),
|
|
embed_height: safeEmbedHeight(item.embed_height),
|
|
...fallback
|
|
};
|
|
}
|
|
if (type === "youtube_video") {
|
|
const videoId = item.video_id || youtubeVideoId(item.source_url) || youtubeVideoId(sourceUrl);
|
|
if (videoId) return { type, available: true, title, description, embed_url: youtubeVideoEmbedUrl(videoId, item, sourceUrl), source_url: sourceUrl, iframe_allow: "autoplay; encrypted-media; picture-in-picture; web-share" };
|
|
}
|
|
if (type === "discord_server_overview") {
|
|
const guildId = item.video_id || discordServerId(item.source_url) || discordServerId(sourceUrl);
|
|
if (guildId) return { type, available: true, title, description, source_url: sourceUrl, discord_guild_id: guildId, ...fallback };
|
|
}
|
|
if (["youtube_channel", "twitch_stream"].includes(type) && (embedUrl || sourceUrl)) {
|
|
if (type === "twitch_stream" && item.availability_mode === "live_only" && item.live_now !== true) return null;
|
|
return { type, available: Boolean(embedUrl), title, description, embed_url: embedUrl, source_url: sourceUrl, render_error: embedUrl ? "" : heroRenderError(type) };
|
|
}
|
|
return {
|
|
type,
|
|
available: false,
|
|
title,
|
|
description,
|
|
source_url: sourceUrl,
|
|
...fallback,
|
|
render_error: heroRenderError(type)
|
|
};
|
|
}
|
|
|
|
function youtubeVideoId(value) {
|
|
const raw = String(value || "").trim();
|
|
if (/^[A-Za-z0-9_-]{11}$/.test(raw)) return raw;
|
|
try {
|
|
const url = new URL(raw);
|
|
if (url.hostname.includes("youtu.be")) return url.pathname.split("/").filter(Boolean)[0] || "";
|
|
const pathMatch = url.pathname.match(/\/(?:embed|shorts|live)\/([^/?#]+)/);
|
|
return pathMatch?.[1] || url.searchParams.get("v") || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function youtubeStartSeconds(value, fallbackSource = "") {
|
|
const explicit = Number(value);
|
|
if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
|
|
try {
|
|
const url = new URL(fallbackSource || "");
|
|
const raw = url.searchParams.get("start") || url.searchParams.get("t") || "";
|
|
if (/^\d+$/.test(raw)) return Number(raw);
|
|
const match = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/i);
|
|
if (!match) return 0;
|
|
return ((Number(match[1]) || 0) * 3600) + ((Number(match[2]) || 0) * 60) + (Number(match[3]) || 0);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function youtubeVideoEmbedUrl(videoId, item = {}, sourceUrl = "") {
|
|
const url = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`);
|
|
const autoplay = item.autoplay_mode && item.autoplay_mode !== "off";
|
|
if (autoplay) {
|
|
url.searchParams.set("autoplay", "1");
|
|
if (item.autoplay_mode !== "sound") url.searchParams.set("mute", "1");
|
|
}
|
|
if (item.show_controls === false) url.searchParams.set("controls", "0");
|
|
if (item.loop === true) {
|
|
url.searchParams.set("loop", "1");
|
|
url.searchParams.set("playlist", videoId);
|
|
}
|
|
const start = youtubeStartSeconds(item.start_seconds, sourceUrl || item.source_url);
|
|
if (start > 0) url.searchParams.set("start", String(start));
|
|
return url.toString();
|
|
}
|
|
|
|
function youtubeChannelId(value) {
|
|
try {
|
|
const url = new URL(value || "");
|
|
const match = url.pathname.match(/\/(?:channel|c|user)\/([^/?#]+)/);
|
|
return match?.[1] || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function twitchChannelName(value) {
|
|
try {
|
|
const url = new URL(value || "");
|
|
const host = url.hostname.replace(/^www\./, "");
|
|
if (!host.endsWith("twitch.tv")) return "";
|
|
const first = url.pathname.split("/").filter(Boolean)[0] || "";
|
|
return ["videos", "directory", "p"].includes(first) ? "" : first;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function discordServerId(value) {
|
|
const raw = String(value || "").trim();
|
|
if (/^\d{10,30}$/.test(raw)) return raw;
|
|
try {
|
|
const url = new URL(raw);
|
|
return url.searchParams.get("id") || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function deriveHomepageEmbedUrl(type, item, options = {}) {
|
|
const sourceUrl = safeExternalUrl(item.source_url);
|
|
const parentHost = options.parentHost || "localhost";
|
|
const allowInsecure = Boolean(options.allowInsecureEmbeds);
|
|
if (type === "custom_embed") {
|
|
const manual = safeEmbedUrl(item.embed_url, { allowInsecure });
|
|
if (manual && !isDiscordUrl(manual)) return manual;
|
|
const direct = safeEmbedUrl(item.source_url, { allowInsecure });
|
|
if (!direct || isDiscordUrl(direct)) return "";
|
|
const youtube = youtubeVideoId(sourceUrl);
|
|
if (youtube) return `https://www.youtube-nocookie.com/embed/${youtube}`;
|
|
const twitch = twitchChannelName(sourceUrl);
|
|
if (twitch) return twitchEmbedUrl(twitch, parentHost);
|
|
return direct;
|
|
}
|
|
if (type === "youtube_video") {
|
|
const video = item.video_id || youtubeVideoId(item.source_url) || youtubeVideoId(sourceUrl);
|
|
return video ? youtubeVideoEmbedUrl(video, item, sourceUrl) : "";
|
|
}
|
|
if (type === "youtube_channel") {
|
|
const channel = item.video_id || youtubeChannelId(sourceUrl);
|
|
return channel ? `https://www.youtube.com/embed/live_stream?channel=${encodeURIComponent(channel)}` : "";
|
|
}
|
|
if (type === "twitch_stream") {
|
|
const channel = item.video_id || twitchChannelName(sourceUrl);
|
|
return channel ? twitchEmbedUrl(channel, parentHost) : "";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function homepageHeroFallback(item) {
|
|
return {
|
|
fallback_image_url: safeExternalUrl(item.fallback_image_url),
|
|
fallback_url: safeExternalUrl(item.fallback_url) || safeExternalUrl(item.source_url),
|
|
fallback_text: String(item.fallback_text || "").slice(0, 500),
|
|
fallback_behavior: ["hide", "message", "link"].includes(item.fallback_behavior) ? item.fallback_behavior : "message"
|
|
};
|
|
}
|
|
|
|
function safeAspectRatio(value) {
|
|
const raw = String(value || "").trim();
|
|
return /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(raw) ? raw.replace(/\s+/g, "") : "";
|
|
}
|
|
|
|
function safeEmbedHeight(value) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number) || number <= 0) return 0;
|
|
return Math.min(1200, Math.max(180, Math.floor(number)));
|
|
}
|
|
|
|
function customEmbedAllow(item) {
|
|
const base = new Set(["fullscreen", "autoplay"]);
|
|
String(item.iframe_allow || "")
|
|
.split(";")
|
|
.map((part) => part.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
.forEach((part) => {
|
|
if (["fullscreen", "autoplay", "encrypted-media", "picture-in-picture"].includes(part)) {
|
|
base.add(part);
|
|
}
|
|
if (item.allow_unsafe_permissions === true && ["camera", "microphone", "clipboard-write"].includes(part)) {
|
|
base.add(part);
|
|
}
|
|
});
|
|
return Array.from(base).join("; ");
|
|
}
|
|
|
|
async function enrichDiscordHero(hero, item) {
|
|
try {
|
|
const widget = await fetchDiscordWidget(hero.discord_guild_id);
|
|
return {
|
|
...hero,
|
|
discord_widget: {
|
|
name: String(widget.name || hero.title || "Discord server").slice(0, 120),
|
|
invite_url: safeExternalUrl(widget.instant_invite) || hero.fallback_url,
|
|
presence_count: Number(widget.presence_count) || 0,
|
|
channels: Array.isArray(widget.channels)
|
|
? widget.channels.slice(0, 8).map((channel) => String(channel.name || "").slice(0, 80)).filter(Boolean)
|
|
: [],
|
|
members: Array.isArray(widget.members)
|
|
? widget.members.slice(0, 8).map((member) => ({
|
|
username: String(member.username || "").slice(0, 80),
|
|
status: String(member.status || "").slice(0, 30),
|
|
avatar_url: safeExternalUrl(member.avatar_url)
|
|
})).filter((member) => member.username)
|
|
: []
|
|
}
|
|
};
|
|
} catch (error) {
|
|
if (hero.fallback_behavior === "hide") return { ...hero, available: false };
|
|
return {
|
|
...hero,
|
|
available: Boolean(hero.fallback_url || hero.fallback_text || hero.fallback_image_url),
|
|
render_error: error?.message || "Discord Server Widget is unavailable. Enable the server widget in Discord or add a fallback link."
|
|
};
|
|
}
|
|
}
|
|
|
|
async function fetchDiscordWidget(guildId) {
|
|
const id = String(guildId || "").trim();
|
|
if (!/^\d{10,30}$/.test(id)) {
|
|
throw new Error("Add a valid Discord server ID.");
|
|
}
|
|
const now = Date.now();
|
|
const cached = discordWidgetCache.get(id);
|
|
if (cached && cached.expires > now) {
|
|
if (cached.error) throw new Error(cached.error);
|
|
return cached.data;
|
|
}
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
try {
|
|
const response = await fetch(`https://discord.com/api/guilds/${encodeURIComponent(id)}/widget.json`, {
|
|
headers: { Accept: "application/json" },
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) {
|
|
const message = response.status === 403 || response.status === 404
|
|
? "Discord Server Widget is disabled or unavailable for this server."
|
|
: `Discord widget request failed with HTTP ${response.status}.`;
|
|
discordWidgetCache.set(id, { expires: now + DISCORD_WIDGET_CACHE_MS, error: message });
|
|
throw new Error(message);
|
|
}
|
|
const data = await response.json();
|
|
discordWidgetCache.set(id, { expires: now + DISCORD_WIDGET_CACHE_MS, data });
|
|
return data;
|
|
} catch (error) {
|
|
const message = error?.name === "AbortError"
|
|
? "Discord widget request timed out."
|
|
: error?.message || "Discord widget could not be loaded.";
|
|
discordWidgetCache.set(id, { expires: now + Math.min(DISCORD_WIDGET_CACHE_MS, 60 * 1000), error: message });
|
|
throw new Error(message);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function homepageEmbedParent(req) {
|
|
const host = String(req?.hostname || req?.get?.("host") || "localhost")
|
|
.split(":")[0]
|
|
.trim();
|
|
return host || "localhost";
|
|
}
|
|
|
|
function twitchEmbedUrl(channel, parentHost) {
|
|
return `https://player.twitch.tv/?channel=${encodeURIComponent(channel)}&parent=${encodeURIComponent(parentHost)}&muted=true`;
|
|
}
|
|
|
|
function heroRenderError(type) {
|
|
const labels = {
|
|
static_image: "Add an image URL before this image hero can be displayed.",
|
|
custom_link: "Add a public link before this link hero can be displayed.",
|
|
custom_embed: "Add a supported YouTube, Twitch, Discord, or direct embed URL before this hero can be displayed.",
|
|
youtube_video: "Add a YouTube video URL or video ID before this hero can be displayed.",
|
|
youtube_channel: "Add a YouTube channel URL or channel ID before this hero can be displayed.",
|
|
twitch_stream: "Add a Twitch channel URL or channel name before this hero can be displayed.",
|
|
discord_server_overview: "Add a Discord server widget URL or server ID before this hero can be displayed."
|
|
};
|
|
return labels[type] || "This hero is missing the required URL or ID.";
|
|
}
|
|
|
|
function validateHomepageHeroEntries(entries) {
|
|
const errors = [];
|
|
entries.forEach((item, index) => {
|
|
if (!item || item.enabled === false) return;
|
|
if (item.type === "custom_embed" && !String(item.title || "").trim()) {
|
|
errors.push(`hero ${index + 1}: Add a title for this external embed.`);
|
|
return;
|
|
}
|
|
const hero = normalizeHomepageHero({ ...item, availability_mode: "always" }, { parentHost: "localhost" });
|
|
if (hero?.available) return;
|
|
if (item.type === "none" && item.fallback_behavior !== "hide") return;
|
|
const label = item.title || `hero ${index + 1}`;
|
|
errors.push(`${label}: ${hero?.render_error || heroRenderError(item.type || "none")}`);
|
|
});
|
|
return errors;
|
|
}
|
|
|
|
function getDiscordSettings() {
|
|
return {
|
|
discord_client_id: getSetting("discord_client_id", ""),
|
|
discord_client_secret: getSetting("discord_client_secret", ""),
|
|
discord_redirect_uri: getSetting("discord_redirect_uri", ""),
|
|
discord_bot_token: getSetting("discord_bot_token", ""),
|
|
discord_guild_id: getSetting("discord_guild_id", ""),
|
|
discord_admin_role_id: getSetting("discord_admin_role_id", ""),
|
|
discord_mod_role_id: getSetting("discord_mod_role_id", "")
|
|
};
|
|
}
|
|
|
|
function getTwitchSettings() {
|
|
return {
|
|
twitch_client_id: getSetting("twitch_client_id", ""),
|
|
twitch_client_secret: getSetting("twitch_client_secret", ""),
|
|
twitch_redirect_uri: getSetting("twitch_redirect_uri", ""),
|
|
twitch_bot_username: getSetting("twitch_bot_username", ""),
|
|
twitch_bot_oauth: getSetting("twitch_bot_oauth", ""),
|
|
twitch_channels: getSetting("twitch_channels", "")
|
|
};
|
|
}
|
|
|
|
function getYouTubeSettings() {
|
|
return {
|
|
youtube_client_id: getSetting("youtube_client_id", ""),
|
|
youtube_client_secret: getSetting("youtube_client_secret", ""),
|
|
youtube_redirect_uri: getSetting("youtube_redirect_uri", ""),
|
|
youtube_bot_channel_id: getSetting("youtube_bot_channel_id", "")
|
|
};
|
|
}
|
|
|
|
function saveSettingsMap(settings) {
|
|
for (const [key, value] of Object.entries(settings)) {
|
|
setSetting(key, value ?? "");
|
|
}
|
|
}
|
|
|
|
function storeSnapshot(req, key, settings) {
|
|
req.session[key] = settings;
|
|
}
|
|
|
|
function restoreSnapshot(req, key) {
|
|
const snapshot = req.session[key];
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
saveSettingsMap(snapshot);
|
|
delete req.session[key];
|
|
}
|
|
|
|
function mergeSecrets(existing, incoming, secretKeys) {
|
|
const merged = { ...existing };
|
|
for (const [key, value] of Object.entries(incoming)) {
|
|
const trimmed = typeof value === "string" ? value.trim() : value;
|
|
if (secretKeys.has(key) && !trimmed) {
|
|
continue;
|
|
}
|
|
merged[key] = trimmed;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
async function verifyDiscordSettings(settings) {
|
|
const errors = [];
|
|
const checks = [];
|
|
const required = [
|
|
"discord_client_id",
|
|
"discord_client_secret",
|
|
"discord_bot_token",
|
|
"discord_guild_id"
|
|
];
|
|
const missing = required.filter((field) => !settings[field]);
|
|
if (missing.length) {
|
|
errors.push("Client ID, Client Secret, Bot Token, and Guild ID are required.");
|
|
return { ok: false, errors, checks };
|
|
}
|
|
|
|
try {
|
|
const botResponse = await fetch("https://discord.com/api/users/@me", {
|
|
headers: { Authorization: `Bot ${settings.discord_bot_token}` }
|
|
});
|
|
if (!botResponse.ok) {
|
|
errors.push("Bot token is invalid or missing permissions.");
|
|
} else {
|
|
const botUser = await botResponse.json();
|
|
checks.push(`Bot token verified (${botUser.username}).`);
|
|
}
|
|
} catch {
|
|
errors.push("Unable to reach Discord to verify bot token.");
|
|
}
|
|
|
|
try {
|
|
const guildResponse = await fetch(
|
|
`https://discord.com/api/guilds/${settings.discord_guild_id}`,
|
|
{ headers: { Authorization: `Bot ${settings.discord_bot_token}` } }
|
|
);
|
|
if (!guildResponse.ok) {
|
|
errors.push("Guild ID is invalid or the bot is not in the guild.");
|
|
} else {
|
|
const guild = await guildResponse.json();
|
|
checks.push(`Guild verified (${guild.name}).`);
|
|
}
|
|
} catch {
|
|
errors.push("Unable to verify the Discord guild.");
|
|
}
|
|
|
|
try {
|
|
const body = new URLSearchParams({
|
|
client_id: settings.discord_client_id,
|
|
client_secret: settings.discord_client_secret,
|
|
grant_type: "client_credentials",
|
|
scope: "identify"
|
|
});
|
|
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body
|
|
});
|
|
if (!tokenResponse.ok) {
|
|
errors.push("Client ID/Secret failed verification.");
|
|
} else {
|
|
checks.push("Client ID and secret verified.");
|
|
}
|
|
} catch {
|
|
errors.push("Unable to verify Discord client credentials.");
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors, checks };
|
|
}
|
|
|
|
async function verifyTwitchSettings(settings) {
|
|
const errors = [];
|
|
const checks = [];
|
|
|
|
if (!settings.twitch_client_id || !settings.twitch_client_secret) {
|
|
errors.push("Twitch Client ID and Client Secret are required.");
|
|
return { ok: false, errors, checks };
|
|
}
|
|
|
|
let appToken = null;
|
|
try {
|
|
const tokenUrl =
|
|
"https://id.twitch.tv/oauth2/token" +
|
|
`?client_id=${encodeURIComponent(settings.twitch_client_id)}` +
|
|
`&client_secret=${encodeURIComponent(settings.twitch_client_secret)}` +
|
|
"&grant_type=client_credentials";
|
|
const tokenResponse = await fetch(tokenUrl, { method: "POST" });
|
|
if (!tokenResponse.ok) {
|
|
errors.push("Client ID/Secret failed verification.");
|
|
} else {
|
|
const data = await tokenResponse.json();
|
|
appToken = data.access_token;
|
|
checks.push("Client ID and secret verified.");
|
|
}
|
|
} catch {
|
|
errors.push("Unable to verify Twitch client credentials.");
|
|
}
|
|
|
|
if (settings.twitch_bot_username || settings.twitch_bot_oauth) {
|
|
if (!settings.twitch_bot_username || !settings.twitch_bot_oauth) {
|
|
errors.push("Bot username and OAuth token must both be filled in.");
|
|
} else {
|
|
const rawToken = settings.twitch_bot_oauth.startsWith("oauth:")
|
|
? settings.twitch_bot_oauth.slice(6)
|
|
: settings.twitch_bot_oauth;
|
|
try {
|
|
const validateResponse = await fetch(
|
|
"https://id.twitch.tv/oauth2/validate",
|
|
{ headers: { Authorization: `OAuth ${rawToken}` } }
|
|
);
|
|
if (!validateResponse.ok) {
|
|
errors.push("Bot OAuth token failed validation.");
|
|
} else {
|
|
const data = await validateResponse.json();
|
|
checks.push(`Bot token verified (${data.login}).`);
|
|
if (
|
|
settings.twitch_bot_username &&
|
|
data.login &&
|
|
data.login.toLowerCase() !== settings.twitch_bot_username.toLowerCase()
|
|
) {
|
|
errors.push("Bot username does not match the OAuth token.");
|
|
}
|
|
}
|
|
} catch {
|
|
errors.push("Unable to verify Twitch bot OAuth token.");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (appToken && settings.twitch_bot_username) {
|
|
try {
|
|
const userResponse = await fetch(
|
|
`https://api.twitch.tv/helix/users?login=${encodeURIComponent(
|
|
settings.twitch_bot_username
|
|
)}`,
|
|
{
|
|
headers: {
|
|
"Client-Id": settings.twitch_client_id,
|
|
Authorization: `Bearer ${appToken}`
|
|
}
|
|
}
|
|
);
|
|
if (userResponse.ok) {
|
|
const data = await userResponse.json();
|
|
if (data.data && data.data.length) {
|
|
checks.push(`Bot account found (${data.data[0].display_name}).`);
|
|
} else {
|
|
errors.push("Bot username was not found on Twitch.");
|
|
}
|
|
}
|
|
} catch {
|
|
errors.push("Unable to verify bot username with Twitch.");
|
|
}
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors, checks };
|
|
}
|
|
|
|
async function verifyYouTubeSettings(settings) {
|
|
const errors = [];
|
|
const checks = [];
|
|
|
|
if (!settings.youtube_client_id || !settings.youtube_client_secret) {
|
|
errors.push("YouTube Client ID and Client Secret are required.");
|
|
return { ok: false, errors, checks };
|
|
}
|
|
|
|
const refreshToken =
|
|
settings.youtube_bot_refresh_token || getSetting("youtube_bot_refresh_token", "");
|
|
if (!refreshToken) {
|
|
checks.push("Bot account not connected yet.");
|
|
return { ok: errors.length === 0, errors, checks };
|
|
}
|
|
|
|
try {
|
|
const body = new URLSearchParams({
|
|
client_id: settings.youtube_client_id,
|
|
client_secret: settings.youtube_client_secret,
|
|
refresh_token: refreshToken,
|
|
grant_type: "refresh_token"
|
|
});
|
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body
|
|
});
|
|
if (!response.ok) {
|
|
errors.push("Bot refresh token failed verification.");
|
|
return { ok: false, errors, checks };
|
|
}
|
|
const token = await response.json();
|
|
checks.push("Bot refresh token verified.");
|
|
const channel = await fetchYouTubeChannel(token.access_token);
|
|
if (channel?.snippet?.title) {
|
|
checks.push(`Bot channel verified (${channel.snippet.title}).`);
|
|
} else {
|
|
checks.push("Bot channel verified.");
|
|
}
|
|
return { ok: errors.length === 0, errors, checks, channel };
|
|
} catch {
|
|
errors.push("Unable to verify YouTube bot credentials.");
|
|
return { ok: false, errors, checks };
|
|
}
|
|
}
|
|
|
|
function createWebServer({ loadPlugins, discordClient }) {
|
|
const app = express();
|
|
const webhooks = createWebhookService();
|
|
if (!global.lumiFrameworks) {
|
|
global.lumiFrameworks = {};
|
|
}
|
|
global.lumiFrameworks.webhooks = webhooks;
|
|
const assetVersion = Date.now().toString();
|
|
const sessionStore = new BetterSqlite3Store({
|
|
client: db
|
|
});
|
|
|
|
app.set("view engine", "ejs");
|
|
app.set("views", path.join(__dirname, "views"));
|
|
app.use("/webhooks", webhooks.router);
|
|
|
|
const originalAppRender = app.render.bind(app);
|
|
app.render = (view, options, callback) => {
|
|
if (typeof options === "function") {
|
|
callback = options;
|
|
options = {};
|
|
}
|
|
const safeOptions = options || {};
|
|
const respond = (err, html) => {
|
|
if (!err) {
|
|
if (typeof callback === "function") {
|
|
return callback(null, html);
|
|
}
|
|
return html;
|
|
}
|
|
const message = err?.message || "";
|
|
const detail = {
|
|
view,
|
|
message,
|
|
stack: err?.stack || ""
|
|
};
|
|
const isMissing = message.includes("Failed to lookup view");
|
|
if (!isMissing) {
|
|
log("error", "View render failed", detail);
|
|
if (typeof callback === "function") {
|
|
return callback(err);
|
|
}
|
|
throw err;
|
|
}
|
|
log("warn", "Missing view fallback", detail);
|
|
if (view === "missing-view") {
|
|
const fallback =
|
|
"<!doctype html><title>Content missing</title><h1>Content unavailable</h1><p>Some content could not be loaded.</p>";
|
|
if (typeof callback === "function") {
|
|
return callback(null, fallback);
|
|
}
|
|
return fallback;
|
|
}
|
|
return originalAppRender(
|
|
"missing-view",
|
|
{
|
|
...safeOptions,
|
|
title: "Content missing",
|
|
resource: "A page component failed to load. Please try again.",
|
|
softError: "Some content could not be loaded."
|
|
},
|
|
callback
|
|
);
|
|
};
|
|
return originalAppRender(view, safeOptions, respond);
|
|
};
|
|
|
|
app.use(
|
|
session({
|
|
secret: ensureSessionSecret(),
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
store: sessionStore
|
|
})
|
|
);
|
|
app.use(express.urlencoded({ extended: false }));
|
|
app.use(express.json({ limit: "1mb" }));
|
|
app.use(express.static(path.join(__dirname, "public")));
|
|
|
|
const uploadDir = path.join(__dirname, "..", "..", "data", "uploads");
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
const navIconDir = path.join(__dirname, "..", "..", "data", "nav-icons");
|
|
fs.mkdirSync(navIconDir, { recursive: true });
|
|
app.use("/assets/nav-icons", express.static(navIconDir));
|
|
const feedbackScreenshotDir = path.join(__dirname, "..", "..", "data", "feedback", "screenshots");
|
|
fs.mkdirSync(feedbackScreenshotDir, { recursive: true });
|
|
const feedbackAttachmentDir = path.join(__dirname, "..", "..", "data", "feedback", "attachments");
|
|
fs.mkdirSync(feedbackAttachmentDir, { recursive: true });
|
|
const upload = multer ? multer({ dest: uploadDir }) : null;
|
|
const uploadSingle = (fieldName) => {
|
|
if (!upload) {
|
|
return (req, _res, next) => {
|
|
req.uploadError = "File uploads require npm install.";
|
|
next();
|
|
};
|
|
}
|
|
return upload.single(fieldName);
|
|
};
|
|
const navIconUpload =
|
|
multer &&
|
|
multer({
|
|
storage: multer.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, navIconDir),
|
|
filename: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname || ".svg").slice(0, 8);
|
|
cb(null, `${crypto.randomUUID()}${ext}`);
|
|
}
|
|
}),
|
|
fileFilter: (_req, file, cb) => {
|
|
if (file.mimetype === "image/png" || file.mimetype === "image/svg+xml") {
|
|
return cb(null, true);
|
|
}
|
|
cb(new Error("Only SVG or PNG files are allowed."));
|
|
}
|
|
});
|
|
const navIconSingle = (req, _res, next) => {
|
|
if (!navIconUpload) {
|
|
req.uploadError = "File uploads require npm install.";
|
|
return next();
|
|
}
|
|
return navIconUpload.single("icon_file")(req, _res, next);
|
|
};
|
|
const feedbackScreenshotUpload =
|
|
multer &&
|
|
multer({
|
|
storage: multer.diskStorage({
|
|
destination: (_req, file, cb) => cb(null, file.fieldname === "attachments" ? feedbackAttachmentDir : feedbackScreenshotDir),
|
|
filename: (_req, file, cb) => {
|
|
const ext = file.fieldname === "attachments" ? feedbackAttachmentExtension(file) : screenshotExtension(file.mimetype);
|
|
cb(null, `${crypto.randomUUID()}${ext}`);
|
|
}
|
|
}),
|
|
limits: { fileSize: 8 * 1024 * 1024 },
|
|
fileFilter: (_req, file, cb) => {
|
|
if (file.fieldname === "screenshot" && ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) {
|
|
return cb(null, true);
|
|
}
|
|
if (file.fieldname === "attachments" && ["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(file.mimetype)) {
|
|
return cb(null, true);
|
|
}
|
|
cb(new Error("Only PNG, JPEG, WebP, PDF, or text feedback files are allowed."));
|
|
}
|
|
});
|
|
const feedbackUploadFields = (req, res, next) => {
|
|
if (!feedbackScreenshotUpload) {
|
|
return next();
|
|
}
|
|
return feedbackScreenshotUpload.fields([
|
|
{ name: "screenshot", maxCount: 1 },
|
|
{ name: "attachments", maxCount: 3 }
|
|
])(req, res, (error) => {
|
|
if (error) {
|
|
req.feedbackUploadError = error.message;
|
|
}
|
|
next();
|
|
});
|
|
};
|
|
|
|
const navItems = [];
|
|
const profileSections = [];
|
|
const assistantPanels = [];
|
|
const web = {
|
|
createRouter: () => express.Router(),
|
|
addRoute: (method, routePath, ...handlers) => {
|
|
const normalizedMethod = String(method || "get").toLowerCase();
|
|
if (typeof app[normalizedMethod] !== "function" || !routePath || !handlers.length) {
|
|
throw new Error("Invalid plugin WebUI route registration.");
|
|
}
|
|
app[normalizedMethod](routePath, ...handlers);
|
|
},
|
|
mount: (mountPath, router, navItem) => {
|
|
app.use(mountPath, router);
|
|
if (navItem) {
|
|
navItems.push({ ...navItem, path: mountPath });
|
|
}
|
|
},
|
|
addNavItem: (navItem) => {
|
|
navItems.push(navItem);
|
|
},
|
|
addProfileSection: (section) => {
|
|
if (!section || (!section.view && !section.content)) {
|
|
return;
|
|
}
|
|
profileSections.push(section);
|
|
},
|
|
addAssistantPanel: (panel) => {
|
|
if (!panel || !panel.id || !panel.view || !fs.existsSync(panel.view)) {
|
|
return () => {};
|
|
}
|
|
const existingIndex = assistantPanels.findIndex((item) => item.id === panel.id);
|
|
if (existingIndex >= 0) {
|
|
assistantPanels.splice(existingIndex, 1, panel);
|
|
} else {
|
|
assistantPanels.push(panel);
|
|
}
|
|
return () => {
|
|
const index = assistantPanels.indexOf(panel);
|
|
if (index >= 0) {
|
|
assistantPanels.splice(index, 1);
|
|
}
|
|
};
|
|
},
|
|
emitEvent: publishWebEvent
|
|
};
|
|
|
|
app.use(requireConfigured);
|
|
app.get("/api/events", requireAuth, subscribeWebEvents);
|
|
app.post("/api/destructive-confirmations", requireAuth, (req, res) => {
|
|
try {
|
|
res.json(issueConfirmation(req, req.body.action));
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
app.get("/api/users/search", requireAuth, (req, res) => {
|
|
res.set("Cache-Control", "no-store");
|
|
res.json({
|
|
ok: true,
|
|
users: searchKnownUsers(req.query.q, { limit: req.query.limit || 30 })
|
|
});
|
|
});
|
|
app.get("/api/feedback/similar", requireAuth, (req, res) => {
|
|
try {
|
|
res.json({
|
|
ok: true,
|
|
matches: findSimilarFeedback(req.query, {
|
|
userId: req.session.user.id,
|
|
limit: req.query.limit || 5
|
|
})
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
app.post("/api/feedback/:id/support", requireAuth, (req, res) => {
|
|
try {
|
|
const support_count = supportFeedback(req.params.id, req.session.user);
|
|
res.json({ ok: true, support_count });
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
app.post("/api/feedback", requireAuth, feedbackUploadFields, (req, res) => {
|
|
try {
|
|
if (req.feedbackUploadError) {
|
|
throw new Error(req.feedbackUploadError);
|
|
}
|
|
const body = normalizeFeedbackRequestBody(req.body);
|
|
const screenshotFile = req.files?.screenshot?.[0] || null;
|
|
const attachmentFiles = req.files?.attachments || [];
|
|
const entry = createFeedback(body, req.session.user, {
|
|
screenshot: screenshotFile
|
|
? {
|
|
path: `feedback/screenshots/${screenshotFile.filename}`,
|
|
mime: screenshotFile.mimetype,
|
|
size: screenshotFile.size
|
|
}
|
|
: null,
|
|
attachments: attachmentFiles.map((file) => ({
|
|
path: `feedback/attachments/${file.filename}`,
|
|
mime: file.mimetype,
|
|
size: file.size,
|
|
original_name: file.originalname
|
|
}))
|
|
});
|
|
res.status(201).json({
|
|
ok: true,
|
|
id: entry.id,
|
|
message: "Feedback submitted. You can review it from the Feedback page."
|
|
});
|
|
} catch (error) {
|
|
[...(req.files?.screenshot || []), ...(req.files?.attachments || [])].forEach((file) => {
|
|
if (file?.path) fs.rmSync(file.path, { force: true });
|
|
});
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
app.get("/api/feedback/notifications", requireAuth, (req, res) => {
|
|
res.set("Cache-Control", "no-store");
|
|
res.json(notificationSummary(req.session.user.id));
|
|
});
|
|
app.use((req, res, next) => {
|
|
if (req.method !== "POST" || !isDestructivePath(req.path)) return next();
|
|
const confirmation = consumeConfirmation(req, normalizeAction(req.originalUrl));
|
|
if (confirmation.valid) return next();
|
|
const message = confirmation.reason === "too_early"
|
|
? "Confirmation was submitted before the safety timer completed."
|
|
: "This action requires a new timed confirmation.";
|
|
if (req.accepts("html")) {
|
|
req.session.flash = { type: "error", message };
|
|
return res.redirect(req.get("referer") || "/");
|
|
}
|
|
return res.status(409).json({ error: message, reason: confirmation.reason });
|
|
});
|
|
app.use((req, res, next) => {
|
|
res.locals.siteTitle = getSetting("site_title", "Lumi Bot");
|
|
res.locals.assetVersion = assetVersion;
|
|
res.locals.user = req.session.user || null;
|
|
res.locals.flash = req.session.flash || null;
|
|
res.locals.softError = null;
|
|
res.locals.theme = getThemeSettings();
|
|
res.locals.botAvatar = getSetting("bot_avatar_url", null);
|
|
const platformStatus = getPlatformStatus();
|
|
res.locals.platforms = platformStatus;
|
|
const localhostLogin = getLocalhostLoginPlatform(req);
|
|
res.locals.localhostLoginAvailable = Boolean(localhostLogin);
|
|
res.locals.platformLogins = [
|
|
...(localhostLogin ? [localhostLogin] : []),
|
|
...platformStatus.filter(
|
|
(platform) => platform.supported && platform.enabled && platform.supportsLogin
|
|
)
|
|
];
|
|
res.locals.platformLinks = platformStatus.filter(
|
|
(platform) => platform.supported && platform.enabled && platform.supportsLink
|
|
);
|
|
const twitchPlatform = platformStatus.find((platform) => platform.id === "twitch");
|
|
res.locals.twitchConfigured = Boolean(twitchPlatform?.configured);
|
|
res.locals.currentPath = req.path;
|
|
res.locals.userAvatar = req.session.user
|
|
? getPreferredAvatar(req.session.user.id)
|
|
: null;
|
|
res.locals.userInitial = req.session.user?.username
|
|
? req.session.user.username.charAt(0).toUpperCase()
|
|
: "";
|
|
res.locals.feedbackNotifications = notificationSummary(req.session.user?.id);
|
|
res.locals.feedbackOptions = feedbackOptions();
|
|
req.session.flash = null;
|
|
trackModRole(db, req.session.user);
|
|
res.locals.navSections = buildNavSections(
|
|
req.session.user,
|
|
navItems,
|
|
req.path
|
|
);
|
|
next();
|
|
});
|
|
|
|
app.use((req, res, next) => {
|
|
const originalRender = res.render.bind(res);
|
|
res.render = (view, options, callback) => {
|
|
if (typeof options === "function") {
|
|
callback = options;
|
|
options = {};
|
|
}
|
|
const handleError = (err) => {
|
|
if (!err) {
|
|
return;
|
|
}
|
|
const message = err?.message || "";
|
|
const isMissing = message.includes("Failed to lookup view");
|
|
const context = {
|
|
view,
|
|
method: req.method,
|
|
path: req.path,
|
|
userId: req.session.user?.id || null,
|
|
message,
|
|
stack: err?.stack || ""
|
|
};
|
|
if (!isMissing) {
|
|
log("error", "Render failed", context);
|
|
return originalRender(
|
|
"error",
|
|
{
|
|
title: "Something went wrong",
|
|
message: "An unexpected error occurred. Please try again."
|
|
},
|
|
callback
|
|
);
|
|
}
|
|
log("warn", "Missing view fallback", context);
|
|
res.locals.softError = "Some content could not be loaded.";
|
|
if (view === "missing-view") {
|
|
return res
|
|
.status(200)
|
|
.send(
|
|
"<!doctype html><title>Content missing</title><h1>Content unavailable</h1><p>Some content could not be loaded.</p>"
|
|
);
|
|
}
|
|
return originalRender(
|
|
"missing-view",
|
|
{
|
|
title: "Content missing",
|
|
resource: "A page component failed to load. Please try again."
|
|
},
|
|
callback
|
|
);
|
|
};
|
|
|
|
return originalRender(view, options, (err, html) => {
|
|
if (err) {
|
|
return handleError(err);
|
|
}
|
|
if (typeof callback === "function") {
|
|
return callback(null, html);
|
|
}
|
|
res.send(html);
|
|
});
|
|
};
|
|
next();
|
|
});
|
|
|
|
app.get("/", async (req, res, next) => {
|
|
try {
|
|
res.render("home", {
|
|
title: "Home",
|
|
homepageLinks: homepageLinksForUser(req.session.user),
|
|
homepageHero: await homepageHeroForUser(req.session.user, req)
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get("/api/assistant-panels", async (req, res) => {
|
|
res.set("Cache-Control", "no-store");
|
|
if (!req.session.user) {
|
|
return res.json({ panels: [] });
|
|
}
|
|
|
|
const panels = [];
|
|
for (const panel of assistantPanels) {
|
|
let panelDebug = panel.debug || null;
|
|
try {
|
|
if (typeof panel.getDebug === "function") {
|
|
panelDebug = await panel.getDebug(req.session.user);
|
|
}
|
|
} catch {}
|
|
let panelAccess = null;
|
|
try {
|
|
panelAccess = typeof panel.canAccess === "function"
|
|
? await panel.canAccess(req.session.user, {
|
|
origin: "webui",
|
|
platform: "webui",
|
|
requested_surface: "webui_panel"
|
|
})
|
|
: { allowed: hasAccess(req.session.user, panel.role || "public"), reason: "role_forbidden" };
|
|
} catch (error) {
|
|
panelAccess = { allowed: false, reason: "access_check_failed", debug_details: { error: error.message } };
|
|
}
|
|
if (panelAccess === false || panelAccess?.allowed === false) {
|
|
panels.push({
|
|
...unavailableAssistantPanel(panel, panelAccess?.reason || "role_forbidden"),
|
|
debug: { ...(panelDebug || {}), permission: panelAccess?.debug_details || null }
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let availability;
|
|
try {
|
|
availability = typeof panel.getAvailability === "function"
|
|
? await panel.getAvailability(req.session.user)
|
|
: {
|
|
available: typeof panel.isVisible === "function"
|
|
? Boolean(await panel.isVisible(req.session.user))
|
|
: true
|
|
};
|
|
} catch (error) {
|
|
console.error(`Assistant panel ${panel.id} availability check failed`, error);
|
|
panels.push({ ...unavailableAssistantPanel(panel, "availability_check_failed"), debug: panelDebug });
|
|
continue;
|
|
}
|
|
|
|
if (!availability?.available) {
|
|
panels.push({
|
|
...unavailableAssistantPanel(panel, availability?.reason_code || "unavailable"),
|
|
status: availability?.status || "offline",
|
|
debug: {
|
|
...(panelDebug || {}),
|
|
permission: availability?.permission?.debug_details || panelAccess?.debug_details || null
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const rendered = await renderAssistantPanel(app, panel, req.session.user);
|
|
panel.onRenderDiagnostic?.({
|
|
panel_endpoint_status: 200,
|
|
panel_html_length: rendered.html.length,
|
|
panel_html_error: null,
|
|
panel_template_path: panel.view,
|
|
missing_locals: []
|
|
});
|
|
if (typeof panel.getDebug === "function") {
|
|
panelDebug = await panel.getDebug(req.session.user);
|
|
}
|
|
panels.push({
|
|
available: true,
|
|
panel_id: panel.id,
|
|
status: availability.status || "healthy",
|
|
reason_code: null,
|
|
version: panel.version || assetVersion,
|
|
html: rendered.html,
|
|
stylesheet: panel.stylesheet || null,
|
|
script: panel.script || null,
|
|
debug: {
|
|
...(panelDebug || {}),
|
|
permission: availability?.permission?.debug_details || panelAccess?.debug_details || null
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error(`Assistant panel ${panel.id} render failed`, error);
|
|
panel.onRenderDiagnostic?.({
|
|
panel_endpoint_status: 500,
|
|
panel_html_length: 0,
|
|
panel_html_error: error.message,
|
|
panel_template_path: panel.view,
|
|
missing_locals: error.missingLocals || []
|
|
});
|
|
if (typeof panel.getDebug === "function") {
|
|
try { panelDebug = await panel.getDebug(req.session.user); } catch {}
|
|
}
|
|
panels.push({
|
|
...unavailableAssistantPanel(panel, "render_failed"),
|
|
render_error: {
|
|
message: error.message || "Assistant panel rendering failed.",
|
|
missing_locals: error.missingLocals || []
|
|
},
|
|
debug: panelDebug
|
|
});
|
|
}
|
|
}
|
|
return res.json({ panels });
|
|
});
|
|
|
|
app.get("/setup", (req, res) => {
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const platforms = getPlatformStatus();
|
|
res.render("setup", {
|
|
title: "Initial setup",
|
|
platforms
|
|
});
|
|
});
|
|
|
|
app.get("/setup/discord", (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const current = getDiscordSettings();
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const form = {
|
|
...current,
|
|
discord_client_secret: "",
|
|
discord_bot_token: "",
|
|
discord_redirect_uri:
|
|
current.discord_redirect_uri || `${baseUrl}/auth/discord/callback`
|
|
};
|
|
storeSnapshot(req, "discordWizardSnapshot", current);
|
|
res.render("wizard-discord", {
|
|
title: "Discord setup",
|
|
form,
|
|
checks: [],
|
|
errors: [],
|
|
actionBase: "/setup/discord",
|
|
cancelPath: "/setup"
|
|
});
|
|
});
|
|
|
|
app.post("/setup/discord/verify", async (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const current = getDiscordSettings();
|
|
const incoming = {
|
|
discord_client_id: req.body.discord_client_id || "",
|
|
discord_client_secret: req.body.discord_client_secret || "",
|
|
discord_redirect_uri: req.body.discord_redirect_uri || "",
|
|
discord_bot_token: req.body.discord_bot_token || "",
|
|
discord_guild_id: req.body.discord_guild_id || "",
|
|
discord_admin_role_id: req.body.discord_admin_role_id || "",
|
|
discord_mod_role_id: req.body.discord_mod_role_id || ""
|
|
};
|
|
const merged = mergeSecrets(
|
|
current,
|
|
incoming,
|
|
new Set(["discord_client_secret", "discord_bot_token"])
|
|
);
|
|
if (!merged.discord_redirect_uri) {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
merged.discord_redirect_uri = `${baseUrl}/auth/discord/callback`;
|
|
}
|
|
const result = await verifyDiscordSettings(merged);
|
|
if (!result.ok) {
|
|
res.render("wizard-discord", {
|
|
title: "Discord setup",
|
|
form: {
|
|
...incoming,
|
|
discord_client_secret: "",
|
|
discord_bot_token: "",
|
|
discord_redirect_uri: merged.discord_redirect_uri
|
|
},
|
|
checks: result.checks,
|
|
errors: result.errors,
|
|
actionBase: "/setup/discord",
|
|
cancelPath: "/setup"
|
|
});
|
|
return;
|
|
}
|
|
saveSettingsMap(merged);
|
|
delete req.session.discordWizardSnapshot;
|
|
setFlash(req, "success", "Discord setup saved. Please log in.");
|
|
res.redirect("/auth/discord");
|
|
});
|
|
|
|
app.post("/setup/discord/cancel", (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
restoreSnapshot(req, "discordWizardSnapshot");
|
|
setFlash(req, "info", "Discord setup canceled.");
|
|
res.redirect("/setup");
|
|
});
|
|
|
|
app.get("/setup/twitch", (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const current = getTwitchSettings();
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const form = {
|
|
...current,
|
|
twitch_client_secret: "",
|
|
twitch_bot_oauth: "",
|
|
twitch_redirect_uri:
|
|
current.twitch_redirect_uri || `${baseUrl}/auth/twitch/callback`
|
|
};
|
|
storeSnapshot(req, "twitchWizardSnapshot", current);
|
|
res.render("wizard-twitch", {
|
|
title: "Twitch setup",
|
|
form,
|
|
checks: [],
|
|
errors: [],
|
|
actionBase: "/setup/twitch",
|
|
cancelPath: "/setup"
|
|
});
|
|
});
|
|
|
|
app.post("/setup/twitch/verify", async (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const current = getTwitchSettings();
|
|
const incoming = {
|
|
twitch_client_id: req.body.twitch_client_id || "",
|
|
twitch_client_secret: req.body.twitch_client_secret || "",
|
|
twitch_redirect_uri: req.body.twitch_redirect_uri || "",
|
|
twitch_bot_username: req.body.twitch_bot_username || "",
|
|
twitch_bot_oauth: req.body.twitch_bot_oauth || "",
|
|
twitch_channels: req.body.twitch_channels || ""
|
|
};
|
|
const merged = mergeSecrets(
|
|
current,
|
|
incoming,
|
|
new Set(["twitch_client_secret", "twitch_bot_oauth"])
|
|
);
|
|
if (!merged.twitch_redirect_uri) {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
merged.twitch_redirect_uri = `${baseUrl}/auth/twitch/callback`;
|
|
}
|
|
const result = await verifyTwitchSettings(merged);
|
|
if (!result.ok) {
|
|
res.render("wizard-twitch", {
|
|
title: "Twitch setup",
|
|
form: {
|
|
...incoming,
|
|
twitch_client_secret: "",
|
|
twitch_bot_oauth: "",
|
|
twitch_redirect_uri: merged.twitch_redirect_uri
|
|
},
|
|
checks: result.checks,
|
|
errors: result.errors,
|
|
actionBase: "/setup/twitch",
|
|
cancelPath: "/setup"
|
|
});
|
|
return;
|
|
}
|
|
saveSettingsMap(merged);
|
|
delete req.session.twitchWizardSnapshot;
|
|
setFlash(req, "success", "Twitch setup saved.");
|
|
res.redirect("/setup");
|
|
});
|
|
|
|
app.post("/setup/twitch/cancel", (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
restoreSnapshot(req, "twitchWizardSnapshot");
|
|
setFlash(req, "info", "Twitch setup canceled.");
|
|
res.redirect("/setup");
|
|
});
|
|
|
|
app.get("/setup/youtube", (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const current = getYouTubeSettings();
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const form = {
|
|
...current,
|
|
youtube_client_secret: "",
|
|
youtube_redirect_uri:
|
|
current.youtube_redirect_uri || `${baseUrl}/auth/youtube/callback`
|
|
};
|
|
const snapshot = {
|
|
...current,
|
|
youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", ""),
|
|
youtube_bot_channel_id: getSetting("youtube_bot_channel_id", "")
|
|
};
|
|
storeSnapshot(req, "youtubeWizardSnapshot", snapshot);
|
|
res.render("wizard-youtube", {
|
|
title: "YouTube setup",
|
|
form,
|
|
checks: [],
|
|
errors: [],
|
|
actionBase: "/setup/youtube",
|
|
cancelPath: "/setup",
|
|
connectPath: "/setup/youtube/connect",
|
|
botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")),
|
|
botChannelId: getSetting("youtube_bot_channel_id", ""),
|
|
botChannelName: getYouTubeClient()?.channelName || null
|
|
});
|
|
});
|
|
|
|
app.post("/setup/youtube/connect", (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const clientId = (req.body.youtube_client_id || "").trim();
|
|
const clientSecret = (req.body.youtube_client_secret || "").trim();
|
|
const redirectUri =
|
|
(req.body.youtube_redirect_uri || "").trim() ||
|
|
`${baseUrl}/auth/youtube/callback`;
|
|
if (!clientId || !clientSecret) {
|
|
setFlash(req, "error", "Client ID and Client Secret are required.");
|
|
return res.redirect("/setup/youtube");
|
|
}
|
|
setSetting("youtube_client_id", clientId);
|
|
setSetting("youtube_client_secret", clientSecret);
|
|
setSetting("youtube_redirect_uri", redirectUri);
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
req.session.youtubeBotState = state;
|
|
req.session.youtubeBotReturnTo = "/setup/youtube";
|
|
const url = buildYouTubeAuthUrl(state, redirectUri, { prompt: "consent" });
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.post("/setup/youtube/verify", async (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
const current = getYouTubeSettings();
|
|
const incoming = {
|
|
youtube_client_id: req.body.youtube_client_id || "",
|
|
youtube_client_secret: req.body.youtube_client_secret || "",
|
|
youtube_redirect_uri: req.body.youtube_redirect_uri || ""
|
|
};
|
|
const merged = mergeSecrets(
|
|
current,
|
|
incoming,
|
|
new Set(["youtube_client_secret"])
|
|
);
|
|
if (!merged.youtube_redirect_uri) {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
merged.youtube_redirect_uri = `${baseUrl}/auth/youtube/callback`;
|
|
}
|
|
const result = await verifyYouTubeSettings({
|
|
...merged,
|
|
youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", "")
|
|
});
|
|
if (!result.ok) {
|
|
res.render("wizard-youtube", {
|
|
title: "YouTube setup",
|
|
form: {
|
|
...incoming,
|
|
youtube_client_secret: "",
|
|
youtube_redirect_uri: merged.youtube_redirect_uri
|
|
},
|
|
checks: result.checks,
|
|
errors: result.errors,
|
|
actionBase: "/setup/youtube",
|
|
cancelPath: "/setup",
|
|
connectPath: "/setup/youtube/connect",
|
|
botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")),
|
|
botChannelId: getSetting("youtube_bot_channel_id", ""),
|
|
botChannelName: getYouTubeClient()?.channelName || null
|
|
});
|
|
return;
|
|
}
|
|
if (result.channel?.id) {
|
|
merged.youtube_bot_channel_id = result.channel.id;
|
|
}
|
|
saveSettingsMap(merged);
|
|
delete req.session.youtubeWizardSnapshot;
|
|
setFlash(req, "success", "YouTube setup saved.");
|
|
res.redirect("/setup");
|
|
});
|
|
|
|
app.post("/setup/youtube/cancel", (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
return res.redirect("/setup");
|
|
}
|
|
if (isConfigured()) {
|
|
return res.redirect("/");
|
|
}
|
|
restoreSnapshot(req, "youtubeWizardSnapshot");
|
|
setFlash(req, "info", "YouTube setup canceled.");
|
|
res.redirect("/setup");
|
|
});
|
|
|
|
app.get("/auth/discord", (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
setFlash(req, "error", "Discord is disabled in Platform Integration.");
|
|
return res.redirect("/setup");
|
|
}
|
|
if (!isPlatformConfigured("discord")) {
|
|
setFlash(req, "error", "Discord is not configured yet.");
|
|
return res.redirect("/setup");
|
|
}
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
if (req.session.user) {
|
|
req.session.discordLinkState = state;
|
|
} else {
|
|
req.session.discordState = state;
|
|
}
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("discord_redirect_uri") ||
|
|
`${baseUrl}/auth/discord/callback`;
|
|
const url = buildDiscordAuthUrl(state, redirectUri);
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.get("/auth/discord/callback", async (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
return res.status(400).render("error", {
|
|
title: "Login failed",
|
|
message: "Discord is disabled in Platform Integration."
|
|
});
|
|
}
|
|
const { code, state } = req.query;
|
|
const isLogin = state && state === req.session.discordState;
|
|
const isLink = state && state === req.session.discordLinkState && req.session.user;
|
|
if (!code || (!isLogin && !isLink)) {
|
|
return res.status(400).render("error", {
|
|
title: "Login failed",
|
|
message: "Invalid OAuth state."
|
|
});
|
|
}
|
|
try {
|
|
if (isLogin) {
|
|
delete req.session.discordState;
|
|
}
|
|
if (isLink) {
|
|
delete req.session.discordLinkState;
|
|
}
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("discord_redirect_uri") ||
|
|
`${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);
|
|
if (isLink) {
|
|
const linked = linkIdentityToUser({
|
|
userId: req.session.user.id,
|
|
provider: "discord",
|
|
providerUserId: user.id,
|
|
displayName: user.global_name || user.username,
|
|
avatar: user.avatar
|
|
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
|
|
: null
|
|
});
|
|
req.session.user = {
|
|
id: linked.id,
|
|
username: linked.internal_username,
|
|
avatar: user.avatar,
|
|
roles,
|
|
...flags
|
|
};
|
|
req.session.discordToken = token;
|
|
setFlash(req, "success", "Discord account linked.");
|
|
res.redirect("/profile");
|
|
return;
|
|
}
|
|
const profile = storeDiscordUser(user);
|
|
req.session.user = {
|
|
id: profile.id,
|
|
username: profile.internal_username,
|
|
avatar: user.avatar,
|
|
roles,
|
|
...flags
|
|
};
|
|
req.session.discordToken = token;
|
|
setFlash(req, "success", "Logged in.");
|
|
res.redirect("/");
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).render("error", {
|
|
title: "Login failed",
|
|
message: "Discord authentication failed."
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get("/auth/localhost", (req, res) => {
|
|
if (!isLocalhostLoginAvailable(req)) {
|
|
return res.status(404).render("error", {
|
|
title: "Login unavailable",
|
|
message: "Localhost Login is only available from a localhost request."
|
|
});
|
|
}
|
|
res.render("localhost-login", {
|
|
title: "Localhost Login",
|
|
username: getSetting("localhost_login_username", "admin")
|
|
});
|
|
});
|
|
|
|
app.post("/auth/localhost", (req, res) => {
|
|
if (!isLocalhostLoginAvailable(req)) {
|
|
return res.status(404).render("error", {
|
|
title: "Login unavailable",
|
|
message: "Localhost Login is only available from a localhost request."
|
|
});
|
|
}
|
|
const username = String(req.body.username || "").trim();
|
|
const password = String(req.body.password || "");
|
|
const expectedUsername = String(getSetting("localhost_login_username", "admin"));
|
|
const expectedPassword = String(getSetting("localhost_login_password", "admin"));
|
|
if (username !== expectedUsername || password !== expectedPassword) {
|
|
setFlash(req, "error", "Invalid localhost username or password.");
|
|
return res.redirect("/auth/localhost");
|
|
}
|
|
const profile = ensureUserForIdentity({
|
|
provider: "localhost",
|
|
providerUserId: expectedUsername,
|
|
displayName: expectedUsername,
|
|
fallbackName: "Localhost Admin"
|
|
});
|
|
req.session.user = {
|
|
id: profile.id,
|
|
username: profile.internal_username,
|
|
avatar: null,
|
|
roles: ["localhost-admin"],
|
|
isAdmin: true,
|
|
isMod: true,
|
|
isLocalhost: true
|
|
};
|
|
setFlash(req, "success", "Logged in with Localhost Login.");
|
|
res.redirect("/");
|
|
});
|
|
|
|
app.post("/auth/logout", (req, res) => {
|
|
req.session.destroy(() => {
|
|
res.redirect("/");
|
|
});
|
|
});
|
|
|
|
app.get("/auth/twitch", requireAuth, (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
setFlash(req, "error", "Twitch is disabled in Platform Integration.");
|
|
return res.redirect("/profile");
|
|
}
|
|
if (!isPlatformConfigured("twitch")) {
|
|
setFlash(req, "error", "Twitch is not configured yet.");
|
|
return res.redirect("/profile");
|
|
}
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
req.session.twitchState = state;
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("twitch_redirect_uri") ||
|
|
`${baseUrl}/auth/twitch/callback`;
|
|
const url = buildTwitchAuthUrl(state, redirectUri);
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.get("/auth/twitch/login", (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
setFlash(req, "error", "Twitch is disabled in Platform Integration.");
|
|
return res.redirect("/setup");
|
|
}
|
|
if (!isPlatformConfigured("twitch")) {
|
|
setFlash(req, "error", "Twitch is not configured yet.");
|
|
return res.redirect("/setup");
|
|
}
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
req.session.twitchLoginState = state;
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("twitch_redirect_uri") ||
|
|
`${baseUrl}/auth/twitch/callback`;
|
|
const url = buildTwitchAuthUrl(state, redirectUri);
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.get("/auth/twitch/callback", async (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
return res.status(400).render("error", {
|
|
title: "Auth failed",
|
|
message: "Twitch is disabled in Platform Integration."
|
|
});
|
|
}
|
|
const { code, state } = req.query;
|
|
const isLogin = state && state === req.session.twitchLoginState;
|
|
const isLink = state && state === req.session.twitchState && req.session.user;
|
|
if (!code || (!isLogin && !isLink)) {
|
|
return res.status(400).render("error", {
|
|
title: "Auth failed",
|
|
message: "Invalid Twitch OAuth state."
|
|
});
|
|
}
|
|
try {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("twitch_redirect_uri") ||
|
|
`${baseUrl}/auth/twitch/callback`;
|
|
const token = await exchangeTwitchCode(code, redirectUri);
|
|
const twitchUser = await fetchTwitchUser(token.access_token);
|
|
if (!twitchUser) {
|
|
throw new Error("Twitch user not found.");
|
|
}
|
|
const now = Date.now();
|
|
if (isLogin) {
|
|
const profile = ensureUserForIdentity({
|
|
provider: "twitch",
|
|
providerUserId: twitchUser.id,
|
|
displayName: twitchUser.display_name,
|
|
avatar: twitchUser.profile_image_url || null
|
|
});
|
|
db.prepare(
|
|
"INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " +
|
|
"VALUES (?, 'twitch', ?, ?, ?, ?, ?, ?, ?) " +
|
|
"ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at"
|
|
).run(
|
|
profile.id,
|
|
twitchUser.id,
|
|
twitchUser.display_name,
|
|
token.access_token,
|
|
token.refresh_token || "",
|
|
token.expires_in ? now + token.expires_in * 1000 : null,
|
|
now,
|
|
now
|
|
);
|
|
const discordIdentity = db
|
|
.prepare(
|
|
"SELECT provider_user_id FROM user_identities WHERE user_id = ? AND provider = 'discord'"
|
|
)
|
|
.get(profile.id);
|
|
const roles = discordIdentity?.provider_user_id
|
|
? await fetchDiscordRolesForUser(discordIdentity.provider_user_id)
|
|
: [];
|
|
const flags = getRoleFlags(roles);
|
|
req.session.user = {
|
|
id: profile.id,
|
|
username: profile.internal_username,
|
|
roles,
|
|
...flags
|
|
};
|
|
req.session.twitchToken = token;
|
|
setFlash(req, "success", "Logged in with Twitch.");
|
|
res.redirect("/");
|
|
} else {
|
|
const profile = linkIdentityToUser({
|
|
userId: req.session.user.id,
|
|
provider: "twitch",
|
|
providerUserId: twitchUser.id,
|
|
displayName: twitchUser.display_name,
|
|
avatar: twitchUser.profile_image_url || null
|
|
});
|
|
db.prepare(
|
|
"INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " +
|
|
"VALUES (?, 'twitch', ?, ?, ?, ?, ?, ?, ?) " +
|
|
"ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at"
|
|
).run(
|
|
profile.id,
|
|
twitchUser.id,
|
|
twitchUser.display_name,
|
|
token.access_token,
|
|
token.refresh_token || "",
|
|
token.expires_in ? now + token.expires_in * 1000 : null,
|
|
now,
|
|
now
|
|
);
|
|
req.session.user.id = profile.id;
|
|
req.session.user.username = profile.internal_username;
|
|
setFlash(req, "success", "Twitch account linked.");
|
|
res.redirect("/profile");
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).render("error", {
|
|
title: isLogin ? "Login failed" : "Link failed",
|
|
message: isLogin
|
|
? "Unable to sign in with Twitch."
|
|
: "Unable to link Twitch account."
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get("/auth/youtube", requireAuth, (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
setFlash(req, "error", "YouTube is disabled in Platform Integration.");
|
|
return res.redirect("/profile");
|
|
}
|
|
if (!isPlatformConfigured("youtube")) {
|
|
setFlash(req, "error", "YouTube is not configured yet.");
|
|
return res.redirect("/profile");
|
|
}
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
req.session.youtubeState = state;
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("youtube_redirect_uri") ||
|
|
`${baseUrl}/auth/youtube/callback`;
|
|
const url = buildYouTubeAuthUrl(state, redirectUri);
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.get("/auth/youtube/login", (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
setFlash(req, "error", "YouTube is disabled in Platform Integration.");
|
|
return res.redirect("/setup");
|
|
}
|
|
if (!isPlatformConfigured("youtube")) {
|
|
setFlash(req, "error", "YouTube is not configured yet.");
|
|
return res.redirect("/setup");
|
|
}
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
req.session.youtubeLoginState = state;
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("youtube_redirect_uri") ||
|
|
`${baseUrl}/auth/youtube/callback`;
|
|
const url = buildYouTubeAuthUrl(state, redirectUri);
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.get("/auth/youtube/callback", async (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
return res.status(400).render("error", {
|
|
title: "Auth failed",
|
|
message: "YouTube is disabled in Platform Integration."
|
|
});
|
|
}
|
|
const { code, state } = req.query;
|
|
const isLogin = state && state === req.session.youtubeLoginState;
|
|
const isLink = state && state === req.session.youtubeState && req.session.user;
|
|
const isBot = state && state === req.session.youtubeBotState;
|
|
if (!code || (!isLogin && !isLink && !isBot)) {
|
|
return res.status(400).render("error", {
|
|
title: "Auth failed",
|
|
message: "Invalid YouTube OAuth state."
|
|
});
|
|
}
|
|
try {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const redirectUri =
|
|
getSetting("youtube_redirect_uri") ||
|
|
`${baseUrl}/auth/youtube/callback`;
|
|
const token = await exchangeYouTubeCode(code, redirectUri);
|
|
const channel = await fetchYouTubeChannel(token.access_token);
|
|
if (!channel) {
|
|
throw new Error("YouTube channel not found.");
|
|
}
|
|
const displayName = channel.snippet?.title || "YouTube Channel";
|
|
const avatar = channel.snippet?.thumbnails?.default?.url || null;
|
|
const now = Date.now();
|
|
|
|
if (isBot) {
|
|
const refreshToken =
|
|
token.refresh_token || getSetting("youtube_bot_refresh_token", "");
|
|
if (!refreshToken) {
|
|
throw new Error("Missing YouTube refresh token.");
|
|
}
|
|
setSetting("youtube_bot_refresh_token", refreshToken);
|
|
setSetting("youtube_bot_channel_id", channel.id || "");
|
|
req.session.youtubeBotState = null;
|
|
const returnTo = req.session.youtubeBotReturnTo || "/admin/youtube-wizard";
|
|
req.session.youtubeBotReturnTo = null;
|
|
setFlash(req, "success", "YouTube bot connected.");
|
|
return res.redirect(returnTo);
|
|
}
|
|
|
|
if (isLogin) {
|
|
const profile = ensureUserForIdentity({
|
|
provider: "youtube",
|
|
providerUserId: channel.id,
|
|
displayName,
|
|
avatar
|
|
});
|
|
db.prepare(
|
|
"INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " +
|
|
"VALUES (?, 'youtube', ?, ?, ?, ?, ?, ?, ?) " +
|
|
"ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at"
|
|
).run(
|
|
profile.id,
|
|
channel.id,
|
|
displayName,
|
|
token.access_token,
|
|
token.refresh_token || "",
|
|
token.expires_in ? now + token.expires_in * 1000 : null,
|
|
now,
|
|
now
|
|
);
|
|
const discordIdentity = db
|
|
.prepare(
|
|
"SELECT provider_user_id FROM user_identities WHERE user_id = ? AND provider = 'discord'"
|
|
)
|
|
.get(profile.id);
|
|
const roles = discordIdentity?.provider_user_id
|
|
? await fetchDiscordRolesForUser(discordIdentity.provider_user_id)
|
|
: [];
|
|
const flags = getRoleFlags(roles);
|
|
req.session.user = {
|
|
id: profile.id,
|
|
username: profile.internal_username,
|
|
roles,
|
|
...flags
|
|
};
|
|
req.session.youtubeToken = token;
|
|
setFlash(req, "success", "Logged in with YouTube.");
|
|
return res.redirect("/");
|
|
}
|
|
|
|
const profile = linkIdentityToUser({
|
|
userId: req.session.user.id,
|
|
provider: "youtube",
|
|
providerUserId: channel.id,
|
|
displayName,
|
|
avatar
|
|
});
|
|
db.prepare(
|
|
"INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " +
|
|
"VALUES (?, 'youtube', ?, ?, ?, ?, ?, ?, ?) " +
|
|
"ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at"
|
|
).run(
|
|
profile.id,
|
|
channel.id,
|
|
displayName,
|
|
token.access_token,
|
|
token.refresh_token || "",
|
|
token.expires_in ? now + token.expires_in * 1000 : null,
|
|
now,
|
|
now
|
|
);
|
|
req.session.user.id = profile.id;
|
|
req.session.user.username = profile.internal_username;
|
|
setFlash(req, "success", "YouTube account linked.");
|
|
res.redirect("/profile");
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).render("error", {
|
|
title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed",
|
|
message: isBot
|
|
? "Unable to connect the YouTube bot."
|
|
: isLogin
|
|
? "Unable to sign in with YouTube."
|
|
: "Unable to link YouTube account."
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get("/profile", requireAuth, (req, res) => {
|
|
const profile = getUserProfileById(req.session.user.id);
|
|
if (!profile) {
|
|
req.session.destroy(() => {
|
|
res.redirect("/");
|
|
});
|
|
return;
|
|
}
|
|
const accounts = getUserIdentities(req.session.user.id);
|
|
const cooldownDays = 90;
|
|
const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000;
|
|
const lastUpdate = profile.username_updated_at || 0;
|
|
const nextAllowedAt = lastUpdate ? lastUpdate + cooldownMs : 0;
|
|
const canChangeUsername = !lastUpdate || Date.now() >= nextAllowedAt;
|
|
const remainingMs = canChangeUsername ? 0 : nextAllowedAt - Date.now();
|
|
const remainingDays = canChangeUsername
|
|
? 0
|
|
: Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000)));
|
|
const profileWidgets = profileSections
|
|
.filter((section) => hasAccess(req.session.user, section.role || "public"))
|
|
.slice()
|
|
.sort((a, b) => (a.order || 100) - (b.order || 100))
|
|
.map((section) => ({
|
|
...section,
|
|
locals: {
|
|
...(section.locals || {}),
|
|
user: req.session.user,
|
|
profile,
|
|
accounts
|
|
}
|
|
}));
|
|
res.render("profile", {
|
|
title: "Your profile",
|
|
accounts,
|
|
profile,
|
|
profileSections: profileWidgets,
|
|
canChangeUsername,
|
|
usernameCooldownDays: cooldownDays,
|
|
usernameCooldownRemainingDays: remainingDays,
|
|
usernameNextAllowedAt: nextAllowedAt
|
|
});
|
|
});
|
|
|
|
app.post("/profile/unlink/:provider", requireAuth, (req, res) => {
|
|
const provider = req.params.provider;
|
|
if (provider === "discord") {
|
|
setFlash(req, "error", "Discord cannot be unlinked from the WebUI.");
|
|
return res.redirect("/profile");
|
|
}
|
|
db.prepare(
|
|
"DELETE FROM linked_accounts WHERE user_id = ? AND provider = ?"
|
|
).run(req.session.user.id, provider);
|
|
db.prepare(
|
|
"DELETE FROM user_identities WHERE user_id = ? AND provider = ?"
|
|
).run(req.session.user.id, provider);
|
|
setFlash(req, "success", "Account unlinked.");
|
|
res.redirect("/profile");
|
|
});
|
|
|
|
app.post("/profile/username", requireAuth, (req, res) => {
|
|
const desired = (req.body.internal_username || "").trim();
|
|
const profile = getUserProfileById(req.session.user.id);
|
|
const cooldownMs = 90 * 24 * 60 * 60 * 1000;
|
|
if (profile?.username_updated_at) {
|
|
const nextAllowed = profile.username_updated_at + cooldownMs;
|
|
if (Date.now() < nextAllowed) {
|
|
setFlash(
|
|
req,
|
|
"error",
|
|
"You can change your username once every 90 days."
|
|
);
|
|
return res.redirect("/profile");
|
|
}
|
|
}
|
|
const result = updateInternalUsername(req.session.user.id, desired);
|
|
if (!result.ok) {
|
|
setFlash(req, "error", result.reason);
|
|
return res.redirect("/profile");
|
|
}
|
|
req.session.user.username = result.username;
|
|
setFlash(req, "success", "Username updated.");
|
|
res.redirect("/profile");
|
|
});
|
|
|
|
app.get("/feedback", requireAuth, (req, res) => {
|
|
markFeedbackViewed(req.session.user.id);
|
|
res.locals.feedbackNotifications = notificationSummary(req.session.user.id);
|
|
const selected = req.query.id
|
|
? getFeedbackForViewer(String(req.query.id), req.session.user.id)
|
|
: null;
|
|
res.render("feedback", {
|
|
title: "Feedback",
|
|
feedbackList: listPublicFeedback({ userId: req.session.user.id }),
|
|
myFeedback: listMyFeedback(req.session.user.id),
|
|
selectedFeedback: selected,
|
|
feedbackOptions: feedbackOptions()
|
|
});
|
|
});
|
|
|
|
app.post("/feedback/:id/comment", requireAuth, (req, res) => {
|
|
try {
|
|
addSubmitterComment(req.params.id, req.body.comment, req.session.user);
|
|
setFlash(req, "success", "Comment added.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`);
|
|
});
|
|
|
|
app.post("/feedback/:id/support", requireAuth, (req, res) => {
|
|
try {
|
|
supportFeedback(req.params.id, req.session.user);
|
|
setFlash(req, "success", "Feedback supported.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect(req.get("referer") || "/feedback");
|
|
});
|
|
|
|
app.get("/feedback/:id/screenshot", requireAuth, (req, res) => {
|
|
const entry = req.session.user?.isAdmin
|
|
? getFeedbackForAdmin(req.params.id)
|
|
: getFeedbackForSubmitter(req.params.id, req.session.user.id);
|
|
if (!entry?.screenshot?.path) {
|
|
return res.status(404).render("error", {
|
|
title: "Screenshot not found",
|
|
message: "That feedback screenshot is not available."
|
|
});
|
|
}
|
|
const screenshotPath = resolveFeedbackScreenshotPath(entry.screenshot.path, feedbackScreenshotDir);
|
|
if (!screenshotPath || !fs.existsSync(screenshotPath)) {
|
|
return res.status(404).render("error", {
|
|
title: "Screenshot not found",
|
|
message: "That feedback screenshot is no longer available."
|
|
});
|
|
}
|
|
res.setHeader("Content-Type", entry.screenshot.mime || "image/png");
|
|
res.setHeader("Cache-Control", "private, no-store");
|
|
res.sendFile(screenshotPath);
|
|
});
|
|
|
|
app.get("/feedback/:id/attachments/:attachmentId", requireAuth, (req, res) => {
|
|
const attachment = getFeedbackAttachment(
|
|
req.params.id,
|
|
req.params.attachmentId,
|
|
req.session.user.id,
|
|
Boolean(req.session.user?.isAdmin)
|
|
);
|
|
if (!attachment?.storage_path) {
|
|
return res.status(404).render("error", {
|
|
title: "Attachment not found",
|
|
message: "That feedback attachment is not available."
|
|
});
|
|
}
|
|
const attachmentPath = resolveFeedbackAttachmentPath(attachment.storage_path, feedbackAttachmentDir);
|
|
if (!attachmentPath || !fs.existsSync(attachmentPath)) {
|
|
return res.status(404).render("error", {
|
|
title: "Attachment not found",
|
|
message: "That feedback attachment is no longer available."
|
|
});
|
|
}
|
|
res.setHeader("Content-Type", attachment.mime || "application/octet-stream");
|
|
res.setHeader("Cache-Control", "private, no-store");
|
|
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(attachment.original_name || "attachment")}"`);
|
|
res.sendFile(attachmentPath);
|
|
});
|
|
|
|
app.get("/health", (req, res) => {
|
|
res.set("Cache-Control", "no-store");
|
|
res.json({ ok: true, ts: Date.now() });
|
|
});
|
|
|
|
app.get("/commands", (req, res) => {
|
|
const prefix = getSetting("command_prefix", "!");
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const usageMap = buildCommandUsageMap();
|
|
const commands = [];
|
|
const conflictMap = new Map();
|
|
const enabledPlatforms = getEnabledPlatformIds();
|
|
|
|
const addConflictEntries = (command) => {
|
|
const triggers = command.conflictTriggers || [command.trigger];
|
|
for (const trigger of triggers) {
|
|
for (const platform of command.platforms) {
|
|
const subcommand = command.subcommand || "";
|
|
const key = `${platform}:${trigger}:${subcommand}`;
|
|
const entries = conflictMap.get(key) || [];
|
|
if (!entries.some((entry) => entry.id === command.id)) {
|
|
entries.push({
|
|
id: command.id,
|
|
name: command.name,
|
|
origin: command.origin
|
|
});
|
|
}
|
|
conflictMap.set(key, entries);
|
|
}
|
|
}
|
|
};
|
|
|
|
const addCommand = (command) => {
|
|
command.count = usageMap.get(command.id) || 0;
|
|
command.anchor = `cmd-${slugify(command.id)}`;
|
|
command.link = `${baseUrl}/commands#${command.anchor}`;
|
|
const subcommand = command.subcommand || "";
|
|
command.search = [
|
|
command.triggerDisplay,
|
|
subcommand,
|
|
command.name,
|
|
command.description,
|
|
command.origin,
|
|
command.platforms.join(" ")
|
|
]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
command.sort = {
|
|
trigger: subcommand ? `${command.trigger} ${subcommand}` : command.trigger,
|
|
name: command.name.toLowerCase(),
|
|
description: command.description.toLowerCase(),
|
|
level: command.level.toLowerCase(),
|
|
platform: command.platforms.join(" "),
|
|
origin: command.origin.toLowerCase(),
|
|
count: command.count
|
|
};
|
|
if (command.levelHelp) {
|
|
command.levelHelp = command.levelHelp.toString();
|
|
}
|
|
commands.push(command);
|
|
addConflictEntries(command);
|
|
};
|
|
|
|
const customCommands = db
|
|
.prepare(
|
|
"SELECT id, trigger, response, mode, language, platform FROM custom_commands WHERE enabled = 1 ORDER BY trigger"
|
|
)
|
|
.all();
|
|
for (const row of customCommands) {
|
|
const trigger = normalizeCommandTrigger(row.trigger);
|
|
if (!trigger) {
|
|
continue;
|
|
}
|
|
const platforms = normalizeCustomPlatforms(row.platform, enabledPlatforms);
|
|
const description =
|
|
row.mode === "advanced"
|
|
? `Dynamic command (${row.language})`
|
|
: truncateText(row.response, 140);
|
|
addCommand({
|
|
id: `custom:${trigger}`,
|
|
trigger,
|
|
triggerDisplay: `${prefix}${trigger}`,
|
|
name: toTitleCase(trigger) || trigger,
|
|
description,
|
|
level: "public",
|
|
origin: "Custom",
|
|
platforms,
|
|
platformLabels: buildPlatformLabels(platforms),
|
|
conflictTriggers: [trigger]
|
|
});
|
|
}
|
|
|
|
const topOptions = getTopCommandOptions();
|
|
if (topOptions.length) {
|
|
const topPlatforms = enabledPlatforms.slice();
|
|
const topPlatformLabels = buildPlatformLabels(topPlatforms);
|
|
const topUsage = buildUsage("top", "top <category>");
|
|
addCommand({
|
|
id: "top",
|
|
trigger: "top",
|
|
triggerDisplay: `${prefix}${topUsage}`,
|
|
name: "Top",
|
|
description: "Show leaderboard rankings.",
|
|
level: "public",
|
|
origin: "Core",
|
|
platforms: topPlatforms,
|
|
platformLabels: topPlatformLabels,
|
|
conflictTriggers: ["top"]
|
|
});
|
|
for (const option of topOptions) {
|
|
const subcommand = normalizeSubcommand(option.id);
|
|
if (!subcommand) {
|
|
continue;
|
|
}
|
|
const usage = buildUsage("top", `top ${subcommand}`);
|
|
addCommand({
|
|
id: `top:${subcommand}`,
|
|
trigger: "top",
|
|
subcommand,
|
|
triggerDisplay: `${prefix}${usage}`,
|
|
name: option.label || toTitleCase(subcommand) || subcommand,
|
|
description:
|
|
option.description ||
|
|
`Show ${option.label || toTitleCase(subcommand) || subcommand} rankings.`,
|
|
level: "public",
|
|
origin: "Core",
|
|
platforms: topPlatforms,
|
|
platformLabels: topPlatformLabels,
|
|
conflictTriggers: ["top"]
|
|
});
|
|
}
|
|
}
|
|
|
|
const plugins = getPlugins().filter((plugin) => plugin.enabled);
|
|
for (const plugin of plugins) {
|
|
const cmdsPath = path.join(plugin.path, "cmds.json");
|
|
if (!fs.existsSync(cmdsPath)) {
|
|
continue;
|
|
}
|
|
const manifest = readJsonSafe(cmdsPath);
|
|
if (!manifest || !Array.isArray(manifest.commands)) {
|
|
continue;
|
|
}
|
|
const pluginSettings = getPluginSettingsMap(plugin.id);
|
|
const platformKeys = manifest.platformKeys || {};
|
|
const platformFlags = {};
|
|
for (const platform of enabledPlatforms) {
|
|
platformFlags[platform] = platformKeys[platform]
|
|
? parseBooleanSetting(pluginSettings[platformKeys[platform]], true)
|
|
: true;
|
|
}
|
|
const pluginName = manifest.pluginName || plugin.name || plugin.id;
|
|
|
|
for (const command of manifest.commands) {
|
|
if (!command || !command.trigger) {
|
|
continue;
|
|
}
|
|
const enabled = command.enabledKey
|
|
? parseBooleanSetting(pluginSettings[command.enabledKey], true)
|
|
: true;
|
|
if (!enabled) {
|
|
continue;
|
|
}
|
|
const override = command.triggerKey
|
|
? pluginSettings[command.triggerKey]
|
|
: "";
|
|
const trigger = normalizeCommandTrigger(override, command.trigger);
|
|
if (!trigger) {
|
|
continue;
|
|
}
|
|
const subcommand = normalizeSubcommand(command.subcommand);
|
|
const usage = buildUsage(
|
|
trigger,
|
|
command.usage || (subcommand ? `${trigger} ${subcommand}` : trigger)
|
|
);
|
|
const platforms = (Array.isArray(command.platforms) && command.platforms.length
|
|
? command.platforms
|
|
: enabledPlatforms
|
|
).filter((platform) => platformFlags[platform] && enabledPlatforms.includes(platform));
|
|
if (!platforms.length) {
|
|
continue;
|
|
}
|
|
const defaultTrigger = normalizeCommandTrigger(command.trigger);
|
|
const useAliases =
|
|
Array.isArray(command.aliases) &&
|
|
command.aliases.length &&
|
|
(!command.aliasesEnabledWhenDefault || trigger === defaultTrigger);
|
|
const aliasTriggers = useAliases
|
|
? command.aliases
|
|
.map((alias) => normalizeCommandTrigger(alias))
|
|
.filter(Boolean)
|
|
: [];
|
|
const description = truncateText(command.description || "", 140);
|
|
addCommand({
|
|
id: `${plugin.id}:${command.id || trigger}`,
|
|
trigger,
|
|
subcommand,
|
|
triggerDisplay: `${prefix}${usage}`,
|
|
name: command.name || toTitleCase(trigger) || trigger,
|
|
description,
|
|
level: command.level || "public",
|
|
levelHelp: command.levelHelp || "",
|
|
origin: pluginName,
|
|
platforms,
|
|
platformLabels: buildPlatformLabels(platforms),
|
|
conflictTriggers: [trigger, ...aliasTriggers]
|
|
});
|
|
}
|
|
}
|
|
|
|
commands.sort((a, b) =>
|
|
a.triggerDisplay.localeCompare(b.triggerDisplay)
|
|
);
|
|
|
|
const conflicts = [];
|
|
for (const [key, entries] of conflictMap.entries()) {
|
|
if (entries.length < 2) {
|
|
continue;
|
|
}
|
|
const [platform, trigger, subcommand] = key.split(":");
|
|
const triggerLabel = subcommand ? `${trigger} ${subcommand}` : trigger;
|
|
const sources = entries.map((entry) => {
|
|
if (entry.origin === entry.name) {
|
|
return entry.origin;
|
|
}
|
|
return `${entry.origin} (${entry.name})`;
|
|
});
|
|
conflicts.push({
|
|
trigger: triggerLabel,
|
|
triggerDisplay: `${prefix}${triggerLabel}`,
|
|
platform,
|
|
platformLabel: getPlatformLabel(platform),
|
|
sourcesLabel: sources.join(", ")
|
|
});
|
|
}
|
|
conflicts.sort((a, b) => {
|
|
if (a.trigger === b.trigger) {
|
|
return a.platform.localeCompare(b.platform);
|
|
}
|
|
return a.trigger.localeCompare(b.trigger);
|
|
});
|
|
const subcommandGroups = new Map();
|
|
for (const command of commands) {
|
|
if (!command.subcommand) {
|
|
continue;
|
|
}
|
|
const group = subcommandGroups.get(command.trigger) || {
|
|
root: null,
|
|
subcommands: []
|
|
};
|
|
group.subcommands.push(command);
|
|
subcommandGroups.set(command.trigger, group);
|
|
}
|
|
|
|
for (const command of commands) {
|
|
if (command.subcommand) {
|
|
continue;
|
|
}
|
|
const group = subcommandGroups.get(command.trigger);
|
|
if (group && !group.root) {
|
|
group.root = command;
|
|
}
|
|
}
|
|
|
|
const buildRootFromSubcommands = (trigger, group) => {
|
|
const platforms = Array.from(
|
|
new Set(group.subcommands.flatMap((item) => item.platforms))
|
|
);
|
|
const origins = Array.from(
|
|
new Set(group.subcommands.map((item) => item.origin))
|
|
);
|
|
const levels = Array.from(
|
|
new Set(group.subcommands.map((item) => item.level))
|
|
);
|
|
const platformLabels = buildPlatformLabels(platforms);
|
|
const name = toTitleCase(trigger) || trigger;
|
|
const subcommandLabels = group.subcommands
|
|
.map((item) => item.subcommand)
|
|
.filter(Boolean);
|
|
const description = subcommandLabels.length
|
|
? `Subcommands: ${subcommandLabels.join(", ")}`
|
|
: "Subcommand group.";
|
|
const origin = origins.length === 1 ? origins[0] : "Multiple";
|
|
const level = levels.length === 1 ? levels[0] : "mixed";
|
|
const count = group.subcommands.reduce(
|
|
(total, item) => total + (item.count || 0),
|
|
0
|
|
);
|
|
const id = `group:${trigger}`;
|
|
const triggerDisplay = `${prefix}${trigger}`;
|
|
const anchor = `cmd-${slugify(id)}`;
|
|
const link = `${baseUrl}/commands#${anchor}`;
|
|
const search = [
|
|
triggerDisplay,
|
|
name,
|
|
description,
|
|
origin,
|
|
platforms.join(" "),
|
|
subcommandLabels.join(" ")
|
|
]
|
|
.join(" ")
|
|
.trim();
|
|
const sort = {
|
|
trigger,
|
|
name: name.toLowerCase(),
|
|
description: description.toLowerCase(),
|
|
level: level.toLowerCase(),
|
|
platform: platforms.join(" "),
|
|
origin: origin.toLowerCase(),
|
|
count
|
|
};
|
|
return {
|
|
id,
|
|
groupKey: trigger,
|
|
trigger,
|
|
triggerDisplay,
|
|
name,
|
|
description,
|
|
level,
|
|
origin,
|
|
platforms,
|
|
platformLabels,
|
|
count,
|
|
anchor,
|
|
link,
|
|
search,
|
|
sort
|
|
};
|
|
};
|
|
|
|
const commandGroups = [];
|
|
const processed = new Set();
|
|
for (const command of commands) {
|
|
const group = subcommandGroups.get(command.trigger);
|
|
if (!group) {
|
|
command.groupKey = command.id;
|
|
commandGroups.push({ root: command, subcommands: [] });
|
|
continue;
|
|
}
|
|
if (processed.has(command.trigger)) {
|
|
continue;
|
|
}
|
|
const root = group.root || buildRootFromSubcommands(command.trigger, group);
|
|
root.groupKey = root.groupKey || command.trigger;
|
|
const subcommands = group.subcommands
|
|
.slice()
|
|
.sort((a, b) => a.triggerDisplay.localeCompare(b.triggerDisplay))
|
|
.map((item) => ({ ...item, groupKey: root.groupKey }));
|
|
if (subcommands.length) {
|
|
const extraSearch = subcommands
|
|
.map((item) => [item.subcommand, item.name].filter(Boolean).join(" "))
|
|
.join(" ");
|
|
root.search = `${root.search} ${extraSearch}`.trim();
|
|
}
|
|
commandGroups.push({ root, subcommands });
|
|
processed.add(command.trigger);
|
|
}
|
|
|
|
res.render("commands", {
|
|
title: "Commands",
|
|
commandGroups,
|
|
conflicts,
|
|
isAdmin: Boolean(req.session.user?.isAdmin)
|
|
});
|
|
});
|
|
|
|
app.get("/leaderboards", (req, res) => {
|
|
res.render("leaderboards", {
|
|
title: "Leaderboards",
|
|
sections: getLeaderboardSections({ limit: 25 })
|
|
});
|
|
});
|
|
|
|
app.get("/stats", requireAuth, (req, res) => {
|
|
const payload = buildUserStatsPayload(req.session.user.id);
|
|
res.render("stats", {
|
|
title: "Your stats",
|
|
stats: payload.stats,
|
|
expression: payload.expression,
|
|
pluginStats: payload.pluginStats,
|
|
statsOwner: {
|
|
username: req.session.user.username,
|
|
isSelf: true
|
|
},
|
|
compare: null
|
|
});
|
|
});
|
|
|
|
app.get("/stats/:username", (req, res) => {
|
|
const username = (req.params.username || "").trim();
|
|
if (!username) {
|
|
return res.status(404).render("error", {
|
|
title: "Not found",
|
|
message: "User not found."
|
|
});
|
|
}
|
|
const profile = db
|
|
.prepare(
|
|
"SELECT id, internal_username FROM user_profiles WHERE internal_username = ? LIMIT 1"
|
|
)
|
|
.get(username);
|
|
if (!profile) {
|
|
return res.status(404).render("error", {
|
|
title: "Not found",
|
|
message: "User not found."
|
|
});
|
|
}
|
|
|
|
const payload = buildUserStatsPayload(profile.id);
|
|
let compare = null;
|
|
if (req.session.user && req.session.user.id !== profile.id) {
|
|
const currentPayload = buildUserStatsPayload(req.session.user.id);
|
|
compare = {
|
|
leftLabel: req.session.user.username || "You",
|
|
rightLabel: profile.internal_username,
|
|
rows: buildCompareRows(currentPayload, payload)
|
|
};
|
|
}
|
|
|
|
res.render("stats", {
|
|
title: `${profile.internal_username}'s stats`,
|
|
stats: payload.stats,
|
|
expression: payload.expression,
|
|
pluginStats: payload.pluginStats,
|
|
statsOwner: {
|
|
username: profile.internal_username,
|
|
isSelf: req.session.user?.id === profile.id
|
|
},
|
|
compare
|
|
});
|
|
});
|
|
|
|
app.get("/pages/:slug", (req, res) => {
|
|
const page = db
|
|
.prepare(
|
|
"SELECT * FROM custom_pages WHERE slug = ? AND enabled = 1 LIMIT 1"
|
|
)
|
|
.get(req.params.slug);
|
|
if (!page) {
|
|
return res.status(404).render("error", {
|
|
title: "Not found",
|
|
message: "That page does not exist."
|
|
});
|
|
}
|
|
if (!hasAccess(req.session.user, page.role)) {
|
|
return res.status(403).render("error", {
|
|
title: "Access denied",
|
|
message: "You do not have access to that page."
|
|
});
|
|
}
|
|
const format = normalizePageFormat(page.format);
|
|
const pageData = {
|
|
...page,
|
|
format,
|
|
content_css: page.content_css || ""
|
|
};
|
|
const renderedContent =
|
|
format === "markdown" ? renderMarkdown(pageData.content) : "";
|
|
const pageSrcdoc =
|
|
format === "html" ? buildCustomPageSrcdoc(pageData, res.locals.theme) : "";
|
|
const safePageSrcdoc = pageSrcdoc.replace(/<\/script/gi, "<\\/script");
|
|
res.render("custom-page", {
|
|
title: page.title,
|
|
page: pageData,
|
|
renderedContent,
|
|
pageSrcdoc: safePageSrcdoc
|
|
});
|
|
});
|
|
|
|
app.get("/moderator", requireRole("mod"), (req, res) => {
|
|
const now = Date.now();
|
|
const totals = db
|
|
.prepare(
|
|
"SELECT user_id, SUM(CASE WHEN end_at IS NULL THEN ? - start_at ELSE end_at - start_at END) AS total_ms " +
|
|
"FROM mod_role_periods GROUP BY user_id"
|
|
)
|
|
.all(now);
|
|
const totalsMap = new Map(
|
|
totals.map((row) => [row.user_id, Number(row.total_ms || 0)])
|
|
);
|
|
const users = listUsersWithIdentities()
|
|
.filter((user) => totalsMap.has(user.id))
|
|
.map((user) => {
|
|
const identities = (user.identities || []).map((identity) => ({
|
|
...identity,
|
|
label: getPlatformLabel(identity.provider) || identity.provider
|
|
}));
|
|
const aliases = identities
|
|
.map(
|
|
(identity) =>
|
|
`${identity.label}: ${identity.display_name || identity.provider_user_id}`
|
|
)
|
|
.join(" | ");
|
|
return {
|
|
id: user.id,
|
|
username: user.internal_username,
|
|
identities,
|
|
aliasText: aliases,
|
|
totalMs: totalsMap.get(user.id) || 0
|
|
};
|
|
})
|
|
.sort((a, b) => b.totalMs - a.totalMs);
|
|
|
|
res.render("moderator", {
|
|
title: "Mods List",
|
|
mods: users,
|
|
formatDuration
|
|
});
|
|
});
|
|
|
|
app.get("/admin", requireRole("admin"), (req, res) => {
|
|
res.render("admin-dashboard", {
|
|
title: "Admin dashboard"
|
|
});
|
|
});
|
|
|
|
app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => {
|
|
const plugins = getPlugins();
|
|
const scope = parseDashboardScope(req.query.scope);
|
|
const scopeMs = DASHBOARD_SCOPES[scope];
|
|
const now = Date.now();
|
|
const logs = listLogs({ sinceMs: now - scopeMs });
|
|
const memory = process.memoryUsage();
|
|
recordMemorySample(memory, now);
|
|
const count = (table) => {
|
|
try {
|
|
return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
};
|
|
res.set("Cache-Control", "no-store");
|
|
res.json({
|
|
uptime_seconds: Math.round(process.uptime()),
|
|
memory: {
|
|
rss: memory.rss,
|
|
heap_used: memory.heapUsed,
|
|
heap_total: memory.heapTotal
|
|
},
|
|
plugins: {
|
|
total: plugins.length,
|
|
enabled: plugins.filter((plugin) => plugin.enabled).length
|
|
},
|
|
counts: {
|
|
users: count("user_profiles"),
|
|
commands: countVisibleCommands(),
|
|
pages: count("custom_pages"),
|
|
logs: count("logs")
|
|
},
|
|
scopes: Object.fromEntries(
|
|
Object.entries(DASHBOARD_SCOPES).map(([id, duration]) => [id, { duration_ms: duration }])
|
|
),
|
|
selected_scope: scope,
|
|
memory_scopes: scopedMemorySamples(now),
|
|
logs: {
|
|
scope,
|
|
scope_ms: scopeMs,
|
|
total: logs.length,
|
|
levels: countLogsByLevel(logs)
|
|
},
|
|
sampled_at: now
|
|
});
|
|
});
|
|
|
|
app.get("/admin/settings", requireRole("admin"), (req, res) => {
|
|
res.render("admin-settings", {
|
|
title: "Settings",
|
|
settings: getAllSettings(),
|
|
platforms: getPlatformStatus(),
|
|
localhostLoginAvailable: isLocalhostLoginAvailable(req),
|
|
navIconItems: buildNavIconItems(req.session.user, navItems, req.path)
|
|
});
|
|
});
|
|
|
|
app.post("/admin/settings", requireRole("admin"), (req, res) => {
|
|
const fields = [
|
|
"site_title",
|
|
"command_prefix",
|
|
"auto_update_enabled",
|
|
"auto_update_interval_minutes",
|
|
"git_remote",
|
|
"git_branch"
|
|
];
|
|
for (const field of fields) {
|
|
if (req.body[field] !== undefined) {
|
|
const value = req.body[field];
|
|
if (field === "auto_update_enabled") {
|
|
setSetting(field, value === "on");
|
|
} else if (field === "auto_update_interval_minutes") {
|
|
setSetting(field, Number(value));
|
|
} else {
|
|
setSetting(field, value.trim());
|
|
}
|
|
}
|
|
}
|
|
if (isLocalhostLoginAvailable(req)) {
|
|
const localhostUsername = String(req.body.localhost_login_username || "").trim();
|
|
if (localhostUsername) {
|
|
setSetting("localhost_login_username", localhostUsername);
|
|
}
|
|
const localhostPassword = String(req.body.localhost_login_password || "");
|
|
if (localhostPassword) {
|
|
setSetting("localhost_login_password", localhostPassword);
|
|
}
|
|
}
|
|
for (const field of ["homepage_link_buttons", "homepage_hero_entries"]) {
|
|
if (req.body[field] === undefined) continue;
|
|
const raw = String(req.body[field] || "").trim();
|
|
if (!raw) {
|
|
setSetting(field, []);
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) throw new Error("Expected an array.");
|
|
if (field === "homepage_hero_entries") {
|
|
const heroErrors = validateHomepageHeroEntries(parsed);
|
|
if (heroErrors.length) {
|
|
setFlash(req, "error", `Homepage hero cannot be saved yet. ${heroErrors.slice(0, 3).join(" ")}`);
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
}
|
|
setSetting(field, parsed);
|
|
} catch (error) {
|
|
setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`);
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
}
|
|
const platformStatus = getPlatformStatus();
|
|
const nextPlatformValues = new Map();
|
|
for (const platform of platformStatus) {
|
|
nextPlatformValues.set(platform.id, req.body[platform.enabledKey] === "on");
|
|
}
|
|
const hasLoginPlatform = platformStatus.some(
|
|
(platform) =>
|
|
platform.supported &&
|
|
platform.supportsLogin &&
|
|
nextPlatformValues.get(platform.id)
|
|
);
|
|
if (!hasLoginPlatform && !isLocalhostLoginAvailable(req)) {
|
|
setFlash(
|
|
req,
|
|
"error",
|
|
"At least one login platform must remain enabled."
|
|
);
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
|
|
let restartNeeded = false;
|
|
for (const platform of platformStatus) {
|
|
const nextValue = nextPlatformValues.get(platform.id);
|
|
const currentValue = isPlatformEnabled(platform.id);
|
|
if (nextValue !== currentValue) {
|
|
restartNeeded = true;
|
|
}
|
|
setSetting(platform.enabledKey, nextValue);
|
|
}
|
|
setFlash(
|
|
req,
|
|
"success",
|
|
restartNeeded ? "Settings saved. Restarting..." : "Settings saved."
|
|
);
|
|
res.redirect("/admin/settings");
|
|
if (restartNeeded) {
|
|
requestRestart();
|
|
}
|
|
});
|
|
|
|
app.post(
|
|
"/admin/settings/nav-icons",
|
|
requireRole("admin"),
|
|
navIconSingle,
|
|
(req, res) => {
|
|
if (req.uploadError) {
|
|
setFlash(req, "error", req.uploadError);
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const itemId = (req.body.item_id || "").trim();
|
|
if (!itemId) {
|
|
setFlash(req, "error", "Missing navigation item.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
if (!req.file) {
|
|
setFlash(req, "error", "Upload an SVG or PNG icon.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const map = getSetting("nav_item_icons", {}) || {};
|
|
const previous = map[itemId];
|
|
map[itemId] = req.file.filename;
|
|
setSetting("nav_item_icons", map);
|
|
if (previous) {
|
|
try {
|
|
fs.rmSync(path.join(navIconDir, previous), { force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
}
|
|
setFlash(req, "success", "Navigation icon updated.");
|
|
res.redirect("/admin/settings");
|
|
}
|
|
);
|
|
|
|
app.post("/admin/settings/nav-icons/reset", requireRole("admin"), (req, res) => {
|
|
const itemId = (req.body.item_id || "").trim();
|
|
if (!itemId) {
|
|
setFlash(req, "error", "Missing navigation item.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const map = getSetting("nav_item_icons", {}) || {};
|
|
const previous = map[itemId];
|
|
delete map[itemId];
|
|
setSetting("nav_item_icons", map);
|
|
if (previous) {
|
|
try {
|
|
fs.rmSync(path.join(navIconDir, previous), { force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
}
|
|
setFlash(req, "success", "Navigation icon reset.");
|
|
res.redirect("/admin/settings");
|
|
});
|
|
|
|
app.get("/admin/navigation", requireRole("admin"), (req, res) => {
|
|
const availableItems = collectNavItems(req.session.user, navItems, req.path);
|
|
const sortedItems = availableItems
|
|
.slice()
|
|
.sort((a, b) => {
|
|
if (a.section === b.section) {
|
|
return a.label.localeCompare(b.label);
|
|
}
|
|
return a.section.localeCompare(b.section);
|
|
});
|
|
const defaultStructure = buildDefaultNavStructure(sortedItems);
|
|
const storedStructure = normalizeNavStructure(getSetting("nav_structure", null));
|
|
const navStructure = storedStructure || {
|
|
...defaultStructure,
|
|
enabled: false,
|
|
includeUnassigned: true
|
|
};
|
|
const navSections = storedStructure?.sections?.length
|
|
? storedStructure.sections
|
|
: defaultStructure.sections;
|
|
res.render("admin-navigation", {
|
|
title: "Navigation",
|
|
navStructure,
|
|
navSectionsJson: JSON.stringify(navSections, null, 2),
|
|
navSectionsData: JSON.stringify(navSections),
|
|
defaultSectionsJson: JSON.stringify(defaultStructure.sections, null, 2),
|
|
navItems: sortedItems,
|
|
navItemsJson: JSON.stringify(sortedItems),
|
|
sectionIcons: NAV_SECTION_ICONS
|
|
});
|
|
});
|
|
|
|
app.post("/admin/navigation", requireRole("admin"), (req, res) => {
|
|
const enabled = req.body.nav_enabled === "on";
|
|
const includeUnassigned = req.body.nav_include_unassigned === "on";
|
|
const unassignedLabel = (req.body.nav_unassigned_label || "").trim();
|
|
const unassignedIcon = (req.body.nav_unassigned_icon || "").trim();
|
|
const unassignedId = (req.body.nav_unassigned_id || "").trim();
|
|
const rawSections = (req.body.nav_sections || "").trim();
|
|
let sections = [];
|
|
if (rawSections) {
|
|
try {
|
|
sections = JSON.parse(rawSections);
|
|
} catch {
|
|
setFlash(req, "error", "Navigation JSON is invalid.");
|
|
return res.redirect("/admin/navigation");
|
|
}
|
|
}
|
|
if (!Array.isArray(sections)) {
|
|
setFlash(req, "error", "Navigation JSON must be an array of sections.");
|
|
return res.redirect("/admin/navigation");
|
|
}
|
|
const navStructure = normalizeNavStructure({
|
|
enabled,
|
|
includeUnassigned,
|
|
unassignedLabel,
|
|
unassignedIcon,
|
|
unassignedId,
|
|
sections
|
|
});
|
|
if (!navStructure) {
|
|
setFlash(req, "error", "Navigation settings could not be saved.");
|
|
return res.redirect("/admin/navigation");
|
|
}
|
|
setSetting("nav_structure", navStructure);
|
|
setFlash(req, "success", "Navigation updated.");
|
|
res.redirect("/admin/navigation");
|
|
});
|
|
|
|
app.post("/admin/navigation/reset", requireRole("admin"), (req, res) => {
|
|
setSetting("nav_structure", null);
|
|
setFlash(req, "success", "Navigation reset to default.");
|
|
res.redirect("/admin/navigation");
|
|
});
|
|
|
|
app.get("/admin/discord-wizard", requireRole("admin"), (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
setFlash(req, "error", "Discord is disabled in Platform Integration.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const current = getDiscordSettings();
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const form = {
|
|
...current,
|
|
discord_client_secret: "",
|
|
discord_bot_token: "",
|
|
discord_redirect_uri:
|
|
current.discord_redirect_uri || `${baseUrl}/auth/discord/callback`
|
|
};
|
|
storeSnapshot(req, "discordWizardSnapshot", current);
|
|
res.render("wizard-discord", {
|
|
title: "Discord setup",
|
|
form,
|
|
checks: [],
|
|
errors: [],
|
|
actionBase: "/admin/discord-wizard",
|
|
cancelPath: "/admin/settings"
|
|
});
|
|
});
|
|
|
|
app.post("/admin/discord-wizard/verify", requireRole("admin"), async (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
setFlash(req, "error", "Discord is disabled in Platform Integration.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const current = getDiscordSettings();
|
|
const incoming = {
|
|
discord_client_id: req.body.discord_client_id || "",
|
|
discord_client_secret: req.body.discord_client_secret || "",
|
|
discord_redirect_uri: req.body.discord_redirect_uri || "",
|
|
discord_bot_token: req.body.discord_bot_token || "",
|
|
discord_guild_id: req.body.discord_guild_id || "",
|
|
discord_admin_role_id: req.body.discord_admin_role_id || "",
|
|
discord_mod_role_id: req.body.discord_mod_role_id || ""
|
|
};
|
|
const merged = mergeSecrets(
|
|
current,
|
|
incoming,
|
|
new Set(["discord_client_secret", "discord_bot_token"])
|
|
);
|
|
if (!merged.discord_redirect_uri) {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
merged.discord_redirect_uri = `${baseUrl}/auth/discord/callback`;
|
|
}
|
|
const result = await verifyDiscordSettings(merged);
|
|
if (!result.ok) {
|
|
res.render("wizard-discord", {
|
|
title: "Discord setup",
|
|
form: {
|
|
...incoming,
|
|
discord_client_secret: "",
|
|
discord_bot_token: "",
|
|
discord_redirect_uri: merged.discord_redirect_uri
|
|
},
|
|
checks: result.checks,
|
|
errors: result.errors,
|
|
actionBase: "/admin/discord-wizard",
|
|
cancelPath: "/admin/settings"
|
|
});
|
|
return;
|
|
}
|
|
saveSettingsMap(merged);
|
|
delete req.session.discordWizardSnapshot;
|
|
setFlash(req, "success", "Discord setup saved.");
|
|
res.redirect("/admin/settings");
|
|
});
|
|
|
|
app.post("/admin/discord-wizard/cancel", requireRole("admin"), (req, res) => {
|
|
if (!isPlatformEnabled("discord")) {
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
restoreSnapshot(req, "discordWizardSnapshot");
|
|
setFlash(req, "info", "Discord setup canceled.");
|
|
res.redirect("/admin/settings");
|
|
});
|
|
|
|
app.get("/admin/twitch-wizard", requireRole("admin"), (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
setFlash(req, "error", "Twitch is disabled in Platform Integration.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const current = getTwitchSettings();
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const form = {
|
|
...current,
|
|
twitch_client_secret: "",
|
|
twitch_bot_oauth: "",
|
|
twitch_redirect_uri:
|
|
current.twitch_redirect_uri || `${baseUrl}/auth/twitch/callback`
|
|
};
|
|
storeSnapshot(req, "twitchWizardSnapshot", current);
|
|
res.render("wizard-twitch", {
|
|
title: "Twitch setup",
|
|
form,
|
|
checks: [],
|
|
errors: [],
|
|
actionBase: "/admin/twitch-wizard",
|
|
cancelPath: "/admin/settings"
|
|
});
|
|
});
|
|
|
|
app.post("/admin/twitch-wizard/verify", requireRole("admin"), async (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
setFlash(req, "error", "Twitch is disabled in Platform Integration.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const current = getTwitchSettings();
|
|
const incoming = {
|
|
twitch_client_id: req.body.twitch_client_id || "",
|
|
twitch_client_secret: req.body.twitch_client_secret || "",
|
|
twitch_redirect_uri: req.body.twitch_redirect_uri || "",
|
|
twitch_bot_username: req.body.twitch_bot_username || "",
|
|
twitch_bot_oauth: req.body.twitch_bot_oauth || "",
|
|
twitch_channels: req.body.twitch_channels || ""
|
|
};
|
|
const merged = mergeSecrets(
|
|
current,
|
|
incoming,
|
|
new Set(["twitch_client_secret", "twitch_bot_oauth"])
|
|
);
|
|
if (!merged.twitch_redirect_uri) {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
merged.twitch_redirect_uri = `${baseUrl}/auth/twitch/callback`;
|
|
}
|
|
const result = await verifyTwitchSettings(merged);
|
|
if (!result.ok) {
|
|
res.render("wizard-twitch", {
|
|
title: "Twitch setup",
|
|
form: {
|
|
...incoming,
|
|
twitch_client_secret: "",
|
|
twitch_bot_oauth: "",
|
|
twitch_redirect_uri: merged.twitch_redirect_uri
|
|
},
|
|
checks: result.checks,
|
|
errors: result.errors,
|
|
actionBase: "/admin/twitch-wizard",
|
|
cancelPath: "/admin/settings"
|
|
});
|
|
return;
|
|
}
|
|
saveSettingsMap(merged);
|
|
delete req.session.twitchWizardSnapshot;
|
|
setFlash(req, "success", "Twitch setup saved.");
|
|
res.redirect("/admin/settings");
|
|
});
|
|
|
|
app.post("/admin/twitch-wizard/cancel", requireRole("admin"), (req, res) => {
|
|
if (!isPlatformEnabled("twitch")) {
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
restoreSnapshot(req, "twitchWizardSnapshot");
|
|
setFlash(req, "info", "Twitch setup canceled.");
|
|
res.redirect("/admin/settings");
|
|
});
|
|
|
|
app.get("/admin/youtube-wizard", requireRole("admin"), (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
setFlash(req, "error", "YouTube is disabled in Platform Integration.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const current = getYouTubeSettings();
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const form = {
|
|
...current,
|
|
youtube_client_secret: "",
|
|
youtube_redirect_uri:
|
|
current.youtube_redirect_uri || `${baseUrl}/auth/youtube/callback`
|
|
};
|
|
const snapshot = {
|
|
...current,
|
|
youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", ""),
|
|
youtube_bot_channel_id: getSetting("youtube_bot_channel_id", "")
|
|
};
|
|
storeSnapshot(req, "youtubeWizardSnapshot", snapshot);
|
|
res.render("wizard-youtube", {
|
|
title: "YouTube setup",
|
|
form,
|
|
checks: [],
|
|
errors: [],
|
|
actionBase: "/admin/youtube-wizard",
|
|
cancelPath: "/admin/settings",
|
|
connectPath: "/admin/youtube-wizard/connect",
|
|
botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")),
|
|
botChannelId: getSetting("youtube_bot_channel_id", ""),
|
|
botChannelName: getYouTubeClient()?.channelName || null
|
|
});
|
|
});
|
|
|
|
app.post("/admin/youtube-wizard/connect", requireRole("admin"), (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
setFlash(req, "error", "YouTube is disabled in Platform Integration.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const clientId = (req.body.youtube_client_id || "").trim();
|
|
const clientSecret = (req.body.youtube_client_secret || "").trim();
|
|
const redirectUri =
|
|
(req.body.youtube_redirect_uri || "").trim() ||
|
|
`${baseUrl}/auth/youtube/callback`;
|
|
if (!clientId || !clientSecret) {
|
|
setFlash(req, "error", "Client ID and Client Secret are required.");
|
|
return res.redirect("/admin/youtube-wizard");
|
|
}
|
|
setSetting("youtube_client_id", clientId);
|
|
setSetting("youtube_client_secret", clientSecret);
|
|
setSetting("youtube_redirect_uri", redirectUri);
|
|
const state = crypto.randomBytes(16).toString("hex");
|
|
req.session.youtubeBotState = state;
|
|
req.session.youtubeBotReturnTo = "/admin/youtube-wizard";
|
|
const url = buildYouTubeAuthUrl(state, redirectUri, { prompt: "consent" });
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.post("/admin/youtube-wizard/verify", requireRole("admin"), async (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
setFlash(req, "error", "YouTube is disabled in Platform Integration.");
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
const current = getYouTubeSettings();
|
|
const incoming = {
|
|
youtube_client_id: req.body.youtube_client_id || "",
|
|
youtube_client_secret: req.body.youtube_client_secret || "",
|
|
youtube_redirect_uri: req.body.youtube_redirect_uri || ""
|
|
};
|
|
const merged = mergeSecrets(
|
|
current,
|
|
incoming,
|
|
new Set(["youtube_client_secret"])
|
|
);
|
|
if (!merged.youtube_redirect_uri) {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
merged.youtube_redirect_uri = `${baseUrl}/auth/youtube/callback`;
|
|
}
|
|
const result = await verifyYouTubeSettings({
|
|
...merged,
|
|
youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", "")
|
|
});
|
|
if (!result.ok) {
|
|
res.render("wizard-youtube", {
|
|
title: "YouTube setup",
|
|
form: {
|
|
...incoming,
|
|
youtube_client_secret: "",
|
|
youtube_redirect_uri: merged.youtube_redirect_uri
|
|
},
|
|
checks: result.checks,
|
|
errors: result.errors,
|
|
actionBase: "/admin/youtube-wizard",
|
|
cancelPath: "/admin/settings",
|
|
connectPath: "/admin/youtube-wizard/connect",
|
|
botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")),
|
|
botChannelId: getSetting("youtube_bot_channel_id", ""),
|
|
botChannelName: getYouTubeClient()?.channelName || null
|
|
});
|
|
return;
|
|
}
|
|
if (result.channel?.id) {
|
|
merged.youtube_bot_channel_id = result.channel.id;
|
|
}
|
|
saveSettingsMap(merged);
|
|
delete req.session.youtubeWizardSnapshot;
|
|
setFlash(req, "success", "YouTube setup saved.");
|
|
res.redirect("/admin/settings");
|
|
});
|
|
|
|
app.post("/admin/youtube-wizard/cancel", requireRole("admin"), (req, res) => {
|
|
if (!isPlatformEnabled("youtube")) {
|
|
return res.redirect("/admin/settings");
|
|
}
|
|
restoreSnapshot(req, "youtubeWizardSnapshot");
|
|
setFlash(req, "info", "YouTube setup canceled.");
|
|
res.redirect("/admin/settings");
|
|
});
|
|
|
|
app.get("/admin/theming", requireRole("admin"), (req, res) => {
|
|
const activeTheme = getActiveTheme();
|
|
const requestedEdit = String(req.query.edit || "");
|
|
const editingTheme = requestedEdit
|
|
? getThemeById(requestedEdit)
|
|
: activeTheme.builtin
|
|
? null
|
|
: activeTheme;
|
|
const editingBaseTheme = editingTheme && !editingTheme.builtin
|
|
? getThemeById(editingTheme.baseThemeId)
|
|
: null;
|
|
res.render("admin-theme", {
|
|
title: "Theming",
|
|
theme: activeTheme,
|
|
activeTheme,
|
|
themes: listThemes(),
|
|
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null,
|
|
editingBaseTheme,
|
|
fontStacks: FONT_STACKS
|
|
});
|
|
});
|
|
|
|
app.post("/admin/theming/select", requireRole("admin"), (req, res) => {
|
|
try {
|
|
const theme = setActiveTheme(String(req.body.theme_id || ""));
|
|
setFlash(req, "success", `${theme.name} is now active.`);
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect("/admin/theming");
|
|
});
|
|
|
|
app.post("/admin/theming/duplicate", requireRole("admin"), (req, res) => {
|
|
try {
|
|
const theme = duplicateTheme(req.body.theme_id, req.body.name);
|
|
setFlash(req, "success", `${theme.name} created. You can edit it below.`);
|
|
return res.redirect(`/admin/theming?edit=${encodeURIComponent(theme.id)}#theme-editor`);
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
return res.redirect("/admin/theming");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/theming/custom/:id/save", requireRole("admin"), (req, res) => {
|
|
const themeId = `custom:${req.params.id}`;
|
|
try {
|
|
const current = getThemeById(themeId);
|
|
if (!current) throw new Error("Theme not found.");
|
|
const theme = saveCustomTheme(themeId, valuesFromRequest(req.body, current));
|
|
if (req.body.apply === "on") setActiveTheme(theme.id);
|
|
setFlash(
|
|
req,
|
|
"success",
|
|
req.body.apply === "on"
|
|
? `${theme.name} saved and applied.`
|
|
: `${theme.name} saved.`
|
|
);
|
|
} catch (error) {
|
|
const detail = error.validationErrors?.join(" ") || error.message;
|
|
setFlash(req, "error", detail);
|
|
}
|
|
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`);
|
|
});
|
|
|
|
app.post("/admin/theming/custom/:id/rename", requireRole("admin"), (req, res) => {
|
|
const themeId = `custom:${req.params.id}`;
|
|
try {
|
|
const theme = renameCustomTheme(themeId, req.body.name);
|
|
setFlash(req, "success", `Theme renamed to ${theme.name}.`);
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}`);
|
|
});
|
|
|
|
app.post("/admin/theming/custom/:id/delete", requireRole("admin"), (req, res) => {
|
|
try {
|
|
deleteCustomTheme(`custom:${req.params.id}`);
|
|
setFlash(req, "success", "Custom theme deleted.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect("/admin/theming");
|
|
});
|
|
|
|
app.post("/admin/theming", requireRole("admin"), (req, res) => {
|
|
let createdTarget = null;
|
|
try {
|
|
let target = getActiveTheme();
|
|
if (target.builtin) {
|
|
target = duplicateTheme(target.id, "Legacy Custom");
|
|
createdTarget = target.id;
|
|
}
|
|
const values = JSON.parse(JSON.stringify(target));
|
|
const legacyUpdates = [];
|
|
const legacyMap = {
|
|
theme_light_bg_1: ["light", "bg1"],
|
|
theme_light_bg_2: ["light", "bg2"],
|
|
theme_light_bg_3: ["light", "bg3"],
|
|
theme_light_text: ["light", "text"],
|
|
theme_light_text_muted: ["light", "muted"],
|
|
theme_light_accent: ["light", "accent"],
|
|
theme_light_accent_alt: ["light", "accentAlt"],
|
|
theme_light_danger: ["light", "danger"],
|
|
theme_light_surface: ["light", "surface"],
|
|
theme_light_surface_2: ["light", "surface2"],
|
|
theme_light_surface_3: ["light", "surface3"],
|
|
theme_light_border: ["light", "border"],
|
|
theme_dark_bg_1: ["dark", "bg1"],
|
|
theme_dark_bg_2: ["dark", "bg2"],
|
|
theme_dark_bg_3: ["dark", "bg3"],
|
|
theme_dark_text: ["dark", "text"],
|
|
theme_dark_text_muted: ["dark", "muted"],
|
|
theme_dark_accent: ["dark", "accent"],
|
|
theme_dark_accent_alt: ["dark", "accentAlt"],
|
|
theme_dark_danger: ["dark", "danger"],
|
|
theme_dark_surface: ["dark", "surface"],
|
|
theme_dark_surface_2: ["dark", "surface2"],
|
|
theme_dark_surface_3: ["dark", "surface3"],
|
|
theme_dark_border: ["dark", "border"],
|
|
theme_role_public: ["role", "public"],
|
|
theme_role_mod: ["role", "mod"],
|
|
theme_role_admin: ["role", "admin"]
|
|
};
|
|
for (const [field, [group, key]] of Object.entries(legacyMap)) {
|
|
if (req.body[field] === undefined) continue;
|
|
values[group][key] = String(req.body[field]).trim();
|
|
legacyUpdates.push([field, values[group][key]]);
|
|
}
|
|
saveCustomTheme(target.id, values);
|
|
for (const [field, value] of legacyUpdates) setSetting(field, value);
|
|
setActiveTheme(target.id);
|
|
setFlash(req, "success", "Theme updated.");
|
|
} catch (error) {
|
|
if (createdTarget) deleteCustomTheme(createdTarget);
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect("/admin/theming");
|
|
});
|
|
|
|
app.get("/admin/logs", requireRole("admin"), (req, res) => {
|
|
const range = parseLogRange(req.query.range);
|
|
const limit = parseLogLimit(req.query.limit);
|
|
const levelValue = normalizeLogLevel(req.query.level) || "all";
|
|
const levels = levelValue === "all" ? [] : [levelValue];
|
|
const sinceMs = range.rangeMs ? Date.now() - range.rangeMs : null;
|
|
const logs = listLogs({ limit: limit.limit, sinceMs, levels });
|
|
res.render("admin-logs", {
|
|
title: "Logs",
|
|
logs,
|
|
logFilters: {
|
|
range: range.rangeValue,
|
|
level: levelValue,
|
|
limit: limit.limitValue
|
|
}
|
|
});
|
|
});
|
|
|
|
app.get("/admin/logs/download", requireRole("admin"), (req, res) => {
|
|
const range = parseLogRange(req.query.range);
|
|
const limit = parseLogLimit(req.query.limit, { allowAll: true });
|
|
const levels = parseLogLevels(req.query.level);
|
|
const sinceMs = range.rangeMs ? Date.now() - range.rangeMs : null;
|
|
const logs = listLogs({ limit: limit.limit, sinceMs, levels });
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="lumi-logs-${stamp}.txt"`
|
|
);
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
const lines = logs.map((log) => {
|
|
const timestamp = new Date(log.created_at).toISOString();
|
|
const header = `${timestamp} [${log.level.toUpperCase()}] ${log.message}`;
|
|
if (log.details) {
|
|
return `${header}\n${log.details}\n`;
|
|
}
|
|
return `${header}\n`;
|
|
});
|
|
res.send(lines.join("\n"));
|
|
});
|
|
|
|
app.get("/admin/feedback", requireRole("admin"), (req, res) => {
|
|
res.render("admin-feedback", {
|
|
title: "Feedback review",
|
|
feedbackItems: listFeedbackForAdmin(req.query),
|
|
filters: {
|
|
status: req.query.status || "",
|
|
category: req.query.category || "",
|
|
severity: req.query.severity || "",
|
|
scope: req.query.scope || "",
|
|
area: req.query.area || "",
|
|
submitter: req.query.submitter || "",
|
|
date_from: req.query.date_from || "",
|
|
date_to: req.query.date_to || "",
|
|
needs_action: req.query.needs_action || "",
|
|
sort: req.query.sort || "last_activity"
|
|
},
|
|
feedbackOptions: feedbackOptions()
|
|
});
|
|
});
|
|
|
|
app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => {
|
|
try {
|
|
const body = { ...req.body };
|
|
if (body.review_action === "finalize") {
|
|
body.status = "closed";
|
|
body.status_note = body.status_note || "Finalized and closed.";
|
|
} else if (body.review_action === "reopen") {
|
|
body.status = "reviewed";
|
|
body.status_note = body.status_note || "Reopened for review.";
|
|
}
|
|
adminUpdateFeedback(req.params.id, body, req.session.user);
|
|
setFlash(req, "success", body.review_action === "finalize"
|
|
? "Feedback finalized and closed."
|
|
: body.review_action === "reopen"
|
|
? "Feedback reopened."
|
|
: "Feedback updated.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect(req.get("referer") || "/admin/feedback");
|
|
});
|
|
|
|
app.post("/admin/feedback/:id/finalize", requireRole("admin"), (req, res) => {
|
|
try {
|
|
adminUpdateFeedback(req.params.id, {
|
|
...req.body,
|
|
status: "closed",
|
|
status_note: req.body.status_note || "Finalized and closed."
|
|
}, req.session.user);
|
|
setFlash(req, "success", "Feedback finalized and closed.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect(req.get("referer") || "/admin/feedback");
|
|
});
|
|
|
|
app.post("/admin/feedback/:id/reopen", requireRole("admin"), (req, res) => {
|
|
try {
|
|
adminUpdateFeedback(req.params.id, {
|
|
...req.body,
|
|
status: "reviewed",
|
|
status_note: req.body.status_note || "Reopened for review."
|
|
}, req.session.user);
|
|
setFlash(req, "success", "Feedback reopened.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect(req.get("referer") || "/admin/feedback");
|
|
});
|
|
|
|
app.post("/admin/feedback/:id/delete", requireRole("admin"), (req, res) => {
|
|
try {
|
|
deleteFeedback(req.params.id, {
|
|
deleteScreenshot(relativePath) {
|
|
const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir);
|
|
if (screenshotPath) {
|
|
fs.rmSync(screenshotPath, { force: true });
|
|
}
|
|
},
|
|
deleteAttachment(relativePath) {
|
|
const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir);
|
|
if (attachmentPath) {
|
|
fs.rmSync(attachmentPath, { force: true });
|
|
}
|
|
}
|
|
});
|
|
setFlash(req, "success", "Feedback permanently deleted.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect("/admin/feedback");
|
|
});
|
|
|
|
app.post("/admin/feedback/:id/cleanup", requireRole("admin"), (req, res) => {
|
|
try {
|
|
cleanupFeedback(req.params.id, req.body, req.session.user, {
|
|
deleteScreenshot(relativePath) {
|
|
const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir);
|
|
if (screenshotPath) {
|
|
fs.rmSync(screenshotPath, { force: true });
|
|
}
|
|
},
|
|
deleteAttachment(relativePath) {
|
|
const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir);
|
|
if (attachmentPath) {
|
|
fs.rmSync(attachmentPath, { force: true });
|
|
}
|
|
}
|
|
});
|
|
setFlash(req, "success", "Feedback data cleaned.");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
}
|
|
res.redirect(req.get("referer") || "/admin/feedback");
|
|
});
|
|
|
|
app.get("/admin/privileges", requireRole("admin"), async (req, res) => {
|
|
const discord = await buildDiscordPrivileges(discordClient);
|
|
const twitch = await buildTwitchPrivileges();
|
|
res.render("admin-privileges", {
|
|
title: "Privileges",
|
|
discord,
|
|
twitch
|
|
});
|
|
});
|
|
|
|
app.get("/admin/commands", requireRole("mod"), (req, res) => {
|
|
const platformStatus = getPlatformStatus().filter((platform) => platform.supported);
|
|
const availablePlatforms = platformStatus.map((platform) => platform.id);
|
|
const commands = db
|
|
.prepare("SELECT * FROM custom_commands ORDER BY trigger")
|
|
.all()
|
|
.map((command) => ({
|
|
...command,
|
|
platforms: normalizeCustomPlatforms(command.platform, availablePlatforms),
|
|
preview: buildCommandPreviewPresentation(command)
|
|
}));
|
|
res.render("admin-commands", {
|
|
title: "Custom commands",
|
|
commands,
|
|
isAdmin: Boolean(req.session.user?.isAdmin),
|
|
platforms: platformStatus.map((platform) => ({
|
|
id: platform.id,
|
|
label: platform.label,
|
|
enabled: platform.enabled
|
|
}))
|
|
});
|
|
});
|
|
|
|
app.post("/admin/commands", requireRole("mod"), async (req, res) => {
|
|
const isAdmin = Boolean(req.session.user?.isAdmin);
|
|
const availablePlatforms = getPlatformStatus()
|
|
.filter((platform) => platform.supported)
|
|
.map((platform) => platform.id);
|
|
const trigger = (req.body.trigger || "").trim().toLowerCase();
|
|
const mode = (req.body.mode || "plain").trim();
|
|
const language = (req.body.language || "js").trim();
|
|
const response = (req.body.response || "").trim();
|
|
const code = (req.body.code || "").trim();
|
|
const selectedPlatforms = parsePlatformSelectionFromBody(
|
|
req.body,
|
|
availablePlatforms
|
|
);
|
|
if (!trigger) {
|
|
setFlash(req, "error", "Trigger is required.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
if (!selectedPlatforms.length) {
|
|
setFlash(req, "error", "Select at least one platform.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
if (!isAdmin && mode === "advanced") {
|
|
setFlash(req, "error", "Dynamic commands are restricted to admins.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
if (mode === "advanced") {
|
|
if (!code) {
|
|
setFlash(req, "error", "Dynamic commands require code.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
} else if (!response) {
|
|
setFlash(req, "error", "Static commands require a response.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
const now = Date.now();
|
|
const preview = isAdmin && mode === "advanced"
|
|
? await generateCommandPreview({ code, language })
|
|
: emptyCommandPreview();
|
|
try {
|
|
db.prepare(
|
|
"INSERT INTO custom_commands (trigger, response, mode, language, code, platform, preview_text, preview_status, preview_error, preview_generated_at, preview_dynamic_segments, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)"
|
|
).run(
|
|
trigger,
|
|
response || "",
|
|
isAdmin ? mode : "plain",
|
|
isAdmin ? language : "js",
|
|
isAdmin && mode === "advanced" ? code : null,
|
|
serializePlatformSelection(selectedPlatforms),
|
|
preview.preview_text,
|
|
preview.preview_status,
|
|
preview.preview_error,
|
|
preview.preview_generated_at,
|
|
preview.preview_dynamic_segments,
|
|
now,
|
|
now
|
|
);
|
|
setFlash(req, "success", "Command created.");
|
|
res.redirect("/admin/commands");
|
|
} catch (error) {
|
|
setFlash(req, "error", "That command already exists.");
|
|
res.redirect("/admin/commands");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/commands/:id/toggle", requireRole("mod"), (req, res) => {
|
|
const row = db
|
|
.prepare("SELECT enabled FROM custom_commands WHERE id = ?")
|
|
.get(req.params.id);
|
|
if (row) {
|
|
db.prepare(
|
|
"UPDATE custom_commands SET enabled = ?, updated_at = ? WHERE id = ?"
|
|
).run(row.enabled ? 0 : 1, Date.now(), req.params.id);
|
|
}
|
|
res.redirect("/admin/commands");
|
|
});
|
|
|
|
app.post("/admin/commands/:id/delete", requireRole("mod"), (req, res) => {
|
|
db.prepare("DELETE FROM custom_commands WHERE id = ?").run(req.params.id);
|
|
setFlash(req, "success", "Command deleted.");
|
|
res.redirect("/admin/commands");
|
|
});
|
|
|
|
app.post("/admin/commands/:id/update", requireRole("mod"), async (req, res) => {
|
|
const isAdmin = Boolean(req.session.user?.isAdmin);
|
|
const existing = db
|
|
.prepare("SELECT mode FROM custom_commands WHERE id = ?")
|
|
.get(req.params.id);
|
|
if (existing?.mode === "advanced" && !isAdmin) {
|
|
setFlash(req, "error", "Dynamic commands can only be edited by admins.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
const availablePlatforms = getPlatformStatus()
|
|
.filter((platform) => platform.supported)
|
|
.map((platform) => platform.id);
|
|
const trigger = (req.body.trigger || "").trim().toLowerCase();
|
|
const mode = (req.body.mode || "plain").trim();
|
|
const language = (req.body.language || "js").trim();
|
|
const response = (req.body.response || "").trim();
|
|
const code = (req.body.code || "").trim();
|
|
const selectedPlatforms = parsePlatformSelectionFromBody(
|
|
req.body,
|
|
availablePlatforms
|
|
);
|
|
if (!trigger) {
|
|
setFlash(req, "error", "Trigger is required.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
if (!selectedPlatforms.length) {
|
|
setFlash(req, "error", "Select at least one platform.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
if (!isAdmin && mode === "advanced") {
|
|
setFlash(req, "error", "Dynamic commands are restricted to admins.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
if (mode === "advanced") {
|
|
if (!code) {
|
|
setFlash(req, "error", "Dynamic commands require code.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
} else if (!response) {
|
|
setFlash(req, "error", "Static commands require a response.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
const preview = isAdmin && mode === "advanced"
|
|
? await generateCommandPreview({ code, language })
|
|
: emptyCommandPreview();
|
|
try {
|
|
db.prepare(
|
|
"UPDATE custom_commands SET trigger = ?, response = ?, mode = ?, language = ?, code = ?, platform = ?, preview_text = ?, preview_status = ?, preview_error = ?, preview_generated_at = ?, preview_dynamic_segments = ?, updated_at = ? WHERE id = ?"
|
|
).run(
|
|
trigger,
|
|
response || "",
|
|
isAdmin ? mode : "plain",
|
|
isAdmin ? language : "js",
|
|
isAdmin && mode === "advanced" ? code : null,
|
|
serializePlatformSelection(selectedPlatforms),
|
|
preview.preview_text,
|
|
preview.preview_status,
|
|
preview.preview_error,
|
|
preview.preview_generated_at,
|
|
preview.preview_dynamic_segments,
|
|
Date.now(),
|
|
req.params.id
|
|
);
|
|
setFlash(req, "success", "Command updated.");
|
|
res.redirect("/admin/commands");
|
|
} catch (error) {
|
|
setFlash(req, "error", "Unable to update command.");
|
|
res.redirect("/admin/commands");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/commands/:id/preview", requireRole("admin"), async (req, res) => {
|
|
const command = db
|
|
.prepare("SELECT mode, language, code FROM custom_commands WHERE id = ?")
|
|
.get(req.params.id);
|
|
if (!command || command.mode !== "advanced" || !command.code) {
|
|
setFlash(req, "error", "Dynamic command not found.");
|
|
return res.redirect("/admin/commands");
|
|
}
|
|
const preview = await generateCommandPreview(command);
|
|
db.prepare(
|
|
"UPDATE custom_commands SET preview_text = ?, preview_status = ?, preview_error = ?, preview_generated_at = ?, preview_dynamic_segments = ? WHERE id = ?"
|
|
).run(
|
|
preview.preview_text,
|
|
preview.preview_status,
|
|
preview.preview_error,
|
|
preview.preview_generated_at,
|
|
preview.preview_dynamic_segments,
|
|
req.params.id
|
|
);
|
|
setFlash(req, preview.preview_status === "ready" ? "success" : "error",
|
|
preview.preview_status === "ready" ? "Command preview refreshed." : "Preview unavailable.");
|
|
return res.redirect("/admin/commands");
|
|
});
|
|
|
|
app.get("/admin/pages", requireRole("admin"), (req, res) => {
|
|
const pages = db
|
|
.prepare("SELECT * FROM custom_pages ORDER BY created_at DESC")
|
|
.all();
|
|
res.render("admin-pages", {
|
|
title: "Custom pages",
|
|
pages
|
|
});
|
|
});
|
|
|
|
app.get("/admin/users", requireRole("mod"), (req, res) => {
|
|
const users = listUsersWithIdentities();
|
|
const notes = db
|
|
.prepare(
|
|
"SELECT n.*, s.internal_user_id FROM moderation_notes n " +
|
|
"LEFT JOIN moderation_subjects s ON s.id = n.subject_id"
|
|
)
|
|
.all();
|
|
const notesByUser = notes.reduce((acc, note) => {
|
|
if (!note.internal_user_id) {
|
|
return acc;
|
|
}
|
|
if (!acc[note.internal_user_id]) {
|
|
acc[note.internal_user_id] = [];
|
|
}
|
|
acc[note.internal_user_id].push(note);
|
|
return acc;
|
|
}, {});
|
|
res.render("admin-users", {
|
|
title: "Users",
|
|
users,
|
|
notesByUser,
|
|
isAdmin: Boolean(req.session.user?.isAdmin)
|
|
});
|
|
});
|
|
|
|
app.post("/admin/users/:id/username", requireRole("admin"), (req, res) => {
|
|
const desired = (req.body.internal_username || "").trim();
|
|
const result = updateInternalUsername(req.params.id, desired);
|
|
if (!result.ok) {
|
|
setFlash(req, "error", result.reason);
|
|
return res.redirect("/admin/users");
|
|
}
|
|
setFlash(req, "success", "Username updated.");
|
|
res.redirect("/admin/users");
|
|
});
|
|
|
|
app.post("/admin/pages", requireRole("admin"), (req, res) => {
|
|
const slug = (req.body.slug || "").trim();
|
|
const title = (req.body.title || "").trim();
|
|
const navLabel = (req.body.nav_label || "").trim();
|
|
const content = (req.body.content || "").trim();
|
|
const format = normalizePageFormat(req.body.format);
|
|
const contentCss =
|
|
format === "html" ? (req.body.content_css || "").trim() : "";
|
|
const role = (req.body.role || "public").trim();
|
|
const showInNav = req.body.show_in_nav === "on";
|
|
if (!slug || !title || !content) {
|
|
setFlash(req, "error", "Slug, title, and content are required.");
|
|
return res.redirect("/admin/pages");
|
|
}
|
|
const now = Date.now();
|
|
try {
|
|
db.prepare(
|
|
"INSERT INTO custom_pages (slug, title, nav_label, content, content_css, format, role, show_in_nav, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)"
|
|
).run(
|
|
slug,
|
|
title,
|
|
navLabel || null,
|
|
content,
|
|
contentCss,
|
|
format,
|
|
role,
|
|
showInNav ? 1 : 0,
|
|
now,
|
|
now
|
|
);
|
|
setFlash(req, "success", "Page created.");
|
|
res.redirect("/admin/pages");
|
|
} catch (error) {
|
|
setFlash(req, "error", "That page already exists.");
|
|
res.redirect("/admin/pages");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/pages/:id/toggle", requireRole("admin"), (req, res) => {
|
|
const row = db
|
|
.prepare("SELECT enabled FROM custom_pages WHERE id = ?")
|
|
.get(req.params.id);
|
|
if (row) {
|
|
db.prepare(
|
|
"UPDATE custom_pages SET enabled = ?, updated_at = ? WHERE id = ?"
|
|
).run(row.enabled ? 0 : 1, Date.now(), req.params.id);
|
|
}
|
|
res.redirect("/admin/pages");
|
|
});
|
|
|
|
app.post("/admin/pages/:id/delete", requireRole("admin"), (req, res) => {
|
|
db.prepare("DELETE FROM custom_pages WHERE id = ?").run(req.params.id);
|
|
setFlash(req, "success", "Page deleted.");
|
|
res.redirect("/admin/pages");
|
|
});
|
|
|
|
app.post("/admin/pages/:id/update", requireRole("admin"), (req, res) => {
|
|
const slug = (req.body.slug || "").trim();
|
|
const title = (req.body.title || "").trim();
|
|
const navLabel = (req.body.nav_label || "").trim();
|
|
const content = (req.body.content || "").trim();
|
|
const format = normalizePageFormat(req.body.format);
|
|
const contentCss =
|
|
format === "html" ? (req.body.content_css || "").trim() : "";
|
|
const role = (req.body.role || "public").trim();
|
|
const showInNav = req.body.show_in_nav === "on";
|
|
if (!slug || !title || !content) {
|
|
setFlash(req, "error", "Slug, title, and content are required.");
|
|
return res.redirect("/admin/pages");
|
|
}
|
|
try {
|
|
db.prepare(
|
|
"UPDATE custom_pages SET slug = ?, title = ?, nav_label = ?, content = ?, content_css = ?, format = ?, role = ?, show_in_nav = ?, updated_at = ? WHERE id = ?"
|
|
).run(
|
|
slug,
|
|
title,
|
|
navLabel || null,
|
|
content,
|
|
contentCss,
|
|
format,
|
|
role,
|
|
showInNav ? 1 : 0,
|
|
Date.now(),
|
|
req.params.id
|
|
);
|
|
setFlash(req, "success", "Page updated.");
|
|
res.redirect("/admin/pages");
|
|
} catch (error) {
|
|
setFlash(req, "error", "Unable to update page.");
|
|
res.redirect("/admin/pages");
|
|
}
|
|
});
|
|
|
|
app.get("/admin/plugins", requireRole("admin"), (req, res) => {
|
|
syncPluginRegistry();
|
|
const plugins = getPlugins().filter((plugin) => !/^lumi_ai_[a-z0-9_-]+$/i.test(plugin.id || ""));
|
|
res.render("admin-plugins", {
|
|
title: "Plugins",
|
|
plugins
|
|
});
|
|
});
|
|
|
|
app.post("/admin/plugins/:id/toggle", requireRole("admin"), (req, res) => {
|
|
setPluginEnabled(req.params.id, req.body.enabled === "true");
|
|
setFlash(req, "success", "Plugin updated. Restarting...");
|
|
res.redirect("/admin/plugins");
|
|
requestRestart();
|
|
});
|
|
|
|
app.post("/admin/plugins/:id/uninstall", requireRole("admin"), (req, res) => {
|
|
const plugin = db
|
|
.prepare("SELECT path FROM plugins WHERE id = ?")
|
|
.get(req.params.id);
|
|
if (plugin?.path) {
|
|
try {
|
|
fs.rmSync(plugin.path, { recursive: true, force: true });
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
removePlugin(req.params.id);
|
|
setFlash(req, "success", "Plugin uninstalled. Restarting...");
|
|
res.redirect("/admin/plugins");
|
|
requestRestart();
|
|
});
|
|
|
|
app.post(
|
|
"/admin/plugins/upload",
|
|
requireRole("admin"),
|
|
uploadSingle("plugin_zip"),
|
|
async (req, res) => {
|
|
if (req.uploadError) {
|
|
setFlash(req, "error", req.uploadError);
|
|
return res.redirect("/admin/plugins");
|
|
}
|
|
if (!req.file) {
|
|
setFlash(req, "error", "Upload a ZIP archive.");
|
|
return res.redirect("/admin/plugins");
|
|
}
|
|
try {
|
|
await applyPluginUpdate(req.file.path);
|
|
setFlash(req, "success", "Plugin uploaded. Restarting...");
|
|
res.redirect("/admin/plugins");
|
|
requestRestart();
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin/plugins");
|
|
} finally {
|
|
try {
|
|
fs.rmSync(req.file.path, { force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post("/admin/plugins/install", requireRole("admin"), (req, res) => {
|
|
const url = (req.body.url || "").trim();
|
|
if (!url) {
|
|
setFlash(req, "error", "Plugin URL is required.");
|
|
return res.redirect("/admin/plugins");
|
|
}
|
|
try {
|
|
installFromGit(url);
|
|
setFlash(req, "success", "Plugin installed. Restarting...");
|
|
res.redirect("/admin/plugins");
|
|
requestRestart();
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin/plugins");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/plugins/create", requireRole("admin"), (req, res) => {
|
|
const id = (req.body.id || "").trim();
|
|
const name = (req.body.name || "").trim();
|
|
const description = (req.body.description || "").trim();
|
|
try {
|
|
createLocalPlugin({ id, name, description });
|
|
setFlash(req, "success", "Plugin created. Restarting...");
|
|
res.redirect("/admin/plugins");
|
|
requestRestart();
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin/plugins");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/plugins/:id/update", requireRole("admin"), (req, res) => {
|
|
const plugin = db
|
|
.prepare("SELECT path FROM plugins WHERE id = ?")
|
|
.get(req.params.id);
|
|
if (!plugin?.path) {
|
|
setFlash(req, "error", "Plugin not found.");
|
|
return res.redirect("/admin/plugins");
|
|
}
|
|
try {
|
|
updatePluginFromGit(plugin.path);
|
|
setFlash(req, "success", "Plugin updated. Restarting...");
|
|
res.redirect("/admin/plugins");
|
|
requestRestart();
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin/plugins");
|
|
}
|
|
});
|
|
|
|
function wantsJson(req) {
|
|
return req.xhr ||
|
|
req.get("accept")?.includes("application/json") ||
|
|
req.get("content-type")?.includes("application/json");
|
|
}
|
|
|
|
function updateSourceFrom(req) {
|
|
const value = req.body?.source || req.query?.source || "stable";
|
|
return value === "experimental" ? "experimental" : String(value || "stable");
|
|
}
|
|
|
|
function sendUpdateResult(req, res, result, redirectPath = "/admin/updates") {
|
|
if (wantsJson(req)) {
|
|
return res.json({ ok: true, ...result });
|
|
}
|
|
setFlash(req, "success", result.message || "Update action completed.");
|
|
return res.redirect(redirectPath);
|
|
}
|
|
|
|
function sendUpdateError(req, res, error, redirectPath = "/admin/updates") {
|
|
if (wantsJson(req)) {
|
|
return res.status(400).json({ ok: false, error: error.message });
|
|
}
|
|
setFlash(req, "error", error.message);
|
|
return res.redirect(redirectPath);
|
|
}
|
|
|
|
function scheduleRestartAfterNotice() {
|
|
setTimeout(() => requestRestart(), 5000);
|
|
}
|
|
|
|
app.get("/admin/updates/events", requireRole("admin"), subscribeWebEvents);
|
|
|
|
app.get("/admin/updates/status", requireRole("admin"), (req, res) => {
|
|
try {
|
|
res.json({ ok: true, status: getUpdateStatus({ source: updateSourceFrom(req) }) });
|
|
} catch (error) {
|
|
res.status(500).json({ ok: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get("/admin/updates/recovery", requireRole("admin"), (req, res) => {
|
|
res.json({ ok: true, recovery: safeModeStatus(), snapshots: listSnapshots() });
|
|
});
|
|
|
|
app.post("/admin/updates/recovery/clear-marker", requireRole("admin"), (req, res) => {
|
|
clearRecoveryMarker();
|
|
publishWebEvent("recovery:marker_detected", { status: "cleared" }, { role: "admin" });
|
|
sendUpdateResult(req, res, { message: "Recovery marker cleared." });
|
|
});
|
|
|
|
app.post("/admin/updates/recovery/retry-normal-startup", requireRole("admin"), (req, res) => {
|
|
updateRecoveryMarker({ status: "retry_startup", retry_at: new Date().toISOString() });
|
|
publishWebEvent("recovery:retry_startup", {}, { role: "admin" });
|
|
sendUpdateResult(req, res, { message: "Retrying normal startup." });
|
|
scheduleRestartAfterNotice();
|
|
});
|
|
|
|
app.get("/admin/updates", requireRole("admin"), (req, res) => {
|
|
let updateStatus = null;
|
|
let updateStatusError = null;
|
|
let snapshots = [];
|
|
let recoveryStatus = {};
|
|
try {
|
|
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
|
|
} catch (error) {
|
|
updateStatusError = error.message;
|
|
}
|
|
try {
|
|
snapshots = listSnapshots();
|
|
} catch (error) {
|
|
updateStatusError = [updateStatusError, `Snapshots could not be loaded: ${error.message}`]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
try {
|
|
recoveryStatus = safeModeStatus();
|
|
} catch (error) {
|
|
updateStatusError = [updateStatusError, `Recovery status could not be loaded: ${error.message}`]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
res.render("admin-updates", {
|
|
title: "Updates",
|
|
snapshots,
|
|
updateStatus,
|
|
updateStatusError,
|
|
recoveryStatus
|
|
});
|
|
});
|
|
|
|
app.post("/admin/updates/core/check", requireRole("admin"), (req, res) => {
|
|
try {
|
|
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
|
sendUpdateResult(req, res, {
|
|
status,
|
|
message: status.core.update_available
|
|
? `Safe core target ${status.core.safe_target_version} is available.`
|
|
: status.core.blocked
|
|
? status.core.blocked_reason
|
|
: "No core updates found."
|
|
});
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
}
|
|
});
|
|
|
|
app.post("/admin/updates/core/apply", requireRole("admin"), async (req, res) => {
|
|
try {
|
|
const result = await applyCoreUpdate({
|
|
source: updateSourceFrom(req),
|
|
publish: publishWebEvent
|
|
});
|
|
sendUpdateResult(req, res, {
|
|
...result,
|
|
refresh_after_ms: 5000,
|
|
message: "Core update applied. Lumi will restart after the confirmation notice."
|
|
});
|
|
scheduleRestartAfterNotice();
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
}
|
|
});
|
|
|
|
app.post("/admin/updates/core/revert", requireRole("admin"), async (req, res) => {
|
|
try {
|
|
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
|
const snapshotId = req.body.snapshot_id || status.core.snapshot.latest_snapshot_id;
|
|
if (!snapshotId) throw new Error("No core snapshot is available to revert.");
|
|
const result = await revertCoreSnapshot(snapshotId, {
|
|
currentVersion: status.core.current_version,
|
|
publish: publishWebEvent
|
|
});
|
|
sendUpdateResult(req, res, {
|
|
...result,
|
|
refresh_after_ms: 5000,
|
|
message: "Core snapshot reverted. Lumi will restart after the confirmation notice."
|
|
});
|
|
scheduleRestartAfterNotice();
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
}
|
|
});
|
|
|
|
app.post(
|
|
"/admin/updates/bot",
|
|
requireRole("admin"),
|
|
uploadSingle("update_zip"),
|
|
async (req, res) => {
|
|
if (req.uploadError) {
|
|
setFlash(req, "error", req.uploadError);
|
|
return res.redirect("/admin/updates");
|
|
}
|
|
if (!req.file) {
|
|
setFlash(req, "error", "Upload a ZIP archive.");
|
|
return res.redirect("/admin/updates");
|
|
}
|
|
try {
|
|
const patchMode = req.body.patch_mode === "1";
|
|
await applyBotUpdate(req.file.path, {
|
|
mode: patchMode ? "patch" : "full"
|
|
});
|
|
setFlash(
|
|
req,
|
|
"success",
|
|
patchMode ? "Patch applied. Restarting..." : "Update applied. Restarting..."
|
|
);
|
|
res.redirect("/admin/updates");
|
|
requestRestart();
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin/updates");
|
|
} finally {
|
|
try {
|
|
fs.rmSync(req.file.path, { force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post(
|
|
"/admin/updates/core/zip",
|
|
requireRole("admin"),
|
|
uploadSingle("update_zip"),
|
|
async (req, res) => {
|
|
if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError));
|
|
if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive."));
|
|
try {
|
|
const patchMode = req.body.patch_mode === "1";
|
|
const snapshot = await applyBotUpdate(req.file.path, {
|
|
mode: patchMode ? "patch" : "full",
|
|
metadata: {
|
|
update_method: patchMode ? "zip_patch" : "zip",
|
|
source_branch: "manual_zip",
|
|
rollback_safe: req.body.rollback_safe === "1"
|
|
}
|
|
});
|
|
sendUpdateResult(req, res, {
|
|
snapshot,
|
|
restart_required: true,
|
|
refresh_after_ms: 5000,
|
|
message: patchMode ? "Core patch ZIP applied. Restarting..." : "Core ZIP update applied. Restarting..."
|
|
});
|
|
scheduleRestartAfterNotice();
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
} finally {
|
|
try { fs.rmSync(req.file?.path, { force: true }); } catch {}
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post(
|
|
"/admin/updates/plugin",
|
|
requireRole("admin"),
|
|
uploadSingle("plugin_zip"),
|
|
async (req, res) => {
|
|
if (req.uploadError) {
|
|
setFlash(req, "error", req.uploadError);
|
|
return res.redirect("/admin/updates");
|
|
}
|
|
if (!req.file) {
|
|
setFlash(req, "error", "Upload a ZIP archive.");
|
|
return res.redirect("/admin/updates");
|
|
}
|
|
try {
|
|
await applyPluginUpdate(req.file.path);
|
|
setFlash(req, "success", "Plugin update applied. Restarting...");
|
|
res.redirect("/admin/updates");
|
|
requestRestart();
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin/updates");
|
|
} finally {
|
|
try {
|
|
fs.rmSync(req.file.path, { force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post("/admin/updates/plugins/:id/check", requireRole("admin"), (req, res) => {
|
|
try {
|
|
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
|
const plugin = status.plugins.find((item) => item.id === req.params.id);
|
|
if (!plugin) throw new Error("Plugin not found.");
|
|
sendUpdateResult(req, res, {
|
|
plugin,
|
|
message: plugin.update_available
|
|
? `Safe plugin target ${plugin.safe_target_version} is available.`
|
|
: plugin.blocked
|
|
? plugin.blocked_reason
|
|
: "No plugin updates found."
|
|
});
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
}
|
|
});
|
|
|
|
app.post("/admin/updates/plugins/:id/apply", requireRole("admin"), async (req, res) => {
|
|
try {
|
|
const result = await applyPluginUpdateFromRepo(req.params.id, {
|
|
source: updateSourceFrom(req),
|
|
publish: publishWebEvent
|
|
});
|
|
sendUpdateResult(req, res, {
|
|
...result,
|
|
message: "Plugin update applied."
|
|
});
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
}
|
|
});
|
|
|
|
app.post("/admin/updates/plugins/:id/revert", requireRole("admin"), async (req, res) => {
|
|
try {
|
|
const status = getUpdateStatus({ source: updateSourceFrom(req) });
|
|
const plugin = status.plugins.find((item) => item.id === req.params.id);
|
|
if (!plugin) throw new Error("Plugin not found.");
|
|
const snapshotId = req.body.snapshot_id || plugin.snapshot.latest_snapshot_id;
|
|
if (!snapshotId) throw new Error("No plugin snapshot is available to revert.");
|
|
const result = await revertPluginSnapshot(req.params.id, snapshotId, {
|
|
currentVersion: plugin.current_version,
|
|
publish: publishWebEvent
|
|
});
|
|
sendUpdateResult(req, res, {
|
|
...result,
|
|
message: "Plugin snapshot reverted. Restarting..."
|
|
});
|
|
scheduleRestartAfterNotice();
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
}
|
|
});
|
|
|
|
app.post("/admin/updates/plugins/:id/disable", requireRole("admin"), (req, res) => {
|
|
try {
|
|
const result = disablePluginForRecovery(req.params.id, publishWebEvent);
|
|
sendUpdateResult(req, res, { ...result, message: "Plugin disabled for recovery." });
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
}
|
|
});
|
|
|
|
app.post(
|
|
"/admin/updates/plugins/:id/zip",
|
|
requireRole("admin"),
|
|
uploadSingle("plugin_zip"),
|
|
async (req, res) => {
|
|
if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError));
|
|
if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive."));
|
|
try {
|
|
const snapshot = await applyPluginUpdate(req.file.path, {
|
|
expectedPluginId: req.params.id,
|
|
metadata: {
|
|
target_id: req.params.id,
|
|
source_branch: "manual_zip",
|
|
rollback_safe: req.body.rollback_safe === "1"
|
|
}
|
|
});
|
|
sendUpdateResult(req, res, {
|
|
snapshot,
|
|
message: "Plugin ZIP update applied."
|
|
});
|
|
} catch (error) {
|
|
sendUpdateError(req, res, error);
|
|
} finally {
|
|
try { fs.rmSync(req.file?.path, { force: true }); } catch {}
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post("/admin/update", requireRole("admin"), async (req, res) => {
|
|
try {
|
|
const remote = getSetting("git_remote", "origin");
|
|
const branch = getSetting("git_branch", "main");
|
|
await pullUpdates(remote, branch);
|
|
setFlash(req, "success", "Update applied. Restarting...");
|
|
res.redirect("/admin");
|
|
requestRestart();
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/check-update", requireRole("admin"), (req, res) => {
|
|
try {
|
|
const remote = getSetting("git_remote", "origin");
|
|
const branch = getSetting("git_branch", "main");
|
|
const hasUpdate = checkForUpdates(remote, branch);
|
|
setFlash(
|
|
req,
|
|
"info",
|
|
hasUpdate ? "Updates are available." : "No updates found."
|
|
);
|
|
res.redirect("/admin");
|
|
} catch (error) {
|
|
setFlash(req, "error", error.message);
|
|
res.redirect("/admin");
|
|
}
|
|
});
|
|
|
|
app.post("/admin/restart", requireRole("admin"), (req, res) => {
|
|
setFlash(req, "success", "Restarting...");
|
|
res.redirect("/admin");
|
|
requestRestart();
|
|
});
|
|
|
|
app.use((err, req, res, next) => {
|
|
if (res.headersSent) {
|
|
return next(err);
|
|
}
|
|
const message = err?.message || "";
|
|
const isViewMissing = message.includes("Failed to lookup view");
|
|
log("error", "Unhandled error", {
|
|
path: req.path,
|
|
method: req.method,
|
|
userId: req.session.user?.id || null,
|
|
message,
|
|
stack: err?.stack || ""
|
|
});
|
|
if (isViewMissing) {
|
|
res.locals.softError = "Some content could not be loaded.";
|
|
return res.status(200).render("missing-view", {
|
|
title: "Content missing",
|
|
resource: "A page component failed to load. Please try again."
|
|
});
|
|
}
|
|
const status = err?.status || 500;
|
|
res.status(status).render("error", {
|
|
title: "Something went wrong",
|
|
message: "An unexpected error occurred. Please try again."
|
|
});
|
|
});
|
|
|
|
if (typeof loadPlugins === "function") {
|
|
loadPlugins(app, web, webhooks);
|
|
}
|
|
|
|
return app;
|
|
}
|
|
|
|
const NAV_SECTION_ICONS = ["home", "spark", "shield", "gear", "blocks"];
|
|
const DEFAULT_NAV_SECTIONS = [
|
|
{ id: "overview", label: "Overview", icon: "home" },
|
|
{ id: "community", label: "Community", icon: "spark" },
|
|
{ id: "moderation", label: "Mod", icon: "shield" },
|
|
{ id: "admin", label: "Admin", icon: "gear" },
|
|
{ id: "plugins", label: "Plugins", icon: "blocks" }
|
|
];
|
|
|
|
function collectNavItems(user, pluginNav, currentPath) {
|
|
const iconMap = getSetting("nav_item_icons", {}) || {};
|
|
const base = [
|
|
{ label: "Home", path: "/", role: "public", section: "overview" },
|
|
{ label: "Commands", path: "/commands", role: "public", section: "community" },
|
|
{
|
|
label: "Leaderboards",
|
|
path: "/leaderboards",
|
|
role: "public",
|
|
section: "community"
|
|
},
|
|
{
|
|
label: "Stats",
|
|
path: "/stats",
|
|
role: "public",
|
|
authRequired: true,
|
|
section: "community"
|
|
},
|
|
{
|
|
label: "Profile",
|
|
path: "/profile",
|
|
role: "public",
|
|
authRequired: true,
|
|
section: "community"
|
|
},
|
|
{
|
|
label: "Feedback",
|
|
path: "/feedback",
|
|
role: "public",
|
|
authRequired: true,
|
|
section: "community"
|
|
},
|
|
{ label: "Mods List", path: "/moderator", role: "mod", section: "moderation" },
|
|
{ label: "Admin", path: "/admin", role: "admin", section: "admin" },
|
|
{
|
|
label: "Settings",
|
|
path: "/admin/settings",
|
|
role: "admin",
|
|
section: "admin"
|
|
},
|
|
{
|
|
label: "Navigation",
|
|
path: "/admin/navigation",
|
|
role: "admin",
|
|
section: "admin"
|
|
},
|
|
{
|
|
label: "Theming",
|
|
path: "/admin/theming",
|
|
role: "admin",
|
|
section: "admin"
|
|
},
|
|
{
|
|
label: "Privileges",
|
|
path: "/admin/privileges",
|
|
role: "admin",
|
|
section: "admin"
|
|
},
|
|
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
|
|
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
|
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
|
{
|
|
label: "Custom commands",
|
|
path: "/admin/commands",
|
|
role: "mod",
|
|
section: "moderation"
|
|
},
|
|
{ label: "Pages", path: "/admin/pages", role: "admin", section: "admin" },
|
|
{ label: "Users", path: "/admin/users", role: "mod", section: "moderation" },
|
|
{ label: "Plugins", path: "/admin/plugins", role: "admin", section: "admin" }
|
|
];
|
|
const pages = db
|
|
.prepare(
|
|
"SELECT slug, title, nav_label, role FROM custom_pages WHERE show_in_nav = 1 AND enabled = 1 ORDER BY created_at ASC"
|
|
)
|
|
.all()
|
|
.map((page) => ({
|
|
label: page.nav_label || page.title,
|
|
path: `/pages/${page.slug}`,
|
|
role: page.role,
|
|
section: "community"
|
|
}));
|
|
const pluginItems = (pluginNav || []).map((item) => ({
|
|
...item,
|
|
section: item.section || "plugins"
|
|
}));
|
|
const allItems = [...base, ...pages, ...pluginItems].filter((item) => {
|
|
if (item.authRequired && !user) {
|
|
return false;
|
|
}
|
|
if (typeof item.canAccess === "function") {
|
|
try {
|
|
if (!item.canAccess(user)) return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return hasAccess(user, item.role);
|
|
});
|
|
return allItems.map((item) => {
|
|
const navId = buildNavItemId(item);
|
|
const customIcon = iconMap[navId] || null;
|
|
const defaultIcon = getDefaultNavIcon(item);
|
|
const icon = customIcon
|
|
? `/assets/nav-icons/${customIcon}`
|
|
: defaultIcon
|
|
? `/icons/nav/${defaultIcon}.svg`
|
|
: null;
|
|
return {
|
|
...item,
|
|
navId,
|
|
icon,
|
|
active: isActivePath(item.path, currentPath)
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildNavSections(user, pluginNav, currentPath) {
|
|
const items = collectNavItems(user, pluginNav, currentPath);
|
|
const navStructure = normalizeNavStructure(getSetting("nav_structure", null));
|
|
if (navStructure?.enabled && navStructure.sections.length) {
|
|
return buildNavSectionsFromStructure(items, navStructure);
|
|
}
|
|
return buildDefaultNavSections(items);
|
|
}
|
|
|
|
function buildDefaultNavSections(items) {
|
|
const sections = DEFAULT_NAV_SECTIONS.map((section) => ({
|
|
...section,
|
|
items: []
|
|
}));
|
|
const sectionMap = new Map(sections.map((section) => [section.id, section]));
|
|
|
|
for (const item of items) {
|
|
const section = sectionMap.get(item.section);
|
|
if (section) {
|
|
section.items.push(item);
|
|
}
|
|
}
|
|
|
|
return sections
|
|
.filter((section) => section.items.length)
|
|
.map((section) => ({
|
|
...section,
|
|
items: section.items.slice().sort((a, b) => a.label.localeCompare(b.label)),
|
|
open: section.items.some((item) => item.active)
|
|
}));
|
|
}
|
|
|
|
function buildNavSectionsFromStructure(items, structure) {
|
|
const itemMap = new Map(items.map((item) => [item.navId, item]));
|
|
const used = new Set();
|
|
const sections = [];
|
|
|
|
const pushSection = (section, sectionItems) => {
|
|
if (!sectionItems.length) {
|
|
return;
|
|
}
|
|
sections.push({
|
|
id: section.id,
|
|
label: section.label || toTitleCase(section.id) || section.id,
|
|
icon: normalizeNavSectionIcon(section.icon) || "blocks",
|
|
items: sectionItems,
|
|
open: sectionItems.some((item) => item.active)
|
|
});
|
|
};
|
|
|
|
for (const section of structure.sections) {
|
|
if (!section?.id) {
|
|
continue;
|
|
}
|
|
const sectionItems = [];
|
|
for (const rawId of section.items || []) {
|
|
const navId = (rawId || "").toString().trim();
|
|
if (!navId || used.has(navId)) {
|
|
continue;
|
|
}
|
|
const item = itemMap.get(navId);
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
sectionItems.push(item);
|
|
used.add(navId);
|
|
}
|
|
pushSection(section, sectionItems);
|
|
}
|
|
|
|
if (structure.includeUnassigned !== false) {
|
|
const unassigned = items.filter((item) => !used.has(item.navId));
|
|
if (unassigned.length) {
|
|
pushSection(
|
|
{
|
|
id: structure.unassignedId || "other",
|
|
label: structure.unassignedLabel || "Other",
|
|
icon: structure.unassignedIcon || "blocks"
|
|
},
|
|
unassigned
|
|
);
|
|
}
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
function buildNavIconItems(user, pluginNav, currentPath) {
|
|
return collectNavItems(user, pluginNav, currentPath).map((item) => ({
|
|
id: item.navId,
|
|
label: item.label,
|
|
path: item.path,
|
|
icon: item.icon
|
|
}));
|
|
}
|
|
|
|
function buildDefaultNavStructure(items) {
|
|
const sections = DEFAULT_NAV_SECTIONS.map((section) => ({
|
|
id: section.id,
|
|
label: section.label,
|
|
icon: section.icon,
|
|
items: []
|
|
}));
|
|
const sectionMap = new Map(sections.map((section) => [section.id, section]));
|
|
items.forEach((item) => {
|
|
const section = sectionMap.get(item.section);
|
|
if (section) {
|
|
section.items.push(item);
|
|
}
|
|
});
|
|
sections.forEach((section) => {
|
|
section.items = section.items
|
|
.slice()
|
|
.sort((a, b) => a.label.localeCompare(b.label))
|
|
.map((item) => item.navId);
|
|
});
|
|
return {
|
|
enabled: false,
|
|
includeUnassigned: true,
|
|
sections
|
|
};
|
|
}
|
|
|
|
function normalizeNavStructure(raw) {
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
let value = raw;
|
|
if (typeof value === "string") {
|
|
try {
|
|
value = JSON.parse(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return null;
|
|
}
|
|
const sections = Array.isArray(value.sections)
|
|
? value.sections.map(normalizeNavSection).filter(Boolean)
|
|
: [];
|
|
return {
|
|
enabled: Boolean(value.enabled),
|
|
includeUnassigned: value.includeUnassigned !== false,
|
|
unassignedLabel:
|
|
typeof value.unassignedLabel === "string" && value.unassignedLabel.trim()
|
|
? value.unassignedLabel.trim()
|
|
: null,
|
|
unassignedIcon: normalizeNavSectionIcon(value.unassignedIcon),
|
|
unassignedId:
|
|
typeof value.unassignedId === "string" && value.unassignedId.trim()
|
|
? value.unassignedId.trim()
|
|
: null,
|
|
sections
|
|
};
|
|
}
|
|
|
|
function normalizeNavSection(section, index) {
|
|
if (!section || typeof section !== "object") {
|
|
return null;
|
|
}
|
|
const id = (section.id || section.label || `section-${index + 1}`)
|
|
.toString()
|
|
.trim();
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
const label = (section.label || toTitleCase(id) || id).toString().trim();
|
|
const icon = normalizeNavSectionIcon(section.icon) || "blocks";
|
|
const items = Array.isArray(section.items)
|
|
? section.items.map((item) => item.toString().trim()).filter(Boolean)
|
|
: [];
|
|
return { id, label, icon, items };
|
|
}
|
|
|
|
function normalizeNavSectionIcon(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const normalized = value.toString().trim().toLowerCase();
|
|
return NAV_SECTION_ICONS.includes(normalized) ? normalized : null;
|
|
}
|
|
|
|
function buildNavItemId(item) {
|
|
const base = (item.path || item.label || "item").toString().toLowerCase();
|
|
return base.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
}
|
|
|
|
function getDefaultNavIcon(item) {
|
|
const pathName = (item.path || "").toLowerCase();
|
|
if (pathName === "/") return "home";
|
|
if (pathName === "/commands") return "commands";
|
|
if (pathName === "/leaderboards") return "leaderboards";
|
|
if (pathName === "/stats") return "stats";
|
|
if (pathName === "/profile") return "profile";
|
|
if (pathName === "/moderator") return "users";
|
|
if (pathName.startsWith("/pages/")) return "pages";
|
|
if (pathName === "/admin") return "admin";
|
|
if (pathName === "/admin/settings") return "settings";
|
|
if (pathName === "/admin/navigation") return "settings";
|
|
if (pathName === "/admin/theming") return "theming";
|
|
if (pathName === "/admin/privileges") return "privileges";
|
|
if (pathName === "/admin/logs") return "logs";
|
|
if (pathName === "/admin/updates") return "updates";
|
|
if (pathName === "/admin/commands") return "commands";
|
|
if (pathName === "/admin/pages") return "pages";
|
|
if (pathName === "/admin/users") return "users";
|
|
if (pathName === "/admin/plugins") return "plugins";
|
|
if (pathName === "/moderator") return "users";
|
|
if (pathName.startsWith("/plugins/moderation/tos-bans")) return "moderation";
|
|
if (pathName.startsWith("/plugins/moderation")) return "moderation";
|
|
if (pathName.startsWith("/plugins")) return "plugins";
|
|
if (pathName.startsWith("/moderator")) return "moderation";
|
|
return "pages";
|
|
}
|
|
|
|
function isActivePath(itemPath, currentPath) {
|
|
if (!itemPath || !currentPath) {
|
|
return false;
|
|
}
|
|
if (itemPath === "/") {
|
|
return currentPath === "/";
|
|
}
|
|
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
|
|
}
|
|
|
|
function unavailableAssistantPanel(panel, reasonCode) {
|
|
return {
|
|
available: false,
|
|
panel_id: panel.id,
|
|
status: "offline",
|
|
reason_code: reasonCode
|
|
};
|
|
}
|
|
|
|
function renderAssistantPanel(app, panel, user) {
|
|
const requiredLocals = Array.isArray(panel.requiredLocals) ? panel.requiredLocals : [];
|
|
const locals = { ...(panel.locals || {}), user };
|
|
const missingLocals = requiredLocals.filter((key) => locals[key] === undefined || locals[key] === null);
|
|
if (!fs.existsSync(panel.view)) {
|
|
return Promise.reject(new Error(`Assistant panel template does not exist: ${panel.view}`));
|
|
}
|
|
try {
|
|
fs.accessSync(panel.view, fs.constants.R_OK);
|
|
} catch {
|
|
return Promise.reject(new Error(`Assistant panel template is not readable: ${panel.view}`));
|
|
}
|
|
if (missingLocals.length) {
|
|
const error = new Error(`Assistant panel is missing required locals: ${missingLocals.join(", ")}`);
|
|
error.missingLocals = missingLocals;
|
|
return Promise.reject(error);
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
app.render(
|
|
panel.view,
|
|
locals,
|
|
(error, html) => {
|
|
if (error) return reject(error);
|
|
const content = String(html || "");
|
|
if (!content.trim()) return reject(new Error(`Assistant panel ${panel.id} rendered empty HTML.`));
|
|
if (!content.includes(`data-assistant-panel-id="${panel.id}"`)) {
|
|
return reject(new Error(`Assistant panel ${panel.id} HTML is missing its panel marker.`));
|
|
}
|
|
return resolve({ html: content });
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
createWebServer
|
|
};
|