Add dashboard UI and secure API endpoints

This commit is contained in:
2026-01-27 15:20:10 +01:00
parent 41949fe596
commit 5905b16d50
12 changed files with 1193 additions and 181 deletions
+15
View File
@@ -0,0 +1,15 @@
# 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=
+52 -67
View File
@@ -1,80 +1,65 @@
Im going to make a To-do List for the features i want to implement for my Bot. # Discord Bot
### **To-Do List for My Discord Bot** ## Dashboard setup
#### **Core Features** This repository includes a lightweight dashboard for configuring permissions and warnings without editing JSON. The dashboard uses Discord OAuth2 and the existing MongoDB collections.
(I will try to make these tools optional, because alot of bots already feature them)
1. **Basic Commands** ### Environment variables
- [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.
2. **Role Management** Copy `.env.example` and fill in the values:
- [ ] Set up a self-assignable role system (e.g., using reaction roles).
- [x] Allow admins to assign/remove roles with commands.
3. **Moderation Tools** ```bash
- [x] Add commands to mute, kick, and temp_ban users. cp .env.example .env
- [ ] Create an auto-moderation feature to delete spam or inappropriate content. ```
- [x] Implement a warning system with a strike counter.
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
```
--- ---
#### **Unique Features** ## Existing feature notes
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]`).
2. **Datetime Converter** ### Server-Specific Permission Configuration
(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.
3. **Minigames** 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.
- [ ] 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: Example document stored in the `discord.guild_permissions` collection:
```json ```json
+12
View File
@@ -0,0 +1,12 @@
<!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
@@ -0,0 +1,19 @@
{
"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
@@ -0,0 +1,576 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,265 @@
: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
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});
+168 -110
View File
@@ -2,9 +2,18 @@ const crypto = require('node:crypto');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const axios = require('axios'); const axios = require('axios');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const express = require('express'); 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 {addWarning, getWarnings} = require('./services/warnings');
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); const {
getGuildPermissions,
updateGuildPermissions,
sanitizePermissionPayload,
} = require('./services/permissions');
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const PERMISSION_BITS = { const PERMISSION_BITS = {
ADMINISTRATOR: 0x8n, ADMINISTRATOR: 0x8n,
@@ -38,6 +47,8 @@ const DISCORD_BOT_TOKEN =
process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN ||
config.discordBotToken || config.discordBotToken ||
config.token; config.token;
const MONGODB_URI = process.env.MONGODB_URI || config.mongodb;
const SESSION_SECRET = process.env.SESSION_SECRET || config.sessionSecret;
const DASHBOARD_CLIENT_URL = const DASHBOARD_CLIENT_URL =
process.env.DASHBOARD_CLIENT_URL || process.env.DASHBOARD_CLIENT_URL ||
config.dashboardClientUrl || config.dashboardClientUrl ||
@@ -56,77 +67,14 @@ console.log('[oauth config]', {
}); });
const DISCORD_API_BASE = 'https://discord.com/api'; const DISCORD_API_BASE = 'https://discord.com/api';
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
const sessionStore = new Map(); const logInfo = (message, meta = {}) => {
console.log(JSON.stringify({level: 'info', message, ...meta}));
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 setCookie = (res, name, value, options = {}) => { const logError = (message, meta = {}) => {
const directives = [`${name}=${encodeURIComponent(value)}`]; console.error(JSON.stringify({level: 'error', message, ...meta}));
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 = () => { const ensureOAuthConfig = () => {
@@ -151,7 +99,7 @@ const buildAuthorizationUrl = (state) => {
prompt: 'consent', prompt: 'consent',
}); });
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`; return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
}; };
const fetchDiscordAccessToken = async (code) => { const fetchDiscordAccessToken = async (code) => {
@@ -227,19 +175,10 @@ const canManageGuild = (guild) => {
}; };
const ensureSession = (req, res, next) => { const ensureSession = (req, res, next) => {
const cookies = parseCookies(req.headers.cookie); if (!req.session?.user || !req.session?.accessToken) {
const sessionId = cookies.dashboard_session;
if (!sessionId) {
return res.status(401).json({error: 'Unauthorized'}); 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(); return next();
}; };
@@ -257,40 +196,138 @@ const ensureManageableGuild = async (req, res, next) => {
req.guild = guild; req.guild = guild;
return next(); return next();
} catch (error) { } 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'}); 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 createDashboardApp = () => {
const app = express(); 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(express.json());
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.get('/auth/discord', (req, res) => { 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) => {
if (!ensureOAuthConfig()) { if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'}); return res.status(500).json({error: 'Missing Discord OAuth configuration'});
} }
const state = crypto.randomBytes(16).toString('hex'); const state = crypto.randomBytes(16).toString('hex');
setCookie(res, 'oauth_state', state, { res.cookie('oauth_state', state, {
httpOnly: true, httpOnly: true,
sameSite: 'Lax', sameSite: 'lax',
secure: req.secure, secure: isProduction,
maxAge: 300, maxAge: 300 * 1000,
}); });
return res.redirect(buildAuthorizationUrl(state)); return res.redirect(buildAuthorizationUrl(state));
}); });
app.get('/auth/discord/callback', async (req, res) => { app.get('/auth/discord/callback', authLimiter, async (req, res) => {
if (!ensureOAuthConfig()) { if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'}); return res.status(500).json({error: 'Missing Discord OAuth configuration'});
} }
const {code, state} = req.query; const {code, state} = req.query;
const cookies = parseCookies(req.headers.cookie); if (!code || !state || req.cookies.oauth_state !== state) {
if (!code || !state || cookies.oauth_state !== state) {
return res.status(400).json({error: 'Invalid OAuth state'}); return res.status(400).json({error: 'Invalid OAuth state'});
} }
@@ -298,21 +335,15 @@ const createDashboardApp = () => {
const tokenData = await fetchDiscordAccessToken(code); const tokenData = await fetchDiscordAccessToken(code);
const user = await fetchDiscordUser(tokenData.access_token); const user = await fetchDiscordUser(tokenData.access_token);
const {sessionId, expiresAt} = createSession({ const expiresAt = Date.now() + (tokenData.expires_in || 0) * 1000;
accessToken: tokenData.access_token, req.session.accessToken = tokenData.access_token;
refreshToken: tokenData.refresh_token, req.session.refreshToken = tokenData.refresh_token;
tokenType: tokenData.token_type, req.session.tokenType = tokenData.token_type;
scope: tokenData.scope, req.session.scope = tokenData.scope;
user, req.session.expiresAt = expiresAt;
}); req.session.user = user;
setCookie(res, 'dashboard_session', sessionId, { res.clearCookie('oauth_state');
httpOnly: true,
sameSite: 'Lax',
secure: req.secure,
maxAge: Math.floor((expiresAt - Date.now()) / 1000),
});
clearCookie(res, 'oauth_state');
if (DASHBOARD_CLIENT_URL) { if (DASHBOARD_CLIENT_URL) {
return res.redirect(DASHBOARD_CLIENT_URL); return res.redirect(DASHBOARD_CLIENT_URL);
@@ -324,7 +355,7 @@ const createDashboardApp = () => {
return res.status(200).json({user}); return res.status(200).json({user});
} catch (error) { } catch (error) {
console.error('Discord OAuth failed', { logError('Discord OAuth failed', {
message: error.message, message: error.message,
status: error.response?.status, status: error.response?.status,
data: error.response?.data, data: error.response?.data,
@@ -338,9 +369,18 @@ const createDashboardApp = () => {
}); });
app.post('/auth/logout', ensureSession, (req, res) => { app.post('/auth/logout', ensureSession, (req, res) => {
destroySession(req.sessionId); req.session.destroy((err) => {
clearCookie(res, 'dashboard_session'); if (err) {
return res.status(204).send(); logError('Failed to destroy session', {message: err.message});
return res.status(500).json({error: 'Failed to log out'});
}
res.clearCookie('connect.sid');
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) => { app.get('/api/guilds', ensureSession, async (req, res) => {
@@ -349,6 +389,10 @@ const createDashboardApp = () => {
const manageableGuilds = guilds.filter(canManageGuild); const manageableGuilds = guilds.filter(canManageGuild);
return res.json({guilds: manageableGuilds}); return res.json({guilds: manageableGuilds});
} catch (error) { } catch (error) {
logError('Failed to fetch guilds', {
message: error.message,
status: error.response?.status,
});
return res.status(502).json({error: 'Failed to fetch guilds'}); return res.status(502).json({error: 'Failed to fetch guilds'});
} }
}); });
@@ -362,6 +406,7 @@ const createDashboardApp = () => {
const permissions = await getGuildPermissions(req.params.guildId); const permissions = await getGuildPermissions(req.params.guildId);
return res.json({permissions}); return res.json({permissions});
} catch (error) { } catch (error) {
logError('Failed to fetch permissions', {message: error.message});
return res.status(500).json({error: 'Failed to fetch permissions'}); return res.status(500).json({error: 'Failed to fetch permissions'});
} }
}, },
@@ -372,13 +417,19 @@ const createDashboardApp = () => {
ensureSession, ensureSession,
ensureManageableGuild, ensureManageableGuild,
async (req, res) => { async (req, res) => {
const validationErrors = validatePermissionPayload(req.body || {});
if (validationErrors.length) {
return res.status(400).json({error: 'Invalid permissions payload', details: validationErrors});
}
try { try {
const sanitizedPermissions = sanitizePermissionPayload(req.body || {});
const permissions = await updateGuildPermissions({ const permissions = await updateGuildPermissions({
guildId: req.params.guildId, guildId: req.params.guildId,
permissions: req.body || {}, permissions: sanitizedPermissions,
}); });
return res.json({permissions}); return res.json({permissions});
} catch (error) { } catch (error) {
logError('Failed to update permissions', {message: error.message});
return res.status(500).json({error: 'Failed to update permissions'}); return res.status(500).json({error: 'Failed to update permissions'});
} }
}, },
@@ -393,6 +444,10 @@ const createDashboardApp = () => {
const roles = await fetchDiscordGuildRoles(req.params.guildId); const roles = await fetchDiscordGuildRoles(req.params.guildId);
return res.json({roles}); return res.json({roles});
} catch (error) { } catch (error) {
logError('Failed to fetch roles', {
message: error.message,
status: error.response?.status,
});
return res.status(500).json({error: 'Failed to fetch roles'}); return res.status(500).json({error: 'Failed to fetch roles'});
} }
}, },
@@ -410,6 +465,7 @@ const createDashboardApp = () => {
}); });
return res.json({warnings}); return res.json({warnings});
} catch (error) { } catch (error) {
logError('Failed to fetch warnings', {message: error.message});
return res.status(500).json({error: 'Failed to fetch warnings'}); return res.status(500).json({error: 'Failed to fetch warnings'});
} }
}, },
@@ -421,18 +477,20 @@ const createDashboardApp = () => {
ensureManageableGuild, ensureManageableGuild,
async (req, res) => { async (req, res) => {
const {reason} = req.body || {}; const {reason} = req.body || {};
if (!reason) { const reasonError = validateWarningReason(reason);
return res.status(400).json({error: 'Reason is required'}); if (reasonError) {
return res.status(400).json({error: reasonError});
} }
try { try {
const result = await addWarning({ const result = await addWarning({
guildId: req.params.guildId, guildId: req.params.guildId,
userId: req.params.userId, userId: req.params.userId,
moderatorId: req.session.user?.id, moderatorId: req.session.user?.id,
reason, reason: reason.trim(),
}); });
return res.status(201).json(result); return res.status(201).json(result);
} catch (error) { } catch (error) {
logError('Failed to add warning', {message: error.message});
return res.status(500).json({error: 'Failed to add warning'}); return res.status(500).json({error: 'Failed to add warning'});
} }
}, },
+18 -2
View File
@@ -1,11 +1,27 @@
const fs = require('node:fs');
const path = require('node:path');
const {MongoClient, ServerApiVersion} = require('mongodb'); const {MongoClient, ServerApiVersion} = require('mongodb');
const {mongodb} = require("./config.json");
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;
let dbClient; let dbClient;
async function connectToDatabase() { async function connectToDatabase() {
if (!dbClient) { if (!dbClient) {
dbClient = new MongoClient(mongodb, { if (!MONGODB_URI) {
throw new Error('Missing MongoDB connection string.');
}
dbClient = new MongoClient(MONGODB_URI, {
serverApi: { serverApi: {
version: ServerApiVersion.v1, version: ServerApiVersion.v1,
strict: true, strict: true,
+7 -1
View File
@@ -3,7 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1",
"dashboard": "node dashboardServer.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -12,8 +13,13 @@
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"discord.js": "^14.17.3", "discord.js": "^14.17.3",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"openai": "^4.67.3", "openai": "^4.67.3",
+41 -1
View File
@@ -14,6 +14,43 @@ const normalizePermissionConfig = (config = {}) => ({
commandPermissions: config.commandPermissions ?? {}, 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) => const memberHasRole = (member, roleIds) =>
roleIds.some((roleId) => member.roles?.cache?.has(roleId)); roleIds.some((roleId) => member.roles?.cache?.has(roleId));
@@ -35,7 +72,8 @@ const updateGuildPermissions = async ({ guildId, permissions }) => {
const db = dbClient.db('discord'); const db = dbClient.db('discord');
const collection = db.collection('guild_permissions'); const collection = db.collection('guild_permissions');
const normalizedPermissions = normalizePermissionConfig(permissions); const sanitizedPermissions = sanitizePermissionPayload(permissions);
const normalizedPermissions = normalizePermissionConfig(sanitizedPermissions);
const payload = { const payload = {
guildId, guildId,
...normalizedPermissions, ...normalizedPermissions,
@@ -92,4 +130,6 @@ module.exports = {
getGuildPermissions, getGuildPermissions,
updateGuildPermissions, updateGuildPermissions,
isCommandAllowed, isCommandAllowed,
normalizePermissionConfig,
sanitizePermissionPayload,
}; };