first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
DISCORD_TOKEN=your_discord_bot_token
|
||||||
|
DISCORD_CLIENT_ID=your_discord_application_client_id
|
||||||
|
DISCORD_CLIENT_SECRET=your_discord_application_client_secret
|
||||||
|
DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback
|
||||||
|
SESSION_SECRET=replace_with_a_long_random_secret
|
||||||
|
DISCORD_GUILD_ID=optional_guild_id_for_instant_command_updates
|
||||||
|
PORT=3000
|
||||||
|
QUOTE_CONFIG_PATH=./config/quotes.config.json
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.idea
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
.env
|
||||||
|
uploads
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# ZitateBot
|
||||||
|
|
||||||
|
Discord bot + web form for submitting quotes to different channels based on selected speaker.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Dark-mode animated webpage
|
||||||
|
- Discord login required for quote submissions
|
||||||
|
- Speaker dropdown loaded from config
|
||||||
|
- Quote submission endpoint
|
||||||
|
- Discord embed posting to channel configured per speaker
|
||||||
|
- Optional mirror posting to a global latest channel
|
||||||
|
- Creator attribution is always included on quotes
|
||||||
|
- `/create-quote` command for Discord-side quote creation
|
||||||
|
- Optional file upload from web form with a public URL added to the quote
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies.
|
||||||
|
2. In the Discord Developer Portal, set OAuth2 redirect URL to `http://localhost:3000/auth/discord/callback` (or your hosted URL).
|
||||||
|
3. Copy `.env.example` to `.env` and set bot + OAuth credentials.
|
||||||
|
4. Edit `config/quotes.config.json` with real channel IDs and allowed Discord user IDs.
|
||||||
|
5. Run config check and then start.
|
||||||
|
|
||||||
|
Set `DISCORD_GUILD_ID` in `.env` if you want `/create-quote` to appear quickly in one guild during setup.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
npm run check
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Config format
|
||||||
|
|
||||||
|
`config/quotes.config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"allowedSubmitterDiscordIds": ["111111111111111111"],
|
||||||
|
"latestChannelId": "222222222222222222",
|
||||||
|
"createQuoteChannelId": "333333333333333333",
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "alice",
|
||||||
|
"displayName": "Alice",
|
||||||
|
"channelId": "123456789012345678",
|
||||||
|
"accentColor": 5793266,
|
||||||
|
"avatarUrl": "https://...optional"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Bot requires access to the configured channels.
|
||||||
|
- Only users in `allowedSubmitterDiscordIds` can submit quotes (web and `/create-quote`).
|
||||||
|
- If `latestChannelId` is set, every quote is also posted there.
|
||||||
|
- If `createQuoteChannelId` is set, `/create-quote` can only be used in that channel.
|
||||||
|
- Without `createQuoteChannelId`, `/create-quote` is allowed in channels named `create-quote`.
|
||||||
|
- Web form uploads are optional and stored in `uploads/` (served from `/uploads/<filename>`).
|
||||||
|
- Allowed web upload file types: images, video/audio files, `.txt`, `.pdf`, `.zip`, `.7z`, `.rar` (max 100 MB).
|
||||||
|
|
||||||
|
## Discord command quote creation
|
||||||
|
|
||||||
|
- Use `/create-quote` in your create-quote channel.
|
||||||
|
- Select `speaker` from the dropdown choices.
|
||||||
|
- Enter `quote` text in the command form.
|
||||||
|
- The quote is posted like web submissions and includes who created it.
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"allowedSubmitterDiscordIds": [
|
||||||
|
"844583778299019265",
|
||||||
|
"1195380136598569087",
|
||||||
|
"867341555814760448",
|
||||||
|
"910609873014759424",
|
||||||
|
"972752440917123082"
|
||||||
|
],
|
||||||
|
"latestChannelId": "1492868507296071710",
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "simpell",
|
||||||
|
"displayName": "SimPell",
|
||||||
|
"channelId": "1492868507296071710",
|
||||||
|
"accentColor": 5793266,
|
||||||
|
"avatarUrl": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hykaika",
|
||||||
|
"displayName": "hykaika",
|
||||||
|
"channelId": "1492868527281930350",
|
||||||
|
"accentColor": 3197439,
|
||||||
|
"avatarUrl": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fullrisk",
|
||||||
|
"displayName": "FullRisk",
|
||||||
|
"channelId": "1492868539135037582",
|
||||||
|
"accentColor": 3197439,
|
||||||
|
"avatarUrl": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "winnie",
|
||||||
|
"displayName": "winnie",
|
||||||
|
"channelId": "1492868579555803243",
|
||||||
|
"accentColor": 3197439,
|
||||||
|
"avatarUrl": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aaircrafter",
|
||||||
|
"displayName": "AAirCrafter",
|
||||||
|
"channelId": "1492871094569275423",
|
||||||
|
"accentColor": 3197439,
|
||||||
|
"avatarUrl": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "skyyrex",
|
||||||
|
"displayName": "skyyrex",
|
||||||
|
"channelId": "1492871448870518879",
|
||||||
|
"accentColor": 3197439,
|
||||||
|
"avatarUrl": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const dotenv = require("dotenv");
|
||||||
|
|
||||||
|
const { createBotClient, sendQuoteForUser, wireQuoteCommandHandlers } = require("./src/bot");
|
||||||
|
const { createWebServer } = require("./src/web");
|
||||||
|
const { loadAppConfig } = require("./src/config");
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const configPath = process.env.QUOTE_CONFIG_PATH || path.join(__dirname, "config", "quotes.config.json");
|
||||||
|
const appConfig = loadAppConfig(configPath);
|
||||||
|
|
||||||
|
const client = createBotClient();
|
||||||
|
wireQuoteCommandHandlers(client, appConfig);
|
||||||
|
|
||||||
|
client.once("ready", () => {
|
||||||
|
console.log(`Discord bot connected as ${client.user.tag}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.login(process.env.DISCORD_TOKEN);
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
const app = createWebServer({
|
||||||
|
users: appConfig.users,
|
||||||
|
allowedSubmitterDiscordIds: appConfig.allowedSubmitterDiscordIds,
|
||||||
|
onQuoteSubmit: ({ userId, quoteText, creator, attachmentUrl }) =>
|
||||||
|
sendQuoteForUser(client, appConfig, userId, quoteText, creator, "website", attachmentUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Web form available at http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Failed to start application:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "ZitateBot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Discord quote bot with dark-mode animated web form",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"check": "node scripts/check.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"discord.js": "^14.21.0",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"multer": "^2.0.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
const form = document.getElementById("quote-form");
|
||||||
|
const userSelect = document.getElementById("user-select");
|
||||||
|
const quoteText = document.getElementById("quote-text");
|
||||||
|
const attachmentInput = document.getElementById("attachment");
|
||||||
|
const submitButton = document.getElementById("submit-btn");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const authText = document.getElementById("auth-text");
|
||||||
|
const loginLink = document.getElementById("login-link");
|
||||||
|
const logoutButton = document.getElementById("logout-btn");
|
||||||
|
|
||||||
|
function setFormLocked(locked) {
|
||||||
|
form.classList.toggle("locked", locked);
|
||||||
|
userSelect.disabled = locked;
|
||||||
|
quoteText.disabled = locked;
|
||||||
|
attachmentInput.disabled = locked;
|
||||||
|
submitButton.disabled = locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message, type = "") {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.className = `status ${type}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const response = await fetch("/api/users");
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
userSelect.innerHTML = "";
|
||||||
|
const placeholder = document.createElement("option");
|
||||||
|
placeholder.value = "";
|
||||||
|
placeholder.textContent = "Select a speaker";
|
||||||
|
userSelect.appendChild(placeholder);
|
||||||
|
|
||||||
|
for (const user of payload.users) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = user.id;
|
||||||
|
option.textContent = user.displayName;
|
||||||
|
userSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
const response = await fetch("/api/session");
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!payload.authenticated) {
|
||||||
|
authText.textContent = "Please log in with Discord to submit quotes.";
|
||||||
|
loginLink.hidden = false;
|
||||||
|
logoutButton.hidden = true;
|
||||||
|
setFormLocked(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName = payload.user.globalName || payload.user.username;
|
||||||
|
logoutButton.hidden = false;
|
||||||
|
loginLink.hidden = true;
|
||||||
|
|
||||||
|
if (!payload.authorized) {
|
||||||
|
authText.textContent = `Logged in as ${userName}, but this account is not allowed to submit.`;
|
||||||
|
setFormLocked(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authText.textContent = `Logged in as ${userName}. You can submit quotes.`;
|
||||||
|
setFormLocked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
logoutButton.addEventListener("click", async () => {
|
||||||
|
await fetch("/auth/logout", { method: "POST" });
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const userId = userSelect.value;
|
||||||
|
const quote = quoteText.value.trim();
|
||||||
|
const file = attachmentInput.files && attachmentInput.files[0] ? attachmentInput.files[0] : null;
|
||||||
|
|
||||||
|
if (!userId || !quote) {
|
||||||
|
setStatus("Please choose a speaker and enter a quote.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitButton.disabled = true;
|
||||||
|
setStatus("Sending quote...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("userId", userId);
|
||||||
|
formData.append("quoteText", quote);
|
||||||
|
if (file) {
|
||||||
|
formData.append("attachment", file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/quotes", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "Failed to send quote.");
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteText.value = "";
|
||||||
|
attachmentInput.value = "";
|
||||||
|
setStatus("Quote sent to Discord.", "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message || "Something went wrong.", "error");
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormLocked(true);
|
||||||
|
|
||||||
|
Promise.all([loadSession(), loadUsers()]).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setStatus("Failed to initialize page.", "error");
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ZitateBot - Quote Submitter</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow bg-glow-a"></div>
|
||||||
|
<div class="bg-glow bg-glow-b"></div>
|
||||||
|
|
||||||
|
<main class="app-shell">
|
||||||
|
<section class="card fade-in-up">
|
||||||
|
<h1>ZitateBot</h1>
|
||||||
|
<p class="sub">Select a speaker and submit a quote directly to Discord.</p>
|
||||||
|
|
||||||
|
<div id="auth-panel" class="auth-panel">
|
||||||
|
<p id="auth-text" class="auth-text">Checking Discord session...</p>
|
||||||
|
<div class="auth-actions">
|
||||||
|
<a id="login-link" class="auth-link" href="/auth/discord">Login with Discord</a>
|
||||||
|
<button id="logout-btn" type="button" class="ghost-btn" hidden>Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="quote-form">
|
||||||
|
<label for="user-select">Speaker</label>
|
||||||
|
<select id="user-select" required>
|
||||||
|
<option value="">Loading users...</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="quote-text">Quote</label>
|
||||||
|
<textarea
|
||||||
|
id="quote-text"
|
||||||
|
maxlength="600"
|
||||||
|
placeholder="Write the quote here..."
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<label for="attachment">Attachment (optional)</label>
|
||||||
|
<input id="attachment" type="file" />
|
||||||
|
<p class="hint">Allowed: images, video, audio, txt, pdf, zip (max 100 MB).</p>
|
||||||
|
|
||||||
|
<button id="submit-btn" type="submit">Send Quote</button>
|
||||||
|
<p id="status" class="status" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--bg-soft: #121a2f;
|
||||||
|
--text: #dbe4ff;
|
||||||
|
--text-soft: #8b9cc8;
|
||||||
|
--accent: #6d7cff;
|
||||||
|
--accent-2: #30d1ff;
|
||||||
|
--error: #ff6b8a;
|
||||||
|
--ok: #6dffb5;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: radial-gradient(circle at top left, #111a32, var(--bg));
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
background: linear-gradient(145deg, rgba(18, 26, 47, 0.85), rgba(11, 16, 32, 0.95));
|
||||||
|
border: 1px solid rgba(109, 124, 255, 0.25);
|
||||||
|
border-radius: 18px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
border: 1px solid rgba(139, 156, 200, 0.2);
|
||||||
|
background: rgba(9, 13, 26, 0.45);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link,
|
||||||
|
.ghost-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(109, 124, 255, 0.45);
|
||||||
|
background: rgba(109, 124, 255, 0.14);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link:hover,
|
||||||
|
.ghost-btn:hover {
|
||||||
|
background: rgba(109, 124, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#quote-form.locked {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
input[type="file"],
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(139, 156, 200, 0.25);
|
||||||
|
background: rgba(9, 13, 26, 0.75);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"]::file-selector-button {
|
||||||
|
margin-right: 10px;
|
||||||
|
border: 1px solid rgba(109, 124, 255, 0.45);
|
||||||
|
background: rgba(109, 124, 255, 0.14);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 160px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 0;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||||
|
color: #061228;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(48, 209, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-glow {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(70px);
|
||||||
|
opacity: 0.35;
|
||||||
|
z-index: 1;
|
||||||
|
animation: float 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-glow-a {
|
||||||
|
width: 260px;
|
||||||
|
height: 260px;
|
||||||
|
left: 10%;
|
||||||
|
top: 12%;
|
||||||
|
background: #526dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-glow-b {
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
right: 10%;
|
||||||
|
bottom: 12%;
|
||||||
|
background: #18ccff;
|
||||||
|
animation-delay: -4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up {
|
||||||
|
animation: fadeInUp 0.65s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const { loadAppConfig } = require("../src/config");
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const configPath = process.env.QUOTE_CONFIG_PATH || path.join(__dirname, "..", "config", "quotes.config.json");
|
||||||
|
const config = loadAppConfig(configPath);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Config OK: ${config.users.length} user(s), ${config.allowedSubmitterDiscordIds.length} allowed submitter(s), latest channel ${config.latestChannelId ? "configured" : "not set"}, create-quote channel ${config.createQuoteChannelId ? "configured" : "fallback by name"}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Config check failed:", error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
const { ChannelType, Client, EmbedBuilder, GatewayIntentBits, SlashCommandBuilder } = require("discord.js");
|
||||||
|
|
||||||
|
function createBotClient() {
|
||||||
|
return new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserById(appConfig, userId) {
|
||||||
|
return appConfig.users.find((user) => user.id === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreatorText(creator) {
|
||||||
|
if (!creator) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creator.id) {
|
||||||
|
return `<@${creator.id}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creator.globalName && creator.username) {
|
||||||
|
return `${creator.globalName} (@${creator.username})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creator.username) {
|
||||||
|
return creator.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageUrl(url) {
|
||||||
|
return /\.(png|jpe?g|gif|webp)$/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuoteEmbed(user, quoteText, creator, sourceLabel, attachmentUrl = null) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(user.accentColor)
|
||||||
|
.setAuthor({
|
||||||
|
name: user.displayName,
|
||||||
|
iconURL: user.avatarUrl || undefined,
|
||||||
|
})
|
||||||
|
.setDescription(`"${quoteText}"`)
|
||||||
|
.addFields({
|
||||||
|
name: "Created by",
|
||||||
|
value: buildCreatorText(creator),
|
||||||
|
inline: false,
|
||||||
|
})
|
||||||
|
.setFooter({ text: `Submitted from ${sourceLabel}` })
|
||||||
|
.setTimestamp(new Date());
|
||||||
|
|
||||||
|
if (attachmentUrl) {
|
||||||
|
embed.addFields({
|
||||||
|
name: "Attachment",
|
||||||
|
value: `[Open file](${attachmentUrl})`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
if (isImageUrl(attachmentUrl)) {
|
||||||
|
embed.setImage(attachmentUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTextChannel(client, channelId, label) {
|
||||||
|
const channel = await client.channels.fetch(channelId);
|
||||||
|
if (!channel || channel.type !== ChannelType.GuildText) {
|
||||||
|
throw new Error(`Configured ${label} is not a guild text channel: ${channelId}`);
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inCreateQuoteChannel(interaction, appConfig) {
|
||||||
|
if (appConfig.createQuoteChannelId) {
|
||||||
|
return interaction.channelId === appConfig.createQuoteChannelId;
|
||||||
|
}
|
||||||
|
return interaction.channel && interaction.channel.name === "create-quote";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerCreateQuoteCommand(client, appConfig) {
|
||||||
|
if (appConfig.users.length > 25) {
|
||||||
|
throw new Error("Discord command choices are limited to 25 users.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new SlashCommandBuilder()
|
||||||
|
.setName("create-quote")
|
||||||
|
.setDescription("Create and send a quote")
|
||||||
|
.addStringOption((option) => {
|
||||||
|
option.setName("speaker").setDescription("Who said the quote?").setRequired(true);
|
||||||
|
for (const user of appConfig.users) {
|
||||||
|
option.addChoices({ name: user.displayName, value: user.id });
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
})
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("quote")
|
||||||
|
.setDescription("The quote text")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(600)
|
||||||
|
);
|
||||||
|
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID;
|
||||||
|
if (guildId) {
|
||||||
|
await client.application.commands.set([command], guildId);
|
||||||
|
console.log(`Registered /create-quote in guild ${guildId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.application.commands.set([command]);
|
||||||
|
console.log("Registered global /create-quote command");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireQuoteCommandHandlers(client, appConfig) {
|
||||||
|
client.on("ready", async () => {
|
||||||
|
try {
|
||||||
|
await registerCreateQuoteCommand(client, appConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to register /create-quote command:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("interactionCreate", async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || interaction.commandName !== "create-quote") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inCreateQuoteChannel(interaction, appConfig)) {
|
||||||
|
const message = appConfig.createQuoteChannelId
|
||||||
|
? `Please use this command in <#${appConfig.createQuoteChannelId}>.`
|
||||||
|
: "Please use this command in the #create-quote channel.";
|
||||||
|
await interaction.reply({ content: message, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(appConfig.allowedSubmitterDiscordIds) &&
|
||||||
|
appConfig.allowedSubmitterDiscordIds.length > 0 &&
|
||||||
|
!appConfig.allowedSubmitterDiscordIds.includes(interaction.user.id)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "Your Discord account is not allowed to submit quotes.",
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = interaction.options.getString("speaker", true);
|
||||||
|
const quoteText = interaction.options.getString("quote", true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendQuoteForUser(
|
||||||
|
client,
|
||||||
|
appConfig,
|
||||||
|
userId,
|
||||||
|
quoteText,
|
||||||
|
{
|
||||||
|
id: interaction.user.id,
|
||||||
|
username: interaction.user.username,
|
||||||
|
globalName: interaction.user.globalName || null,
|
||||||
|
},
|
||||||
|
"Discord command"
|
||||||
|
);
|
||||||
|
await interaction.reply({ content: "Quote sent.", ephemeral: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Discord command quote creation failed:", error);
|
||||||
|
await interaction.reply({ content: `Failed to send quote: ${error.message}`, ephemeral: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendQuoteForUser(
|
||||||
|
client,
|
||||||
|
appConfig,
|
||||||
|
userId,
|
||||||
|
quoteText,
|
||||||
|
creator = null,
|
||||||
|
sourceLabel = "website",
|
||||||
|
attachmentUrl = null
|
||||||
|
) {
|
||||||
|
const user = getUserById(appConfig, userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unknown user selection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedQuote = String(quoteText || "").trim();
|
||||||
|
if (!trimmedQuote) {
|
||||||
|
throw new Error("Quote text cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await resolveTextChannel(client, user.channelId, "channel");
|
||||||
|
|
||||||
|
let latestChannel = null;
|
||||||
|
if (appConfig.latestChannelId && appConfig.latestChannelId !== user.channelId) {
|
||||||
|
latestChannel = await resolveTextChannel(client, appConfig.latestChannelId, "latest channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = buildQuoteEmbed(user, trimmedQuote, creator, sourceLabel, attachmentUrl);
|
||||||
|
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
if (latestChannel) {
|
||||||
|
await latestChannel.send({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createBotClient,
|
||||||
|
sendQuoteForUser,
|
||||||
|
wireQuoteCommandHandlers,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
function assertString(value, name) {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error(`${name} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAppConfig(configPath) {
|
||||||
|
const absolutePath = path.resolve(configPath);
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
throw new Error(`Config file not found: ${absolutePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(absolutePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (!parsed || !Array.isArray(parsed.users) || parsed.users.length === 0) {
|
||||||
|
throw new Error("Config must include a non-empty users array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = parsed.users.map((user, index) => {
|
||||||
|
const prefix = `users[${index}]`;
|
||||||
|
assertString(user.id, `${prefix}.id`);
|
||||||
|
assertString(user.displayName, `${prefix}.displayName`);
|
||||||
|
assertString(user.channelId, `${prefix}.channelId`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
displayName: user.displayName,
|
||||||
|
channelId: user.channelId,
|
||||||
|
accentColor: typeof user.accentColor === "number" ? user.accentColor : 0x5865f2,
|
||||||
|
avatarUrl: typeof user.avatarUrl === "string" ? user.avatarUrl : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedSubmitterDiscordIds = Array.isArray(parsed.allowedSubmitterDiscordIds)
|
||||||
|
? parsed.allowedSubmitterDiscordIds.map((value, index) => {
|
||||||
|
assertString(value, `allowedSubmitterDiscordIds[${index}]`);
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let latestChannelId = null;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "latestChannelId")) {
|
||||||
|
assertString(parsed.latestChannelId, "latestChannelId");
|
||||||
|
latestChannelId = parsed.latestChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let createQuoteChannelId = null;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "createQuoteChannelId")) {
|
||||||
|
assertString(parsed.createQuoteChannelId, "createQuoteChannelId");
|
||||||
|
createQuoteChannelId = parsed.createQuoteChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
allowedSubmitterDiscordIds,
|
||||||
|
latestChannelId,
|
||||||
|
createQuoteChannelId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadAppConfig,
|
||||||
|
};
|
||||||
|
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const express = require("express");
|
||||||
|
const session = require("express-session");
|
||||||
|
const multer = require("multer");
|
||||||
|
|
||||||
|
const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024;
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".mp4",
|
||||||
|
".webm",
|
||||||
|
".mov",
|
||||||
|
".mp3",
|
||||||
|
".wav",
|
||||||
|
".ogg",
|
||||||
|
".txt",
|
||||||
|
".pdf",
|
||||||
|
".zip",
|
||||||
|
".7z",
|
||||||
|
".rar",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getRequiredEnv(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebServer({ users, allowedSubmitterDiscordIds, onQuoteSubmit }) {
|
||||||
|
const app = express();
|
||||||
|
const publicDir = path.join(__dirname, "..", "public");
|
||||||
|
const uploadsDir = path.join(__dirname, "..", "uploads");
|
||||||
|
const allowedSet = new Set(allowedSubmitterDiscordIds);
|
||||||
|
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
const discordClientId = getRequiredEnv("DISCORD_CLIENT_ID");
|
||||||
|
const discordClientSecret = getRequiredEnv("DISCORD_CLIENT_SECRET");
|
||||||
|
const discordRedirectUri = getRequiredEnv("DISCORD_REDIRECT_URI");
|
||||||
|
const sessionSecret = getRequiredEnv("SESSION_SECRET");
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: 1000 * 60 * 60 * 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.static(publicDir));
|
||||||
|
app.use(
|
||||||
|
"/uploads",
|
||||||
|
express.static(uploadsDir, {
|
||||||
|
setHeaders(res) {
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const extension = path.extname(file.originalname || "").toLowerCase();
|
||||||
|
const safeExtension = ALLOWED_EXTENSIONS.has(extension) ? extension : "";
|
||||||
|
cb(null, `${Date.now()}-${crypto.randomBytes(6).toString("hex")}${safeExtension}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_UPLOAD_SIZE_BYTES,
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const extension = path.extname(file.originalname || "").toLowerCase();
|
||||||
|
if (!extension || !ALLOWED_EXTENSIONS.has(extension)) {
|
||||||
|
return cb(new Error(`Unsupported file type. Allowed: ${Array.from(ALLOWED_EXTENSIONS).join(", ")}`));
|
||||||
|
}
|
||||||
|
return cb(null, true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildUploadUrl(req, filename) {
|
||||||
|
const encoded = encodeURIComponent(filename);
|
||||||
|
return `${req.protocol}://${req.get("host")}/uploads/${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUploadedFile(filePath) {
|
||||||
|
fs.unlink(filePath, (error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to clean up uploaded file:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/auth/discord", (req, res) => {
|
||||||
|
const state = crypto.randomBytes(20).toString("hex");
|
||||||
|
req.session.oauthState = state;
|
||||||
|
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
client_id: discordClientId,
|
||||||
|
redirect_uri: discordRedirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "identify",
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.redirect(`https://discord.com/oauth2/authorize?${query.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/auth/discord/callback", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.query.code || req.query.state !== req.session.oauthState) {
|
||||||
|
return res.status(400).send("Invalid OAuth callback.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenBody = new URLSearchParams({
|
||||||
|
client_id: discordClientId,
|
||||||
|
client_secret: discordClientSecret,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: String(req.query.code),
|
||||||
|
redirect_uri: discordRedirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: tokenBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
return res.status(401).send("Discord login failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
const userResponse = await fetch("https://discord.com/api/users/@me", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokenData.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userResponse.ok) {
|
||||||
|
return res.status(401).send("Failed to load Discord user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordUser = await userResponse.json();
|
||||||
|
const isAllowed = allowedSet.has(discordUser.id);
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
id: discordUser.id,
|
||||||
|
username: discordUser.username,
|
||||||
|
globalName: discordUser.global_name || null,
|
||||||
|
avatar: discordUser.avatar || null,
|
||||||
|
isAllowed,
|
||||||
|
};
|
||||||
|
req.session.oauthState = null;
|
||||||
|
|
||||||
|
return res.redirect("/");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Discord OAuth callback failed:", error);
|
||||||
|
return res.status(500).send("Discord authentication failed.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/logout", (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/session", (req, res) => {
|
||||||
|
const user = req.session.user || null;
|
||||||
|
res.json({
|
||||||
|
authenticated: Boolean(user),
|
||||||
|
authorized: Boolean(user && user.isAllowed),
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function requireAllowedSubmitter(req, res, next) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
return res.status(401).json({ error: "Please log in with Discord first." });
|
||||||
|
}
|
||||||
|
if (!req.session.user.isAllowed) {
|
||||||
|
return res.status(403).json({ error: "Your Discord account is not allowed to submit quotes." });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/users", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
users: users.map((user) => ({ id: user.id, displayName: user.displayName })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/quotes", requireAllowedSubmitter, upload.single("attachment"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = String(req.body.userId || "").trim();
|
||||||
|
const quoteText = String(req.body.quoteText || "").trim();
|
||||||
|
const attachmentUrl = req.file ? buildUploadUrl(req, req.file.filename) : null;
|
||||||
|
|
||||||
|
if (!userId || !quoteText) {
|
||||||
|
return res.status(400).json({ error: "userId and quoteText are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await onQuoteSubmit({
|
||||||
|
userId,
|
||||||
|
quoteText,
|
||||||
|
attachmentUrl,
|
||||||
|
creator: {
|
||||||
|
id: req.session.user.id,
|
||||||
|
username: req.session.user.username,
|
||||||
|
globalName: req.session.user.globalName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.status(201).json({ ok: true, attachmentUrl });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof multer.MulterError) {
|
||||||
|
if (error.code === "LIMIT_FILE_SIZE") {
|
||||||
|
return res.status(400).json({ error: "Uploaded file is too large (max 100 MB)." });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: error.message || "Invalid upload." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.file && req.file.path) {
|
||||||
|
removeUploadedFile(req.file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error.message === "string" && error.message.startsWith("Unsupported file type.")) {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Quote submission failed:", error);
|
||||||
|
return res.status(500).json({ error: error.message || "Failed to send quote." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createWebServer,
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user