Add dashboard UI and secure API endpoints
This commit is contained in:
@@ -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=
|
||||
@@ -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**
|
||||
(I will try to make these tools optional, because alot of bots already feature them)
|
||||
This repository includes a lightweight dashboard for configuring permissions and warnings without editing JSON. The dashboard uses Discord OAuth2 and the existing MongoDB collections.
|
||||
|
||||
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.
|
||||
### Environment variables
|
||||
|
||||
2. **Role Management**
|
||||
- [ ] Set up a self-assignable role system (e.g., using reaction roles).
|
||||
- [x] Allow admins to assign/remove roles with commands.
|
||||
Copy `.env.example` and fill in the values:
|
||||
|
||||
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.
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **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]`).
|
||||
## Existing feature notes
|
||||
|
||||
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.
|
||||
### Server-Specific Permission Configuration
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Example document stored in the `discord.guild_permissions` collection:
|
||||
```json
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
+168
-110
@@ -2,9 +2,18 @@ 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} = require('./services/permissions');
|
||||
const {
|
||||
getGuildPermissions,
|
||||
updateGuildPermissions,
|
||||
sanitizePermissionPayload,
|
||||
} = require('./services/permissions');
|
||||
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const PERMISSION_BITS = {
|
||||
ADMINISTRATOR: 0x8n,
|
||||
@@ -38,6 +47,8 @@ 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 ||
|
||||
@@ -56,77 +67,14 @@ console.log('[oauth config]', {
|
||||
});
|
||||
|
||||
const DISCORD_API_BASE = 'https://discord.com/api';
|
||||
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
|
||||
|
||||
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 logInfo = (message, meta = {}) => {
|
||||
console.log(JSON.stringify({level: 'info', 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 logError = (message, meta = {}) => {
|
||||
console.error(JSON.stringify({level: 'error', message, ...meta}));
|
||||
};
|
||||
|
||||
const ensureOAuthConfig = () => {
|
||||
@@ -151,7 +99,7 @@ const buildAuthorizationUrl = (state) => {
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
|
||||
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
|
||||
};
|
||||
|
||||
const fetchDiscordAccessToken = async (code) => {
|
||||
@@ -227,19 +175,10 @@ const canManageGuild = (guild) => {
|
||||
};
|
||||
|
||||
const ensureSession = (req, res, next) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const sessionId = cookies.dashboard_session;
|
||||
if (!sessionId) {
|
||||
if (!req.session?.user || !req.session?.accessToken) {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -257,40 +196,138 @@ 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')));
|
||||
|
||||
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()) {
|
||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||
}
|
||||
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
setCookie(res, 'oauth_state', state, {
|
||||
res.cookie('oauth_state', state, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: req.secure,
|
||||
maxAge: 300,
|
||||
sameSite: 'lax',
|
||||
secure: isProduction,
|
||||
maxAge: 300 * 1000,
|
||||
});
|
||||
|
||||
return res.redirect(buildAuthorizationUrl(state));
|
||||
});
|
||||
|
||||
app.get('/auth/discord/callback', async (req, res) => {
|
||||
app.get('/auth/discord/callback', authLimiter, async (req, res) => {
|
||||
if (!ensureOAuthConfig()) {
|
||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||
}
|
||||
|
||||
const {code, state} = req.query;
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (!code || !state || cookies.oauth_state !== state) {
|
||||
if (!code || !state || req.cookies.oauth_state !== state) {
|
||||
return res.status(400).json({error: 'Invalid OAuth state'});
|
||||
}
|
||||
|
||||
@@ -298,21 +335,15 @@ const createDashboardApp = () => {
|
||||
const tokenData = await fetchDiscordAccessToken(code);
|
||||
const user = await fetchDiscordUser(tokenData.access_token);
|
||||
|
||||
const {sessionId, expiresAt} = createSession({
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
tokenType: tokenData.token_type,
|
||||
scope: tokenData.scope,
|
||||
user,
|
||||
});
|
||||
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;
|
||||
|
||||
setCookie(res, 'dashboard_session', sessionId, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: req.secure,
|
||||
maxAge: Math.floor((expiresAt - Date.now()) / 1000),
|
||||
});
|
||||
clearCookie(res, 'oauth_state');
|
||||
res.clearCookie('oauth_state');
|
||||
|
||||
if (DASHBOARD_CLIENT_URL) {
|
||||
return res.redirect(DASHBOARD_CLIENT_URL);
|
||||
@@ -324,7 +355,7 @@ const createDashboardApp = () => {
|
||||
|
||||
return res.status(200).json({user});
|
||||
} catch (error) {
|
||||
console.error('Discord OAuth failed', {
|
||||
logError('Discord OAuth failed', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
@@ -338,9 +369,18 @@ const createDashboardApp = () => {
|
||||
});
|
||||
|
||||
app.post('/auth/logout', ensureSession, (req, res) => {
|
||||
destroySession(req.sessionId);
|
||||
clearCookie(res, 'dashboard_session');
|
||||
return res.status(204).send();
|
||||
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');
|
||||
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) => {
|
||||
@@ -349,6 +389,10 @@ 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'});
|
||||
}
|
||||
});
|
||||
@@ -362,6 +406,7 @@ 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'});
|
||||
}
|
||||
},
|
||||
@@ -372,13 +417,19 @@ 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: req.body || {},
|
||||
permissions: sanitizedPermissions,
|
||||
});
|
||||
return res.json({permissions});
|
||||
} catch (error) {
|
||||
logError('Failed to update permissions', {message: error.message});
|
||||
return res.status(500).json({error: 'Failed to update permissions'});
|
||||
}
|
||||
},
|
||||
@@ -393,6 +444,10 @@ 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'});
|
||||
}
|
||||
},
|
||||
@@ -410,6 +465,7 @@ 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'});
|
||||
}
|
||||
},
|
||||
@@ -421,18 +477,20 @@ const createDashboardApp = () => {
|
||||
ensureManageableGuild,
|
||||
async (req, res) => {
|
||||
const {reason} = req.body || {};
|
||||
if (!reason) {
|
||||
return res.status(400).json({error: 'Reason is required'});
|
||||
const reasonError = validateWarningReason(reason);
|
||||
if (reasonError) {
|
||||
return res.status(400).json({error: reasonError});
|
||||
}
|
||||
try {
|
||||
const result = await addWarning({
|
||||
guildId: req.params.guildId,
|
||||
userId: req.params.userId,
|
||||
moderatorId: req.session.user?.id,
|
||||
reason,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
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'});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
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;
|
||||
|
||||
async function connectToDatabase() {
|
||||
if (!dbClient) {
|
||||
dbClient = new MongoClient(mongodb, {
|
||||
if (!MONGODB_URI) {
|
||||
throw new Error('Missing MongoDB connection string.');
|
||||
}
|
||||
|
||||
dbClient = new MongoClient(MONGODB_URI, {
|
||||
serverApi: {
|
||||
version: ServerApiVersion.v1,
|
||||
strict: true,
|
||||
|
||||
+7
-1
@@ -3,7 +3,8 @@
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dashboard": "node dashboardServer.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -12,8 +13,13 @@
|
||||
"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",
|
||||
|
||||
+41
-1
@@ -14,6 +14,43 @@ 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));
|
||||
|
||||
@@ -35,7 +72,8 @@ const updateGuildPermissions = async ({ guildId, permissions }) => {
|
||||
const db = dbClient.db('discord');
|
||||
const collection = db.collection('guild_permissions');
|
||||
|
||||
const normalizedPermissions = normalizePermissionConfig(permissions);
|
||||
const sanitizedPermissions = sanitizePermissionPayload(permissions);
|
||||
const normalizedPermissions = normalizePermissionConfig(sanitizedPermissions);
|
||||
const payload = {
|
||||
guildId,
|
||||
...normalizedPermissions,
|
||||
@@ -92,4 +130,6 @@ module.exports = {
|
||||
getGuildPermissions,
|
||||
updateGuildPermissions,
|
||||
isCommandAllowed,
|
||||
normalizePermissionConfig,
|
||||
sanitizePermissionPayload,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user