Merge pull request #13 from Fretuks/revert-12-add-web-dashboard-for-discord-bot-configuration

Revert "Add dashboard UI and secure API endpoints"
This commit is contained in:
2026-01-27 15:30:20 +01:00
committed by GitHub
12 changed files with 181 additions and 1193 deletions
-15
View File
@@ -1,15 +0,0 @@
# Backend configuration
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback
DISCORD_BOT_TOKEN=
MONGODB_URI=mongodb://localhost:27017
SESSION_SECRET=change-me
DASHBOARD_CLIENT_URL=http://localhost:5173
DASHBOARD_PORT=3000
DASHBOARD_SESSION_TTL_MS=86400000
NODE_ENV=development
# Frontend configuration (Vite)
VITE_API_BASE_URL=http://localhost:3000
VITE_BOT_INVITE_URL=
+67 -52
View File
@@ -1,65 +1,80 @@
# Discord Bot
Im going to make a To-do List for the features i want to implement for my Bot.
## Dashboard setup
### **To-Do List for My Discord Bot**
This repository includes a lightweight dashboard for configuring permissions and warnings without editing JSON. The dashboard uses Discord OAuth2 and the existing MongoDB collections.
#### **Core Features**
(I will try to make these tools optional, because alot of bots already feature them)
### Environment variables
1. **Basic Commands**
- [x] Add a `/help` command to display a list of available commands.
- [x] Implement a `/ping` command to check bot responsiveness.
- [x] Add an `/about` command to describe the bot's purpose.
Copy `.env.example` and fill in the values:
2. **Role Management**
- [ ] Set up a self-assignable role system (e.g., using reaction roles).
- [x] Allow admins to assign/remove roles with commands.
```bash
cp .env.example .env
```
Required backend variables:
- `DISCORD_CLIENT_ID`
- `DISCORD_CLIENT_SECRET`
- `DISCORD_REDIRECT_URI` (example: `http://localhost:3000/auth/discord/callback`)
- `DISCORD_BOT_TOKEN`
- `MONGODB_URI`
- `SESSION_SECRET`
- `DASHBOARD_CLIENT_URL` (example: `http://localhost:5173`)
Frontend variables (Vite):
- `VITE_API_BASE_URL` (example: `http://localhost:3000`)
- `VITE_BOT_INVITE_URL`
> Make sure the redirect URI is added in the Discord Developer Portal OAuth2 settings.
### Running the backend
```bash
node dashboardServer.js
```
The API will run on `DASHBOARD_PORT` (default `3000`).
### Running the dashboard frontend
```bash
cd dashboard
npm install
npm run dev
```
The frontend will run on `http://localhost:5173` by default.
### API health check
```bash
curl http://localhost:3000/health
```
3. **Moderation Tools**
- [x] Add commands to mute, kick, and temp_ban users.
- [ ] Create an auto-moderation feature to delete spam or inappropriate content.
- [x] Implement a warning system with a strike counter.
---
## Existing feature notes
#### **Unique Features**
1. **Currency System**
- [x] Design a virtual currency system with basic commands:
- [x] `/balance` to check currency balance.
- [x] `/earn` to earn daily or periodic rewards.
- [x] `/transfer @user [amount]` to send currency to another user.
- [ ] Link the currency system to role management:
- [ ] Allow users to purchase roles using the currency with a command (e.g., `!buyrole [role name]`).
### Server-Specific Permission Configuration
2. **Datetime Converter**
(Still in Progress, maybe i'll add visual Menu)
- [x] Create a command to convert timezones:
- [x] `/timestamp [date]` to generate Discord timestamps.
- [x] Support multiple Discord timestamp formats.
- [ ] Include a command to display server-specific or user-specific timezones.
Moderation commands support per-server permission overrides stored in MongoDB. Each guild can define which users or roles are allowed to run specific commands, plus optional admin overrides that apply to all moderation commands.
3. **Minigames**
- [ ] Implement a Quiz Game:
- [x] Add a `/quiz` command to start the game.
- [x] Provide multiple-choice or true/false questions.
- [x] Award virtual currency for correct answers.
- [ ] Add a simple number-guessing game (e.g., `!guess [number]`).
- [ ] Create a leaderboard for game winners.
---
#### **Utility Tools**
1. **Search and Lookups**
- [x] Add a `/wiki [topic]` command to fetch summaries from Wikipedia.
- [x] Include a `/weather [location]` command for weather updates.
---
#### **Advanced and Fun Features (very optional)**
1. **AI Chat Integration**
- [ ] Integrate GPT for conversational AI:
- [ ] Respond to mentions with intelligent replies.
- [ ] Allow users to ask general questions with `!ask [question]`.
---
#### **Deployment (will happen if im completly satisfied with the bot)**
1. **Hosting**
- [ ] Set up local hosting for development.
- [ ] Deploy the bot on a cloud platform (e.g., Heroku, AWS, or Railway).
2. **Monitoring**
- [ ] Add logging to track errors and usage statistics.
- [ ] Implement uptime monitoring to ensure the bot stays online.
---
### **Server-Specific Permission Configuration**
Moderation commands support per-server permission overrides stored in MongoDB. Each guild can define which users or roles are allowed to run specific commands, plus optional admin overrides that apply to all moderation commands. This is intended for future web-based configuration.
Example document stored in the `discord.guild_permissions` collection:
```json
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discord Bot Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
{
"name": "discord-bot-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.7"
}
}
-576
View File
@@ -1,576 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const BOT_INVITE_URL = import.meta.env.VITE_BOT_INVITE_URL || '';
const apiFetch = async (path, options = {}) => {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
const contentType = response.headers.get('content-type');
const data = contentType && contentType.includes('application/json')
? await response.json()
: null;
if (!response.ok) {
const error = new Error(data?.error || 'Request failed');
error.details = data?.details;
throw error;
}
return data;
};
const usePath = () => {
const [path, setPath] = useState(window.location.pathname);
useEffect(() => {
const handler = () => setPath(window.location.pathname);
window.addEventListener('popstate', handler);
return () => window.removeEventListener('popstate', handler);
}, []);
const navigate = useCallback((nextPath) => {
window.history.pushState({}, '', nextPath);
setPath(nextPath);
}, []);
return { path, navigate };
};
const Toast = ({ message, type, onDismiss }) => {
if (!message) return null;
return (
<div className={`toast toast-${type}`}>
<span>{message}</span>
<button type="button" onClick={onDismiss} aria-label="Dismiss">
✕
</button>
</div>
);
};
const Landing = () => (
<div className="card">
<h1>Discord Bot Dashboard</h1>
<p>Manage bot permissions and warnings without editing raw JSON.</p>
<div className="button-row">
{BOT_INVITE_URL ? (
<a className="button" href={BOT_INVITE_URL} target="_blank" rel="noreferrer">
Add bot to server
</a>
) : (
<span className="muted">Set VITE_BOT_INVITE_URL to enable the invite link.</span>
)}
<a className="button primary" href={`${API_BASE_URL}/auth/discord`}>
Login with Discord
</a>
</div>
</div>
);
const GuildPicker = ({ onManage }) => {
const [guilds, setGuilds] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let ignore = false;
setLoading(true);
apiFetch('/api/guilds')
.then((data) => {
if (!ignore) {
setGuilds(data.guilds || []);
setError('');
}
})
.catch((err) => {
if (!ignore) {
setError(err.message);
}
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, []);
return (
<div>
<h1>Your Guilds</h1>
<p className="muted">Choose a server you manage to update permissions and warnings.</p>
{loading ? <p>Loading guilds…</p> : null}
{error ? <p className="error">{error}</p> : null}
<div className="guild-grid">
{guilds.map((guild) => (
<div key={guild.id} className="guild-card">
{guild.icon ? (
<img
src={`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`}
alt=""
/>
) : (
<div className="guild-placeholder" />
)}
<div className="guild-meta">
<h3>{guild.name}</h3>
<button className="button" type="button" onClick={() => onManage(guild.id)}>
Manage
</button>
</div>
</div>
))}
{!loading && guilds.length === 0 ? (
<p className="muted">No manageable guilds found for this account.</p>
) : null}
</div>
</div>
);
};
const AdminUserList = ({ value, onChange }) => {
const [input, setInput] = useState('');
const addUser = () => {
const trimmed = input.trim();
if (!trimmed) return;
if (!value.includes(trimmed)) {
onChange([...value, trimmed]);
}
setInput('');
};
return (
<div>
<div className="chip-row">
{value.map((userId) => (
<span key={userId} className="chip">
{userId}
<button type="button" onClick={() => onChange(value.filter((id) => id !== userId))}>
✕
</button>
</span>
))}
{value.length === 0 ? <span className="muted">No admin users yet.</span> : null}
</div>
<div className="input-row">
<input
type="text"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Add user ID"
/>
<button type="button" className="button" onClick={addUser}>
Add
</button>
</div>
</div>
);
};
const CommandPermissionEditor = ({ roles, value, onChange }) => {
const [newCommand, setNewCommand] = useState('');
const commandEntries = Object.entries(value || {});
const updateCommand = (commandName, nextData) => {
onChange({
...value,
[commandName]: {
allowedRoleIds: nextData.allowedRoleIds,
allowedUserIds: nextData.allowedUserIds,
},
});
};
const removeCommand = (commandName) => {
const updated = { ...value };
delete updated[commandName];
onChange(updated);
};
const addCommand = () => {
const trimmed = newCommand.trim();
if (!trimmed) return;
if (!value[trimmed]) {
onChange({
...value,
[trimmed]: { allowedRoleIds: [], allowedUserIds: [] },
});
}
setNewCommand('');
};
return (
<div className="command-editor">
<div className="input-row">
<input
type="text"
value={newCommand}
onChange={(event) => setNewCommand(event.target.value)}
placeholder="Add command name"
/>
<button type="button" className="button" onClick={addCommand}>
Add command
</button>
</div>
{commandEntries.length === 0 ? (
<p className="muted">No command overrides yet. Empty means commands are allowed for everyone.</p>
) : null}
{commandEntries.map(([commandName, entry]) => (
<div key={commandName} className="command-card">
<div className="command-header">
<h4>{commandName}</h4>
<button type="button" className="text-button" onClick={() => removeCommand(commandName)}>
Remove
</button>
</div>
<p className="muted">
If both lists are empty, this command remains available to everyone.
</p>
<div className="form-group">
<label>Allowed roles</label>
<div className="checkbox-grid">
{roles.map((role) => (
<label key={role.id} className="checkbox-item">
<input
type="checkbox"
checked={entry.allowedRoleIds?.includes(role.id)}
onChange={(event) => {
const next = event.target.checked
? [...entry.allowedRoleIds, role.id]
: entry.allowedRoleIds.filter((id) => id !== role.id);
updateCommand(commandName, {
...entry,
allowedRoleIds: next,
});
}}
/>
<span>{role.name}</span>
</label>
))}
{roles.length === 0 ? <span className="muted">No roles available.</span> : null}
</div>
</div>
<div className="form-group">
<label>Allowed user IDs</label>
<AdminUserList
value={entry.allowedUserIds || []}
onChange={(nextUsers) => updateCommand(commandName, {
...entry,
allowedUserIds: nextUsers,
})}
/>
</div>
</div>
))}
</div>
);
};
const PermissionsTab = ({ guildId, onToast }) => {
const [roles, setRoles] = useState([]);
const [permissions, setPermissions] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let ignore = false;
setLoading(true);
Promise.all([
apiFetch(`/api/guilds/${guildId}/roles`),
apiFetch(`/api/guilds/${guildId}/permissions`),
])
.then(([rolesData, permissionsData]) => {
if (ignore) return;
setRoles(rolesData.roles || []);
setPermissions(permissionsData.permissions || {});
setError('');
})
.catch((err) => {
if (!ignore) setError(err.message);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [guildId]);
const toggleRole = (roleId) => {
if (!permissions) return;
const adminRoleIds = permissions.adminRoleIds || [];
const next = adminRoleIds.includes(roleId)
? adminRoleIds.filter((id) => id !== roleId)
: [...adminRoleIds, roleId];
setPermissions({ ...permissions, adminRoleIds: next });
};
const savePermissions = async () => {
if (!permissions) return;
setSaving(true);
try {
const response = await apiFetch(`/api/guilds/${guildId}/permissions`, {
method: 'PATCH',
body: JSON.stringify(permissions),
});
setPermissions(response.permissions);
onToast('Permissions saved.', 'success');
} catch (err) {
onToast(err.message, 'error');
} finally {
setSaving(false);
}
};
if (loading) return <p>Loading permissions…</p>;
if (error) return <p className="error">{error}</p>;
if (!permissions) return <p className="error">Failed to load permissions.</p>;
return (
<div className="tab-content">
<div className="form-group">
<label>Admin roles</label>
<div className="checkbox-grid">
{roles.map((role) => (
<label key={role.id} className="checkbox-item">
<input
type="checkbox"
checked={permissions.adminRoleIds?.includes(role.id)}
onChange={() => toggleRole(role.id)}
/>
<span>{role.name}</span>
</label>
))}
{roles.length === 0 ? <span className="muted">No roles available.</span> : null}
</div>
</div>
<div className="form-group">
<label>Admin users</label>
<AdminUserList
value={permissions.adminUserIds || []}
onChange={(nextUsers) => setPermissions({ ...permissions, adminUserIds: nextUsers })}
/>
</div>
<div className="form-group">
<label>Per-command permissions</label>
<CommandPermissionEditor
roles={roles}
value={permissions.commandPermissions || {}}
onChange={(nextCommands) =>
setPermissions({ ...permissions, commandPermissions: nextCommands })
}
/>
</div>
<div className="button-row">
<button type="button" className="button primary" onClick={savePermissions} disabled={saving}>
{saving ? 'Saving…' : 'Save permissions'}
</button>
</div>
</div>
);
};
const WarningsTab = ({ guildId, onToast }) => {
const [userId, setUserId] = useState('');
const [warnings, setWarnings] = useState([]);
const [loading, setLoading] = useState(false);
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const fetchWarnings = async () => {
if (!userId.trim()) return;
setLoading(true);
try {
const data = await apiFetch(`/api/guilds/${guildId}/warnings/${userId.trim()}`);
setWarnings(data.warnings || []);
setError('');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const addWarning = async () => {
if (!userId.trim()) return;
try {
const data = await apiFetch(`/api/guilds/${guildId}/warnings/${userId.trim()}`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
setWarnings(data.warnings || []);
setReason('');
onToast('Warning added.', 'success');
} catch (err) {
onToast(err.message, 'error');
}
};
return (
<div className="tab-content">
<div className="form-group">
<label>User ID</label>
<div className="input-row">
<input
type="text"
value={userId}
onChange={(event) => setUserId(event.target.value)}
placeholder="Enter user ID"
/>
<button type="button" className="button" onClick={fetchWarnings} disabled={loading}>
{loading ? 'Loading…' : 'Load warnings'}
</button>
</div>
</div>
{error ? <p className="error">{error}</p> : null}
<div className="warnings-list">
{warnings.length === 0 ? (
<p className="muted">No warnings found for this user.</p>
) : (
warnings.map((warning, index) => (
<div key={`${warning.createdAt}-${index}`} className="warning-card">
<p>{warning.reason}</p>
<div className="warning-meta">
<span>Moderator: {warning.moderatorId || 'Unknown'}</span>
<span>{warning.createdAt ? new Date(warning.createdAt).toLocaleString() : ''}</span>
</div>
</div>
))
)}
</div>
<div className="form-group">
<label>Add warning</label>
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={4}
placeholder="Reason for this warning"
/>
<button type="button" className="button primary" onClick={addWarning}>
Add warning
</button>
</div>
</div>
);
};
const GuildSettings = ({ guildId }) => {
const [activeTab, setActiveTab] = useState('permissions');
const [toast, setToast] = useState({ message: '', type: 'success' });
const dismissToast = () => setToast({ message: '', type: 'success' });
return (
<div>
<div className="tabs">
<button
type="button"
className={activeTab === 'permissions' ? 'active' : ''}
onClick={() => setActiveTab('permissions')}
>
Permissions
</button>
<button
type="button"
className={activeTab === 'warnings' ? 'active' : ''}
onClick={() => setActiveTab('warnings')}
>
Warnings
</button>
</div>
{activeTab === 'permissions' ? (
<PermissionsTab
guildId={guildId}
onToast={(message, type) => setToast({ message, type })}
/>
) : (
<WarningsTab
guildId={guildId}
onToast={(message, type) => setToast({ message, type })}
/>
)}
<Toast message={toast.message} type={toast.type} onDismiss={dismissToast} />
</div>
);
};
const App = () => {
const { path, navigate } = usePath();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
apiFetch('/api/me')
.then((data) => {
setUser(data.user);
setError('');
})
.catch(() => {
setUser(null);
})
.finally(() => setLoading(false));
}, []);
const guildId = useMemo(() => {
const match = path.match(/^\/guilds\/(.+)$/);
return match ? match[1] : null;
}, [path]);
if (loading) {
return <div className="container"><p>Loading dashboard…</p></div>;
}
if (!user) {
return (
<div className="container">
<Landing />
</div>
);
}
return (
<div className="container">
<header className="header">
<div>
<h2>Welcome, {user.username}</h2>
<p className="muted">{user.id}</p>
</div>
<button
type="button"
className="button ghost"
onClick={() => apiFetch('/auth/logout', { method: 'POST' }).then(() => window.location.reload())}
>
Log out
</button>
</header>
{error ? <p className="error">{error}</p> : null}
{guildId ? (
<div>
<button type="button" className="text-button" onClick={() => navigate('/')}>← Back to guilds</button>
<GuildSettings guildId={guildId} />
</div>
) : (
<GuildPicker onManage={(id) => navigate(`/guilds/${id}`)} />
)}
</div>
);
};
export default App;
-11
View File
@@ -1,11 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
const rootElement = document.getElementById('root');
createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
-265
View File
@@ -1,265 +0,0 @@
:root {
font-family: 'Inter', system-ui, sans-serif;
color: #e6e6f0;
background-color: #0f1117;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #0f1117;
}
a {
color: inherit;
}
.container {
max-width: 1080px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #24283b;
}
.card {
background: #171b26;
padding: 32px;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
}
.button {
background: #2f3348;
border: none;
color: #fff;
padding: 10px 18px;
border-radius: 10px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.button.primary {
background: #5865f2;
}
.button.ghost {
background: transparent;
border: 1px solid #2f3348;
}
.text-button {
background: none;
border: none;
color: #8b93ff;
cursor: pointer;
padding: 0;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
.guild-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
margin-top: 20px;
}
.guild-card {
background: #171b26;
padding: 16px;
border-radius: 12px;
display: flex;
gap: 12px;
align-items: center;
}
.guild-card img,
.guild-placeholder {
width: 56px;
height: 56px;
border-radius: 16px;
object-fit: cover;
background: #2f3348;
}
.guild-meta h3 {
margin: 0 0 8px;
}
.muted {
color: #9aa0b4;
font-size: 0.95rem;
}
.error {
color: #ff8a8a;
}
.form-group {
margin-bottom: 24px;
}
.input-row {
display: flex;
gap: 12px;
margin-top: 8px;
}
input,
textarea {
width: 100%;
border-radius: 10px;
border: 1px solid #2f3348;
background: #0f1117;
color: #e6e6f0;
padding: 10px 12px;
}
textarea {
resize: vertical;
}
.checkbox-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
margin-top: 8px;
}
.checkbox-item {
display: flex;
gap: 8px;
align-items: center;
background: #0f1117;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid #2f3348;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.chip {
background: #2f3348;
padding: 6px 10px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chip button {
border: none;
background: none;
color: inherit;
cursor: pointer;
}
.command-card {
background: #171b26;
border-radius: 12px;
padding: 16px;
margin-top: 16px;
}
.command-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.tabs {
display: flex;
gap: 12px;
margin: 20px 0;
}
.tabs button {
background: #2f3348;
border: none;
color: #fff;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
}
.tabs button.active {
background: #5865f2;
}
.warning-card {
background: #171b26;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 12px;
}
.warning-meta {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: #9aa0b4;
margin-top: 6px;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
background: #2f3348;
padding: 14px 18px;
border-radius: 12px;
display: flex;
gap: 12px;
align-items: center;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
}
.toast-success {
border-left: 4px solid #4bd37b;
}
.toast-error {
border-left: 4px solid #ff8a8a;
}
.toast button {
background: none;
border: none;
color: inherit;
cursor: pointer;
}
@media (max-width: 720px) {
.input-row {
flex-direction: column;
}
.header {
flex-direction: column;
align-items: flex-start;
}
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});
+109 -167
View File
@@ -2,18 +2,9 @@ const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const axios = require('axios');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const express = require('express');
const session = require('express-session');
const MongoStore = require('connect-mongo');
const rateLimit = require('express-rate-limit');
const {addWarning, getWarnings} = require('./services/warnings');
const {
getGuildPermissions,
updateGuildPermissions,
sanitizePermissionPayload,
} = require('./services/permissions');
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const PERMISSION_BITS = {
ADMINISTRATOR: 0x8n,
@@ -47,8 +38,6 @@ const DISCORD_BOT_TOKEN =
process.env.DISCORD_BOT_TOKEN ||
config.discordBotToken ||
config.token;
const MONGODB_URI = process.env.MONGODB_URI || config.mongodb;
const SESSION_SECRET = process.env.SESSION_SECRET || config.sessionSecret;
const DASHBOARD_CLIENT_URL =
process.env.DASHBOARD_CLIENT_URL ||
config.dashboardClientUrl ||
@@ -67,14 +56,77 @@ console.log('[oauth config]', {
});
const DISCORD_API_BASE = 'https://discord.com/api';
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
const logInfo = (message, meta = {}) => {
console.log(JSON.stringify({level: 'info', message, ...meta}));
const sessionStore = new Map();
const parseCookies = (headerValue) => {
const cookies = {};
if (!headerValue) {
return cookies;
}
headerValue.split(';').forEach((cookie) => {
const [rawName, ...rest] = cookie.trim().split('=');
if (!rawName) {
return;
}
cookies[rawName] = decodeURIComponent(rest.join('='));
});
return cookies;
};
const logError = (message, meta = {}) => {
console.error(JSON.stringify({level: 'error', message, ...meta}));
const setCookie = (res, name, value, options = {}) => {
const directives = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge) {
directives.push(`Max-Age=${options.maxAge}`);
}
directives.push(`Path=${options.path || '/'}`);
if (options.httpOnly !== false) {
directives.push('HttpOnly');
}
if (options.sameSite) {
directives.push(`SameSite=${options.sameSite}`);
}
if (options.secure) {
directives.push('Secure');
}
res.append('Set-Cookie', directives.join('; '));
};
const clearCookie = (res, name) => {
setCookie(res, name, '', {maxAge: 0});
};
const createSession = (payload) => {
const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + (Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS);
sessionStore.set(sessionId, {
...payload,
expiresAt,
});
return {sessionId, expiresAt};
};
const getSession = (sessionId) => {
const session = sessionStore.get(sessionId);
if (!session) {
return null;
}
if (session.expiresAt <= Date.now()) {
sessionStore.delete(sessionId);
return null;
}
return session;
};
const destroySession = (sessionId) => {
sessionStore.delete(sessionId);
};
const ensureOAuthConfig = () => {
@@ -99,7 +151,7 @@ const buildAuthorizationUrl = (state) => {
prompt: 'consent',
});
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
};
const fetchDiscordAccessToken = async (code) => {
@@ -175,10 +227,19 @@ const canManageGuild = (guild) => {
};
const ensureSession = (req, res, next) => {
if (!req.session?.user || !req.session?.accessToken) {
const cookies = parseCookies(req.headers.cookie);
const sessionId = cookies.dashboard_session;
if (!sessionId) {
return res.status(401).json({error: 'Unauthorized'});
}
const session = getSession(sessionId);
if (!session) {
return res.status(401).json({error: 'Unauthorized'});
}
req.session = session;
req.sessionId = sessionId;
return next();
};
@@ -196,138 +257,40 @@ const ensureManageableGuild = async (req, res, next) => {
req.guild = guild;
return next();
} catch (error) {
logError('Failed to fetch guilds for permission check', {
message: error.message,
status: error.response?.status,
});
return res.status(502).json({error: 'Failed to fetch guilds'});
}
};
const buildCorsOrigins = () => {
if (!DASHBOARD_CLIENT_URL) {
return true;
}
const entries = DASHBOARD_CLIENT_URL.split(',').map((value) => value.trim()).filter(Boolean);
return entries.length ? entries : true;
};
const buildSessionStore = () => {
if (!MONGODB_URI) {
return null;
}
return MongoStore.create({
mongoUrl: MONGODB_URI,
collectionName: 'dashboard_sessions',
ttl: Math.floor(
(Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS) / 1000,
),
});
};
const validatePermissionPayload = (payload = {}) => {
const errors = [];
if (payload.adminRoleIds && !Array.isArray(payload.adminRoleIds)) {
errors.push('adminRoleIds must be an array of role IDs');
}
if (payload.adminUserIds && !Array.isArray(payload.adminUserIds)) {
errors.push('adminUserIds must be an array of user IDs');
}
if (payload.commandPermissions && typeof payload.commandPermissions !== 'object') {
errors.push('commandPermissions must be an object');
}
return errors;
};
const validateWarningReason = (reason) => {
if (typeof reason !== 'string') {
return 'Reason must be a string';
}
const trimmed = reason.trim();
if (!trimmed) {
return 'Reason is required';
}
if (trimmed.length > 500) {
return 'Reason must be 500 characters or fewer';
}
return null;
};
const createDashboardApp = () => {
const app = express();
const isProduction = process.env.NODE_ENV === 'production';
app.set('trust proxy', 1);
app.use(
cors({
origin: buildCorsOrigins(),
credentials: true,
}),
);
app.use(express.json());
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
if (!SESSION_SECRET) {
logError('Missing SESSION_SECRET configuration');
}
const sessionStore = buildSessionStore();
if (!sessionStore) {
logError('Missing MONGODB_URI configuration for session store');
}
app.use(
session({
secret: SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: isProduction,
maxAge: Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS,
},
store: sessionStore || undefined,
}),
);
const authLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
app.get('/health', (req, res) => {
return res.json({status: 'ok'});
});
app.get('/auth/discord', authLimiter, (req, res) => {
app.get('/auth/discord', (req, res) => {
if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
}
const state = crypto.randomBytes(16).toString('hex');
res.cookie('oauth_state', state, {
setCookie(res, 'oauth_state', state, {
httpOnly: true,
sameSite: 'lax',
secure: isProduction,
maxAge: 300 * 1000,
sameSite: 'Lax',
secure: req.secure,
maxAge: 300,
});
return res.redirect(buildAuthorizationUrl(state));
});
app.get('/auth/discord/callback', authLimiter, async (req, res) => {
app.get('/auth/discord/callback', async (req, res) => {
if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
}
const {code, state} = req.query;
if (!code || !state || req.cookies.oauth_state !== state) {
const cookies = parseCookies(req.headers.cookie);
if (!code || !state || cookies.oauth_state !== state) {
return res.status(400).json({error: 'Invalid OAuth state'});
}
@@ -335,15 +298,21 @@ const createDashboardApp = () => {
const tokenData = await fetchDiscordAccessToken(code);
const user = await fetchDiscordUser(tokenData.access_token);
const expiresAt = Date.now() + (tokenData.expires_in || 0) * 1000;
req.session.accessToken = tokenData.access_token;
req.session.refreshToken = tokenData.refresh_token;
req.session.tokenType = tokenData.token_type;
req.session.scope = tokenData.scope;
req.session.expiresAt = expiresAt;
req.session.user = user;
const {sessionId, expiresAt} = createSession({
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type,
scope: tokenData.scope,
user,
});
res.clearCookie('oauth_state');
setCookie(res, 'dashboard_session', sessionId, {
httpOnly: true,
sameSite: 'Lax',
secure: req.secure,
maxAge: Math.floor((expiresAt - Date.now()) / 1000),
});
clearCookie(res, 'oauth_state');
if (DASHBOARD_CLIENT_URL) {
return res.redirect(DASHBOARD_CLIENT_URL);
@@ -355,7 +324,7 @@ const createDashboardApp = () => {
return res.status(200).json({user});
} catch (error) {
logError('Discord OAuth failed', {
console.error('Discord OAuth failed', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
@@ -369,19 +338,10 @@ const createDashboardApp = () => {
});
app.post('/auth/logout', ensureSession, (req, res) => {
req.session.destroy((err) => {
if (err) {
logError('Failed to destroy session', {message: err.message});
return res.status(500).json({error: 'Failed to log out'});
}
res.clearCookie('connect.sid');
destroySession(req.sessionId);
clearCookie(res, 'dashboard_session');
return res.status(204).send();
});
});
app.get('/api/me', ensureSession, (req, res) => {
return res.json({user: req.session.user});
});
app.get('/api/guilds', ensureSession, async (req, res) => {
try {
@@ -389,10 +349,6 @@ const createDashboardApp = () => {
const manageableGuilds = guilds.filter(canManageGuild);
return res.json({guilds: manageableGuilds});
} catch (error) {
logError('Failed to fetch guilds', {
message: error.message,
status: error.response?.status,
});
return res.status(502).json({error: 'Failed to fetch guilds'});
}
});
@@ -406,7 +362,6 @@ const createDashboardApp = () => {
const permissions = await getGuildPermissions(req.params.guildId);
return res.json({permissions});
} catch (error) {
logError('Failed to fetch permissions', {message: error.message});
return res.status(500).json({error: 'Failed to fetch permissions'});
}
},
@@ -417,19 +372,13 @@ const createDashboardApp = () => {
ensureSession,
ensureManageableGuild,
async (req, res) => {
const validationErrors = validatePermissionPayload(req.body || {});
if (validationErrors.length) {
return res.status(400).json({error: 'Invalid permissions payload', details: validationErrors});
}
try {
const sanitizedPermissions = sanitizePermissionPayload(req.body || {});
const permissions = await updateGuildPermissions({
guildId: req.params.guildId,
permissions: sanitizedPermissions,
permissions: req.body || {},
});
return res.json({permissions});
} catch (error) {
logError('Failed to update permissions', {message: error.message});
return res.status(500).json({error: 'Failed to update permissions'});
}
},
@@ -444,10 +393,6 @@ const createDashboardApp = () => {
const roles = await fetchDiscordGuildRoles(req.params.guildId);
return res.json({roles});
} catch (error) {
logError('Failed to fetch roles', {
message: error.message,
status: error.response?.status,
});
return res.status(500).json({error: 'Failed to fetch roles'});
}
},
@@ -465,7 +410,6 @@ const createDashboardApp = () => {
});
return res.json({warnings});
} catch (error) {
logError('Failed to fetch warnings', {message: error.message});
return res.status(500).json({error: 'Failed to fetch warnings'});
}
},
@@ -477,20 +421,18 @@ const createDashboardApp = () => {
ensureManageableGuild,
async (req, res) => {
const {reason} = req.body || {};
const reasonError = validateWarningReason(reason);
if (reasonError) {
return res.status(400).json({error: reasonError});
if (!reason) {
return res.status(400).json({error: 'Reason is required'});
}
try {
const result = await addWarning({
guildId: req.params.guildId,
userId: req.params.userId,
moderatorId: req.session.user?.id,
reason: reason.trim(),
reason,
});
return res.status(201).json(result);
} catch (error) {
logError('Failed to add warning', {message: error.message});
return res.status(500).json({error: 'Failed to add warning'});
}
},
+2 -18
View File
@@ -1,27 +1,11 @@
const fs = require('node:fs');
const path = require('node:path');
const {MongoClient, ServerApiVersion} = require('mongodb');
const loadConfig = () => {
const configPath = path.join(__dirname, 'config.json');
if (fs.existsSync(configPath)) {
return require(configPath);
}
return {};
};
const config = loadConfig();
const MONGODB_URI = process.env.MONGODB_URI || config.mongodb;
const {mongodb} = require("./config.json");
let dbClient;
async function connectToDatabase() {
if (!dbClient) {
if (!MONGODB_URI) {
throw new Error('Missing MongoDB connection string.');
}
dbClient = new MongoClient(MONGODB_URI, {
dbClient = new MongoClient(mongodb, {
serverApi: {
version: ServerApiVersion.v1,
strict: true,
+1 -7
View File
@@ -3,8 +3,7 @@
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dashboard": "node dashboardServer.js"
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
@@ -13,13 +12,8 @@
"dependencies": {
"axios": "^1.7.7",
"better-sqlite3": "^11.3.0",
"connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"discord.js": "^14.17.3",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.1",
"moment": "^2.30.1",
"mongodb": "^6.12.0",
"openai": "^4.67.3",
+1 -41
View File
@@ -14,43 +14,6 @@ const normalizePermissionConfig = (config = {}) => ({
commandPermissions: config.commandPermissions ?? {},
});
const sanitizePermissionPayload = (config = {}) => {
const adminRoleIds = Array.isArray(config.adminRoleIds)
? config.adminRoleIds.filter((entry) => typeof entry === 'string' && entry.trim())
: [];
const adminUserIds = Array.isArray(config.adminUserIds)
? config.adminUserIds.filter((entry) => typeof entry === 'string' && entry.trim())
: [];
const commandPermissions = {};
if (config.commandPermissions && typeof config.commandPermissions === 'object') {
Object.entries(config.commandPermissions).forEach(([commandName, entry]) => {
if (!commandName || typeof commandName !== 'string') {
return;
}
if (!entry || typeof entry !== 'object') {
return;
}
const allowedRoleIds = Array.isArray(entry.allowedRoleIds)
? entry.allowedRoleIds.filter((roleId) => typeof roleId === 'string' && roleId.trim())
: [];
const allowedUserIds = Array.isArray(entry.allowedUserIds)
? entry.allowedUserIds.filter((userId) => typeof userId === 'string' && userId.trim())
: [];
commandPermissions[commandName] = {
allowedRoleIds,
allowedUserIds,
};
});
}
return {
adminRoleIds,
adminUserIds,
commandPermissions,
};
};
const memberHasRole = (member, roleIds) =>
roleIds.some((roleId) => member.roles?.cache?.has(roleId));
@@ -72,8 +35,7 @@ const updateGuildPermissions = async ({ guildId, permissions }) => {
const db = dbClient.db('discord');
const collection = db.collection('guild_permissions');
const sanitizedPermissions = sanitizePermissionPayload(permissions);
const normalizedPermissions = normalizePermissionConfig(sanitizedPermissions);
const normalizedPermissions = normalizePermissionConfig(permissions);
const payload = {
guildId,
...normalizedPermissions,
@@ -130,6 +92,4 @@ module.exports = {
getGuildPermissions,
updateGuildPermissions,
isCommandAllowed,
normalizePermissionConfig,
sanitizePermissionPayload,
};