From 82130fd898cfd3303cb0d1b5e51f7e7b0c250963 Mon Sep 17 00:00:00 2001 From: Patrick <147879351+WinniePatGG@users.noreply.github.com> Date: Fri, 1 May 2026 18:51:04 +0200 Subject: [PATCH] first commit --- .env.example | 9 ++ .gitignore | 5 + README.md | 73 +++++++++++ config/quotes.config.json | 55 ++++++++ index.js | 39 ++++++ package.json | 18 +++ public/app.js | 121 ++++++++++++++++++ public/index.html | 53 ++++++++ public/styles.css | 233 ++++++++++++++++++++++++++++++++++ scripts/check.js | 19 +++ src/bot.js | 213 +++++++++++++++++++++++++++++++ src/config.js | 68 ++++++++++ src/web.js | 256 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 1162 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/quotes.config.json create mode 100644 index.js create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 scripts/check.js create mode 100644 src/bot.js create mode 100644 src/config.js create mode 100644 src/web.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..054ad9e --- /dev/null +++ b/.env.example @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e681ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +node_modules +package-lock.json +.env +uploads diff --git a/README.md b/README.md new file mode 100644 index 0000000..6178337 --- /dev/null +++ b/README.md @@ -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/`). +- 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. + diff --git a/config/quotes.config.json b/config/quotes.config.json new file mode 100644 index 0000000..ba00b5f --- /dev/null +++ b/config/quotes.config.json @@ -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": "" + } + ] +} + diff --git a/index.js b/index.js new file mode 100644 index 0000000..56f38d1 --- /dev/null +++ b/index.js @@ -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); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..e809096 --- /dev/null +++ b/package.json @@ -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 +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..51c1779 --- /dev/null +++ b/public/app.js @@ -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"); +}); + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4cfb5fd --- /dev/null +++ b/public/index.html @@ -0,0 +1,53 @@ + + + + + + ZitateBot - Quote Submitter + + + +
+
+ +
+
+

ZitateBot

+

Select a speaker and submit a quote directly to Discord.

+ +
+

Checking Discord session...

+
+ Login with Discord + +
+
+ +
+ + + + + + + + +

Allowed: images, video, audio, txt, pdf, zip (max 100 MB).

+ + +

+
+
+
+ + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..7dd4ea9 --- /dev/null +++ b/public/styles.css @@ -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); + } +} + diff --git a/scripts/check.js b/scripts/check.js new file mode 100644 index 0000000..17bc059 --- /dev/null +++ b/scripts/check.js @@ -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); +} + diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..34c6146 --- /dev/null +++ b/src/bot.js @@ -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, +}; + diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..1c46f41 --- /dev/null +++ b/src/config.js @@ -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, +}; + diff --git a/src/web.js b/src/web.js new file mode 100644 index 0000000..2d92ea7 --- /dev/null +++ b/src/web.js @@ -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, +}; +