From 8e9f780cc24777a85722d7bbfdb497360091922e Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:38:45 +0000 Subject: [PATCH 1/3] Add exp_discord plugin for Discord login and account linking Players log in to the web interface with Discord through OAuth2 with the identify scope, so no bot token is needed. After logging in they get a code from the login page and type /link-discord in game to link that Factorio account. One Discord account can hold up to exp_discord.max_accounts Factorio accounts (default 3), and linking a name moves it off any other Discord account. Sessions and link codes live in memory on the controller plugin, the links are stored in database/exp_discord/accounts.json. The user page shows the linked Discord account and lets the user, or anyone with exp_discord.link.delete, unlink it. Co-Authored-By: Claude Opus 5.5 --- exp_discord/controller.ts | 390 ++++++++++++++++++ exp_discord/index.ts | 73 ++++ exp_discord/instance.ts | 82 ++++ exp_discord/messages.ts | 159 +++++++ exp_discord/module/commands.lua | 30 ++ exp_discord/module/control.lua | 88 ++++ exp_discord/module/globals.lua | 10 + exp_discord/module/locale/en.cfg | 10 + exp_discord/module/module.json | 14 + exp_discord/module/module_exports.lua | 3 + exp_discord/package.json | 54 +++ exp_discord/test/controller.test.js | 242 +++++++++++ exp_discord/tsconfig.browser.json | 4 + exp_discord/tsconfig.json | 7 + exp_discord/tsconfig.node.json | 5 + .../web/components/DiscordLoginForm.tsx | 161 ++++++++ .../web/components/UserDiscordLink.tsx | 55 +++ exp_discord/web/index.tsx | 19 + exp_discord/webpack.config.cjs | 34 ++ exp_scenario/permissions.ts | 2 + tsconfig.json | 1 + 21 files changed, 1443 insertions(+) create mode 100644 exp_discord/controller.ts create mode 100644 exp_discord/index.ts create mode 100644 exp_discord/instance.ts create mode 100644 exp_discord/messages.ts create mode 100644 exp_discord/module/commands.lua create mode 100644 exp_discord/module/control.lua create mode 100644 exp_discord/module/globals.lua create mode 100644 exp_discord/module/locale/en.cfg create mode 100644 exp_discord/module/module.json create mode 100644 exp_discord/module/module_exports.lua create mode 100644 exp_discord/package.json create mode 100644 exp_discord/test/controller.test.js create mode 100644 exp_discord/tsconfig.browser.json create mode 100644 exp_discord/tsconfig.json create mode 100644 exp_discord/tsconfig.node.json create mode 100644 exp_discord/web/components/DiscordLoginForm.tsx create mode 100644 exp_discord/web/components/UserDiscordLink.tsx create mode 100644 exp_discord/web/index.tsx create mode 100644 exp_discord/webpack.config.cjs diff --git a/exp_discord/controller.ts b/exp_discord/controller.ts new file mode 100644 index 00000000..b9a99379 --- /dev/null +++ b/exp_discord/controller.ts @@ -0,0 +1,390 @@ +import type { Controller, ControllerPluginContext } from "@clusterio/controller"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; +import * as crypto from "node:crypto"; +import * as path from "node:path"; + +const discordApi = "https://discord.com/api/v10"; +const stateTimeoutS = 10 * 60; +const sessionTimeoutMs = 30 * 60e3; +const codeTimeoutMs = 10 * 60e3; +const codeLength = 6; +// 0, O, 1 and I are left out because they are easy to mix up when typing the code in game +const codeLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +type DiscordUser = { id: string, name: string }; +type Session = DiscordUser & { expiresMs: number, code?: string }; +type LinkCode = { sessionId: string, expiresMs: number }; + +/** What an http route answers with, kept apart from express so it can be tested. */ +export type RouteResult = + | { redirect: string, cookie?: string } + | { status: number, json: unknown }; + +type HttpRequest = { + query: Record, + headers: Record, +}; +type HttpResponse = { + status(code: number): HttpResponse, + json(body: unknown): unknown, + redirect(url: string): void, + setHeader(name: string, value: string): unknown, +}; + +function readCookie(header: string | string[] | undefined, name: string) { + for (const part of String(header ?? "").split(";")) { + const [key, ...value] = part.trim().split("="); + if (key === name) { + return value.join("="); + } + } + return undefined; +} + +function randomCode() { + let code = ""; + for (let i = 0; i < codeLength; i++) { + code += codeLetters[crypto.randomInt(codeLetters.length)]; + } + return code; +} + +export class ControllerPlugin { + /** Logins which have been through Discord, by session id */ + sessions = new Map(); + /** Codes waiting to be used in game, by code */ + codes = new Map(); + cleanupInterval?: ReturnType; + + private constructor( + public controller: Controller, + public logger: lib.Logger, + public name: string, + public accounts: lib.SubscribableDatastore, + ) {} + + static async fromContext(context: ControllerPluginContext) { + const controller = context.controller; + const databaseDirectory = controller.config.get("controller.database_directory"); + + const accounts = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_discord", "accounts.json"), + messages.DiscordAccountRecord.fromJSON.bind(messages.DiscordAccountRecord), + ).bootstrap() + ); + + const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, accounts); + + controller.handle(messages.DiscordLinkRequest, plugin.handleLinkRequest.bind(plugin)); + controller.handle(messages.DiscordUnlinkRequest, plugin.handleUnlinkRequest.bind(plugin)); + controller.handle(messages.DiscordLinkGetRequest, plugin.handleLinkGetRequest.bind(plugin)); + + plugin.addRoutes(); + plugin.cleanupInterval = setInterval(() => plugin.removeExpired(), 60e3); + plugin.cleanupInterval.unref(); + + controller.hooks.save.attach(plugin.name, plugin.onSave.bind(plugin)); + controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); + return plugin; + } + + async onSave() { + await this.accounts.save(); + } + + async onShutdown() { + clearInterval(this.cleanupInterval); + await this.accounts.save(); + } + + removeExpired(now = Date.now()) { + for (const [id, session] of this.sessions) { + if (session.expiresMs < now) { + this.sessions.delete(id); + } + } + for (const [code, entry] of this.codes) { + if (entry.expiresMs < now) { + this.codes.delete(code); + } + } + } + + /* + Http routes + */ + + addRoutes() { + const app = this.controller.app; + const route = (handler: (req: HttpRequest) => Promise) => ( + (req: HttpRequest, res: HttpResponse, next: (err: unknown) => void) => { + handler(req).then(result => { + if ("redirect" in result) { + if (result.cookie) { + res.setHeader("Set-Cookie", result.cookie); + } + res.redirect(result.redirect); + } else { + res.status(result.status).json(result.json); + } + }).catch(next); + } + ); + + app.get("/api/exp_discord/authorize", route(async () => this.authorize())); + app.get("/api/exp_discord/callback", route(async req => this.callback( + req.query, readCookie(req.headers.cookie, "exp_discord_state"), + ))); + app.get("/api/exp_discord/accounts", route(async req => this.listAccounts(this.sessionId(req)))); + app.post("/api/exp_discord/login", route(async req => this.login(this.sessionId(req), req.query.name))); + app.post("/api/exp_discord/link_code", route(async req => this.linkCode(this.sessionId(req)))); + } + + sessionId(req: HttpRequest) { + const header = String(req.headers.authorization ?? ""); + return header.startsWith("Bearer ") ? header.slice(7) : ""; + } + + redirectUri() { + return new URL("api/exp_discord/callback", this.controller.getControllerUrl()).href; + } + + /** Back to the web ui, with the outcome in the fragment so it never reaches a server log. */ + webRedirect(key: "session" | "error", value: string): RouteResult { + const url = new URL(this.controller.getControllerUrl()); + url.hash = `exp_discord_${key}=${encodeURIComponent(value)}`; + return { redirect: url.href }; + } + + async authorize(): Promise { + const clientId = this.controller.config.get("exp_discord.client_id"); + if (!clientId || !this.controller.config.get("exp_discord.client_secret")) { + return this.webRedirect("error", "Discord login is not configured on this controller"); + } + + const state = crypto.randomBytes(16).toString("base64url"); + const url = new URL("https://discord.com/oauth2/authorize"); + url.search = new URLSearchParams({ + response_type: "code", + client_id: clientId, + scope: "identify", + state, + redirect_uri: this.redirectUri(), + prompt: "none", + }).toString(); + + const callbackUrl = new URL(this.redirectUri()); + const secure = callbackUrl.protocol === "https:" ? "; Secure" : ""; + return { + redirect: url.href, + cookie: `exp_discord_state=${state}; Path=${callbackUrl.pathname}; Max-Age=${stateTimeoutS}; HttpOnly; SameSite=Lax${secure}`, + }; + } + + async callback(query: Record, cookieState: string | undefined): Promise { + if (typeof query.error === "string") { + return this.webRedirect("error", query.error === "access_denied" ? "Discord login was cancelled" : query.error); + } + if (typeof query.state !== "string" || !cookieState || query.state !== cookieState) { + return this.webRedirect("error", "Discord login expired, please try again"); + } + if (typeof query.code !== "string") { + return this.webRedirect("error", "Discord did not return a login code"); + } + + let discordUser; + try { + discordUser = await this.fetchDiscordUser(query.code); + } catch (err: any) { + this.logger.warn(`Discord login failed: ${err.message}`); + return this.webRedirect("error", "Discord login failed, please try again"); + } + + const account = this.accounts.getMutable(discordUser.id); + if (account && account.name !== discordUser.name) { + account.name = discordUser.name; + this.accounts.set(account); + } + + const sessionId = crypto.randomBytes(32).toString("base64url"); + this.sessions.set(sessionId, { ...discordUser, expiresMs: Date.now() + sessionTimeoutMs }); + return this.webRedirect("session", sessionId); + } + + /** Exchange the code from the callback for the Discord user who logged in. */ + async fetchDiscordUser(code: string): Promise { + const tokenResponse = await fetch(`${discordApi}/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: this.controller.config.get("exp_discord.client_id") ?? "", + client_secret: this.controller.config.get("exp_discord.client_secret") ?? "", + grant_type: "authorization_code", + code, + redirect_uri: this.redirectUri(), + }), + }); + if (!tokenResponse.ok) { + throw new Error(`token exchange responded with ${tokenResponse.status}: ${await tokenResponse.text()}`); + } + const token = await tokenResponse.json() as { access_token: string, token_type: string }; + + const userResponse = await fetch(`${discordApi}/users/@me`, { + headers: { Authorization: `${token.token_type} ${token.access_token}` }, + }); + if (!userResponse.ok) { + throw new Error(`users/@me responded with ${userResponse.status}`); + } + const user = await userResponse.json() as { id: string, username: string, global_name?: string | null }; + return { id: user.id, name: user.global_name || user.username }; + } + + getSession(sessionId: string) { + const session = this.sessions.get(sessionId); + if (!session || session.expiresMs < Date.now()) { + return undefined; + } + return session; + } + + async listAccounts(sessionId: string): Promise { + const session = this.getSession(sessionId); + if (!session) { + return { status: 401, json: { error: "Discord login expired, please log in again" } }; + } + return { + status: 200, + json: { + discord: { id: session.id, name: session.name }, + accounts: this.accounts.get(session.id)?.factorioNames ?? [], + max_accounts: this.controller.config.get("exp_discord.max_accounts"), + }, + }; + } + + async login(sessionId: string, name: unknown): Promise { + const session = this.getSession(sessionId); + if (!session) { + return { status: 401, json: { error: "Discord login expired, please log in again" } }; + } + + const factorioNames = this.accounts.get(session.id)?.factorioNames ?? []; + if (typeof name !== "string" || !factorioNames.some(linked => linked.toLowerCase() === name.toLowerCase())) { + return { status: 403, json: { error: "That Factorio account is not linked to your Discord account" } }; + } + + const user = this.controller.users.getByName(name); + if (!user) { + return { status: 404, json: { error: `There is no user called ${name}` } }; + } + if (user.isBanned) { + return { status: 403, json: { error: `${user.name} is banned` } }; + } + + this.sessions.delete(sessionId); + if (session.code) { + this.codes.delete(session.code); + } + return { status: 200, json: { token: this.controller.users.signUserToken(user) } }; + } + + /** A code which links the player who types it in game to this Discord account. */ + async linkCode(sessionId: string): Promise { + const session = this.getSession(sessionId); + if (!session) { + return { status: 401, json: { error: "Discord login expired, please log in again" } }; + } + + const now = Date.now(); + let entry = session.code ? this.codes.get(session.code) : undefined; + if (!entry || entry.expiresMs < now) { + let code = randomCode(); + while (this.codes.has(code)) { + code = randomCode(); + } + entry = { sessionId, expiresMs: now + codeTimeoutMs }; + this.codes.set(code, entry); + session.code = code; + } + return { status: 200, json: { code: session.code, expires_ms: entry.expiresMs } }; + } + + /* + Links + */ + + findAccount(playerName: string) { + const name = playerName.toLowerCase(); + for (const account of this.accounts.values()) { + if (account.factorioNames.some(linked => linked.toLowerCase() === name)) { + return account; + } + } + return undefined; + } + + /** Remove a player from the account they are linked to, the account goes once nothing is left. */ + removeLink(playerName: string) { + const found = this.findAccount(playerName); + if (!found) { + return null; + } + + const account = this.accounts.getMutable(found.id)!; + const name = playerName.toLowerCase(); + account.factorioNames = account.factorioNames.filter(linked => linked.toLowerCase() !== name); + if (account.factorioNames.length) { + this.accounts.set(account); + } else { + this.accounts.delete(account); + } + return { id: account.id, name: account.name }; + } + + async handleLinkRequest(request: messages.DiscordLinkRequest) { + const code = request.code.trim().toUpperCase(); + const entry = this.codes.get(code); + const session = entry ? this.sessions.get(entry.sessionId) : undefined; + if (!entry || !session || entry.expiresMs < Date.now()) { + throw new lib.RequestError("This code is invalid or has expired, get a new one from the website"); + } + + const name = request.playerName.toLowerCase(); + const existing = this.accounts.get(session.id); + const alreadyLinked = existing?.factorioNames.some(linked => linked.toLowerCase() === name); + const maxAccounts = this.controller.config.get("exp_discord.max_accounts"); + if (!alreadyLinked && (existing?.factorioNames.length ?? 0) >= maxAccounts) { + throw new lib.RequestError( + `${session.name} already has ${maxAccounts} Factorio accounts linked, unlink one first` + ); + } + + this.codes.delete(code); + delete session.code; + if (!alreadyLinked) { + // The player proved they own this Factorio account, so it moves over from any other Discord account + this.removeLink(request.playerName); + const account = this.accounts.getMutable(session.id) + ?? new messages.DiscordAccountRecord(session.id, session.name, []); + account.name = session.name; + account.factorioNames = [...account.factorioNames, request.playerName]; + this.accounts.set(account); + } + return { id: session.id, name: session.name }; + } + + async handleUnlinkRequest(request: messages.DiscordUnlinkRequest) { + return this.removeLink(request.playerName); + } + + async handleLinkGetRequest(request: messages.DiscordLinkGetRequest) { + const account = this.findAccount(request.playerName); + return account ? { id: account.id, name: account.name } : null; + } +} + +export default async function (context: ControllerPluginContext) { + await ControllerPlugin.fromContext(context); +} diff --git a/exp_discord/index.ts b/exp_discord/index.ts new file mode 100644 index 00000000..da0bbe2c --- /dev/null +++ b/exp_discord/index.ts @@ -0,0 +1,73 @@ +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; + +declare module "@clusterio/lib" { + export interface ControllerConfigFields { + "exp_discord.client_id": string | null; + "exp_discord.client_secret": string | null; + "exp_discord.max_accounts": number; + } + export interface Permissions { + "exp_discord.link.get": never; + "exp_discord.link.delete": never; + } +} + +export const plugin: lib.PluginDeclaration = { + name: "exp_discord", + title: "ExpGaming - Discord", + description: "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord", + + features: [ + "SavePatching", + "ScriptCommands", + ], + + messages: [ + messages.DiscordLinkRequest, + messages.DiscordUnlinkRequest, + messages.DiscordLinkGetRequest, + ], + + permissions: [ + { + name: "exp_discord.link.get", + title: "Get Discord Links", + description: "See which Discord account any user is linked to, users can always see their own.", + grantByDefault: false, + }, + { + name: "exp_discord.link.delete", + title: "Remove Discord Links", + description: "Unlink any user from their Discord account, users can always unlink themselves.", + grantByDefault: false, + }, + ], + + instanceEntrypoint: "./dist/node/instance.js", + + controllerEntrypoint: "./dist/node/controller.js", + controllerConfigFields: { + "exp_discord.client_id": { + title: "Client ID", + description: "Application ID of the Discord application used for logging in.", + type: "string", + optional: true, + }, + "exp_discord.client_secret": { + title: "Client Secret", + description: "OAuth2 client secret of the Discord application used for logging in.", + type: "string", + credential: ["controller"], + optional: true, + }, + "exp_discord.max_accounts": { + title: "Max Accounts", + description: "Number of Factorio accounts which can be linked to one Discord account.", + type: "number", + initialValue: 3, + }, + }, + + webEntrypoint: "./web", +}; diff --git a/exp_discord/instance.ts b/exp_discord/instance.ts new file mode 100644 index 00000000..622cc78b --- /dev/null +++ b/exp_discord/instance.ts @@ -0,0 +1,82 @@ +import type { Instance, InstancePluginContext } from "@clusterio/host"; +import type * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; + +/** Sent by the lua side when a player uses a link code. */ +export type IpcLink = { + player_name: string, + code: string, +}; + +/** Sent by the lua side when a player unlinks their Discord account, or asks which one they are linked to. */ +export type IpcPlayer = { + player_name: string, +}; + +/** Bridges the lua side and the controller, the answer is printed to the player once it arrives. */ +export class InstancePlugin { + private constructor( + public instance: Instance, + public logger: lib.Logger, + public name: string, + ) {} + + static async fromContext(context: InstancePluginContext) { + const instance = context.instance; + const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); + + instance.server.handle("exp_discord:link", plugin.handleLinkIPC.bind(plugin)); + instance.server.handle("exp_discord:unlink", plugin.handleUnlinkIPC.bind(plugin)); + instance.server.handle("exp_discord:get", plugin.handleGetIPC.bind(plugin)); + return plugin; + } + + async handleLinkIPC(event: IpcLink) { + try { + const discord = await this.instance.sendTo("controller", new messages.DiscordLinkRequest( + event.player_name, event.code, + )); + await this.luaSend("receive_linked", { player_name: event.player_name, discord_name: discord!.name }); + } catch (err: any) { + await this.luaSend("receive_error", { player_name: event.player_name, message: err.message }); + } + } + + async handleUnlinkIPC(event: IpcPlayer) { + try { + const discord = await this.instance.sendTo("controller", new messages.DiscordUnlinkRequest(event.player_name)); + await this.luaSend("receive_unlinked", { player_name: event.player_name, discord_name: discord?.name }); + } catch (err: any) { + await this.luaSend("receive_error", { player_name: event.player_name, message: err.message }); + } + } + + async handleGetIPC(event: IpcPlayer) { + try { + const discord = await this.instance.sendTo("controller", new messages.DiscordLinkGetRequest(event.player_name)); + await this.luaSend("receive_get", { player_name: event.player_name, discord_name: discord?.name }); + } catch (err: any) { + await this.luaSend("receive_error", { player_name: event.player_name, message: err.message }); + } + } + + /** Hand an answer to lua, unless the instance stopped while it was being fetched. */ + async luaSend(receiver: string, json: any) { + if (this.instance.status !== "running") { + return; + } + // Discord names can contain anything, so pick a long bracket level the json does not close + const text = JSON.stringify(json); + let level = "="; + while (text.includes(`]${level}]`)) { + level += "="; + } + await this.instance.sendRcon( + `/sc exp_discord.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true + ); + } +} + +export default async function (context: InstancePluginContext) { + await InstancePlugin.fromContext(context); +} diff --git a/exp_discord/messages.ts b/exp_discord/messages.ts new file mode 100644 index 00000000..ab71d22a --- /dev/null +++ b/exp_discord/messages.ts @@ -0,0 +1,159 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +/* + Data records +*/ + +/** A Discord account and the Factorio accounts linked to it. */ +export class DiscordAccountRecord { + constructor( + /** Discord user id */ + public id: string, + /** Discord display name at the time of the last login */ + public name: string, + public factorioNames: string[], + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + name: Type.String(), + factorio_names: Type.Array(Type.String()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + name: this.name, + factorio_names: this.factorioNames, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.name, + json.factorio_names, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/** Users may always act on their own link, otherwise the permission is needed. */ +function selfOrPermission(permission: lib.PermissionName) { + return (user: lib.IUser, message: lib.MessageRequest) => { + const playerName = (message.data as { player_name: string }).player_name; + if (user.name.toLowerCase() !== playerName.toLowerCase()) { + user.checkPermission(permission); + } + }; +} + +const LinkResponse = lib.plainJson(Type.Union([ + Type.Null(), + Type.Object({ + id: Type.String(), + name: Type.String(), + }), +])); + +/* + Link requests +*/ + +/** Link a player to the Discord account which generated the code, sent when the player uses the code in game. */ +export class DiscordLinkRequest { + declare ["constructor"]: typeof DiscordLinkRequest; + static plugin = "exp_discord" as const; + static type = "request" as const; + static src = "instance" as const; + static dst = "controller" as const; + static Response = LinkResponse; + + constructor( + public playerName: string, + public code: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + code: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName, code: this.code }; + } + + static fromJSON(json: Static) { + return new this(json.player_name, json.code); + } +} + +/** Remove the link of a player, responds with the account it was linked to. */ +export class DiscordUnlinkRequest { + declare ["constructor"]: typeof DiscordUnlinkRequest; + static plugin = "exp_discord" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = selfOrPermission("exp_discord.link.delete"); + static Response = LinkResponse; + + constructor( + public playerName: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName }; + } + + static fromJSON(json: Static) { + return new this(json.player_name); + } +} + +/** Get the Discord account a player is linked to. */ +export class DiscordLinkGetRequest { + declare ["constructor"]: typeof DiscordLinkGetRequest; + static plugin = "exp_discord" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = selfOrPermission("exp_discord.link.get"); + static Response = LinkResponse; + + constructor( + public playerName: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName }; + } + + static fromJSON(json: Static) { + return new this(json.player_name); + } +} diff --git a/exp_discord/module/commands.lua b/exp_discord/module/commands.lua new file mode 100644 index 00000000..eb8f3df6 --- /dev/null +++ b/exp_discord/module/commands.lua @@ -0,0 +1,30 @@ +--[[-- ExpDiscord - Commands +Commands which link and unlink the player's Discord account +]] + +local Commands = require("modules/exp_commands") + +local Discord = require("modules/exp_discord") + +--- Links your account to the Discord account which made the code, printed once the controller answers +Commands.new("link-discord", { "exp-discord_commands.description-link" }) + :optional("code", { "exp-discord_commands.arg-code" }, Commands.types.string) + :add_aliases{ "link" } + :register(function(player, code) + --- @cast code string? + if code then + Discord.link(player, code) + else + Discord.get_link(player) + end + end) + +--- Removes the link to your Discord account, printed once the controller answers +Commands.new("unlink-discord", { "exp-discord_commands.description-unlink" }) + :add_aliases{ "unlink" } + :register(function(player) + Discord.unlink(player) + end) + +-- Access the exports from other modules using require("modules/exp_discord") +return require("modules/exp_discord/control") diff --git a/exp_discord/module/control.lua b/exp_discord/module/control.lua new file mode 100644 index 00000000..2aa6869a --- /dev/null +++ b/exp_discord/module/control.lua @@ -0,0 +1,88 @@ +--[[-- ExpDiscord +Links Factorio accounts to Discord accounts, the links are kept on the controller. + +A player logs in with Discord on the web interface, which gives them a code. +Typing that code in game links the account they are playing on. Nothing is +stored in the lua state, every call goes to the controller and the answer is +printed to the player once it arrives, if they are still online. +]] + +local clusterio_api = require("modules/clusterio/api") +local ExpUtil = require("modules/exp_util") + +--- @class ExpDiscord +local ExpDiscord = {} + +local error_settings = { color = ExpUtil.color.orange_red, sound_path = "utility/wire_pickup" } + +--- Link the player to the Discord account which made the code +--- @param player LuaPlayer +--- @param code string +function ExpDiscord.link(player, code) + clusterio_api.send_json("exp_discord:link", { player_name = player.name, code = code }) +end + +--- Remove the link between the player and their Discord account +--- @param player LuaPlayer +function ExpDiscord.unlink(player) + clusterio_api.send_json("exp_discord:unlink", { player_name = player.name }) +end + +--- Print the Discord account the player is linked to +--- @param player LuaPlayer +function ExpDiscord.get_link(player) + clusterio_api.send_json("exp_discord:get", { player_name = player.name }) +end + +--- A player who can still be printed to, nil once they have left +--- @param name string +--- @return LuaPlayer? +local function get_online_player(name) + local player = game.get_player(name) + if player and player.valid and player.connected then + return player + end + return nil +end + +--- The controller linked the player +--- @param payload { player_name: string, discord_name: string } +function ExpDiscord.receive_linked(payload) + local player = get_online_player(payload.player_name) + if not player then return end + player.print{ "exp-discord.linked", payload.discord_name } +end + +--- The controller removed the link, discord_name is nil if there was none +--- @param payload { player_name: string, discord_name: string? } +function ExpDiscord.receive_unlinked(payload) + local player = get_online_player(payload.player_name) + if not player then return end + if payload.discord_name then + player.print{ "exp-discord.unlinked", payload.discord_name } + else + player.print({ "exp-discord.not-linked" }, error_settings) + end +end + +--- The controller answered which account the player is linked to +--- @param payload { player_name: string, discord_name: string? } +function ExpDiscord.receive_get(payload) + local player = get_online_player(payload.player_name) + if not player then return end + if payload.discord_name then + player.print{ "exp-discord.linked-to", payload.discord_name } + else + player.print{ "exp-discord.not-linked" } + end +end + +--- The controller refused a request +--- @param payload { player_name: string, message: string } +function ExpDiscord.receive_error(payload) + local player = get_online_player(payload.player_name) + if not player then return end + player.print(payload.message, error_settings) +end + +return ExpDiscord diff --git a/exp_discord/module/globals.lua b/exp_discord/module/globals.lua new file mode 100644 index 00000000..862e1014 --- /dev/null +++ b/exp_discord/module/globals.lua @@ -0,0 +1,10 @@ +--[[ +It is best practice to not expose any globals because all modules share a global environment +However, sometimes you need globals, for example to access functions within rcon commands +Therefore, we advise that this should be the only file in your module to expose globals +]] + +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_discord.foo()` +exp_discord = require("modules/exp_discord/control") diff --git a/exp_discord/module/locale/en.cfg b/exp_discord/module/locale/en.cfg new file mode 100644 index 00000000..57d0ac13 --- /dev/null +++ b/exp_discord/module/locale/en.cfg @@ -0,0 +1,10 @@ +[exp-discord] +linked=Your account is now linked to the Discord account __1__. +linked-to=Your account is linked to the Discord account __1__. Use /unlink-discord to remove it. +unlinked=Your account is no longer linked to the Discord account __1__. +not-linked=Your account is not linked to a Discord account. Log in with Discord on the website to get a code, then use /link-discord . + +[exp-discord_commands] +description-link=Links your account to Discord using the code from the website, or shows the account you are linked to. +description-unlink=Removes the link between your account and Discord. +arg-code=Code shown on the website after logging in with Discord. diff --git a/exp_discord/module/module.json b/exp_discord/module/module.json new file mode 100644 index 00000000..7fea5b7c --- /dev/null +++ b/exp_discord/module/module.json @@ -0,0 +1,14 @@ +{ + "name": "exp_discord", + "load": [ + ], + "require": [ + "globals.lua", + "commands.lua" + ], + "dependencies": { + "clusterio": "*", + "exp_util": "*", + "exp_commands": "*" + } +} diff --git a/exp_discord/module/module_exports.lua b/exp_discord/module/module_exports.lua new file mode 100644 index 00000000..77d115e0 --- /dev/null +++ b/exp_discord/module/module_exports.lua @@ -0,0 +1,3 @@ + +-- Access the exports from other modules using require("modules/exp_discord") +return require("modules/exp_discord/control") diff --git a/exp_discord/package.json b/exp_discord/package.json new file mode 100644 index 00000000..80694ab6 --- /dev/null +++ b/exp_discord/package.json @@ -0,0 +1,54 @@ +{ + "name": "@expcluster/discord", + "version": "7.0.0", + "description": "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "type": "module", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --env production", + "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^" + }, + "devDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "tap": "^21.1.0", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + }, + "dependencies": { + "@sinclair/typebox": "catalog:", + "@expcluster/lib_commands": "workspace:^", + "@expcluster/lib_util": "workspace:^" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_discord/test/controller.test.js b/exp_discord/test/controller.test.js new file mode 100644 index 00000000..b06869f3 --- /dev/null +++ b/exp_discord/test/controller.test.js @@ -0,0 +1,242 @@ +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Controller } from "@clusterio/controller"; +import entrypoint, { ControllerPlugin } from "../dist/node/controller.js"; +import * as messages from "../dist/node/messages.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; + +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} +lib.addPluginConfigFields([pluginDeclaration]); + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +lib.logger.silent = true; +t.after(() => { + lib.logger.silent = false; +}); + +const alice = { id: "100", name: "Alice" }; +const bob = { id: "200", name: "Bob" }; + +/** Build the plugin around a real controller, which is side effect free while not started. */ +async function startPlugin(t2, { config = {} } = {}) { + const controllerConfig = new lib.ControllerConfig("controller", { + // testdir() empties the folder, so a reload passes the directory in + "controller.database_directory": config["controller.database_directory"] ?? t2.testdir(), + "controller.public_url": "https://example.com/cluster/", + "exp_discord.client_id": "123", + "exp_discord.client_secret": "secret", + "controller.auth_secret": Buffer.from("test secret").toString("base64"), + ...config, + }); + const controller = new Controller(logger, [], controllerConfig); + for (const name of ["Player1", "Player2", "Player3", "Player4"]) { + controller.users.createUser(name); + } + + const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_discord" }, controller, logger }); + t2.teardown(() => clearInterval(plugin.cleanupInterval)); + return { plugin, controller }; +} + +/** Go through the Discord callback as the given Discord user, returning the session id. */ +async function discordLogin(plugin, discordUser) { + plugin.fetchDiscordUser = async () => discordUser; + const result = await plugin.callback({ code: "c", state: "s" }, "s"); + return decodeURIComponent(new URL(result.redirect).hash.split("=")[1]); +} + +/** Get a code on the web and use it in game as playerName. */ +async function link(plugin, session, playerName) { + const { json } = await plugin.linkCode(session); + return plugin.handleLinkRequest(new messages.DiscordLinkRequest(playerName, json.code.toLowerCase())); +} + +t.test("entrypoint", async t2 => { + const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); + const controller = new Controller(logger, [], controllerConfig); + await entrypoint({ controller, logger, plugin: { name: "exp_discord" }, metrics: undefined }); + for (const hook of ["save", "shutdown"]) { + t2.ok([...controller.hooks[hook].attached].includes("exp_discord"), `attached to ${hook}`); + } + await controller.hooks.shutdown.invoke(); +}); + +t.test("oauth", t2 => { + t2.test(".authorize() redirects to Discord with a state cookie scoped to the callback", async t3 => { + const { plugin } = await startPlugin(t3); + const result = await plugin.authorize(); + const url = new URL(result.redirect); + t3.equal(url.origin + url.pathname, "https://discord.com/oauth2/authorize"); + t3.equal(url.searchParams.get("client_id"), "123"); + t3.equal(url.searchParams.get("redirect_uri"), "https://example.com/cluster/api/exp_discord/callback"); + const state = url.searchParams.get("state"); + t3.match(result.cookie, `exp_discord_state=${state}; Path=/cluster/api/exp_discord/callback`); + t3.match(result.cookie, "Secure"); + }); + + t2.test(".authorize() without credentials goes back with an error", async t3 => { + const { plugin } = await startPlugin(t3, { config: { "exp_discord.client_secret": null } }); + const result = await plugin.authorize(); + t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_error=/); + }); + + t2.test(".callback() rejects a state which does not match the cookie", async t3 => { + const { plugin } = await startPlugin(t3); + plugin.fetchDiscordUser = async () => t3.fail("should not fetch"); + for (const cookie of [undefined, "other"]) { + const result = await plugin.callback({ code: "c", state: "s" }, cookie); + t3.match(result.redirect, "#exp_discord_error="); + } + t3.equal(plugin.sessions.size, 0); + }); + + t2.test(".callback() reports a failed token exchange as an error", async t3 => { + const { plugin } = await startPlugin(t3); + plugin.fetchDiscordUser = async () => { throw new Error("401"); }; + const result = await plugin.callback({ code: "c", state: "s" }, "s"); + t3.match(result.redirect, "#exp_discord_error="); + }); + + t2.test(".callback() starts a session", async t3 => { + const { plugin } = await startPlugin(t3); + const session = await discordLogin(plugin, alice); + const result = await plugin.listAccounts(session); + t3.same(result, { status: 200, json: { discord: alice, accounts: [], max_accounts: 3 } }); + }); + + t2.test(".listAccounts() rejects unknown and expired sessions", async t3 => { + const { plugin } = await startPlugin(t3); + t3.equal((await plugin.listAccounts("nope")).status, 401); + const session = await discordLogin(plugin, alice); + plugin.sessions.get(session).expiresMs = Date.now() - 1; + t3.equal((await plugin.listAccounts(session)).status, 401); + }); + t2.end(); +}); + +t.test("linking", t2 => { + t2.test("a code links the player who uses it, and they can then log in", async t3 => { + const { plugin, controller } = await startPlugin(t3); + const session = await discordLogin(plugin, alice); + t3.same(await link(plugin, session, "Player1"), alice); + t3.same((await plugin.listAccounts(session)).json.accounts, ["Player1"]); + + const result = await plugin.login(session, "player1"); + t3.equal(result.status, 200); + const payload = JSON.parse(Buffer.from(result.json.token.split(".")[1], "base64url").toString()); + t3.equal(payload.user, controller.users.getByName("Player1").id); + t3.notOk(plugin.sessions.has(session), "session is used up"); + }); + + t2.test("a code can only be used once", async t3 => { + const { plugin } = await startPlugin(t3); + const session = await discordLogin(plugin, alice); + const { json } = await plugin.linkCode(session); + await plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code)); + await t3.rejects( + plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player2", json.code)), /invalid or has expired/, + ); + }); + + t2.test("asking twice gives the same code", async t3 => { + const { plugin } = await startPlugin(t3); + const session = await discordLogin(plugin, alice); + t3.equal((await plugin.linkCode(session)).json.code, (await plugin.linkCode(session)).json.code); + }); + + t2.test("expired codes are refused", async t3 => { + const { plugin } = await startPlugin(t3); + const session = await discordLogin(plugin, alice); + const { json } = await plugin.linkCode(session); + plugin.codes.get(json.code).expiresMs = Date.now() - 1; + await t3.rejects(plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code))); + }); + + t2.test("several accounts up to the limit", async t3 => { + const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 2 } }); + const session = await discordLogin(plugin, alice); + await link(plugin, session, "Player1"); + await link(plugin, session, "Player2"); + await t3.rejects(link(plugin, session, "Player3"), /already has 2 Factorio accounts/); + t3.same(await link(plugin, session, "PLAYER1"), alice, "relinking a linked account is fine at the limit"); + t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1", "Player2"]); + }); + + t2.test("linking moves the account away from another Discord account", async t3 => { + const { plugin } = await startPlugin(t3); + await link(plugin, await discordLogin(plugin, alice), "Player1"); + await link(plugin, await discordLogin(plugin, bob), "Player1"); + t3.notOk(plugin.accounts.has(alice.id), "empty account is removed"); + t3.same(plugin.accounts.get(bob.id).factorioNames, ["Player1"]); + }); + + t2.test(".login() refuses accounts not linked, missing users and banned users", async t3 => { + const { plugin, controller } = await startPlugin(t3); + const session = await discordLogin(plugin, alice); + t3.equal((await plugin.login(session, "Player2")).status, 403); + + await link(plugin, session, "Player1"); + controller.users.getByNameMutable("Player1").set("isBanned", true); + t3.equal((await plugin.login(session, "Player1")).status, 403); + + await link(plugin, session, "Ghost"); + t3.equal((await plugin.login(session, "Ghost")).status, 404); + }); + + t2.test("unlink and get", async t3 => { + const { plugin } = await startPlugin(t3); + await link(plugin, await discordLogin(plugin, alice), "Player1"); + t3.same(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("player1")), alice); + t3.same(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), alice); + t3.equal(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("Player1")), null); + t3.equal(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), null); + }); + + t2.test("links are saved and loaded", async t3 => { + const dir = t3.testdir(); + const config = { "controller.database_directory": dir }; + const { plugin } = await startPlugin(t3, { config }); + await link(plugin, await discordLogin(plugin, alice), "Player1"); + await plugin.onShutdown(); + + const { plugin: reloaded } = await startPlugin(t3, { config }); + t3.same(reloaded.accounts.get(alice.id).factorioNames, ["Player1"]); + }); + + t2.test(".removeExpired() drops old sessions and codes", async t3 => { + const { plugin } = await startPlugin(t3); + const session = await discordLogin(plugin, alice); + await plugin.linkCode(session); + plugin.removeExpired(Date.now() + 60 * 60e3); + t3.equal(plugin.sessions.size, 0); + t3.equal(plugin.codes.size, 0); + }); + t2.end(); +}); + +t.test("permissions", t2 => { + const user = (name, granted = []) => ({ + name, + checkPermission(permission) { + if (!granted.includes(permission)) { + throw new lib.PermissionError("denied"); + } + }, + }); + const request = name => ({ data: { player_name: name } }); + + for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) { + t2.test(Request.name, async t3 => { + const permission = Request === messages.DiscordUnlinkRequest + ? "exp_discord.link.delete" + : "exp_discord.link.get"; + t3.doesNotThrow(() => Request.permission(user("Alice"), request("alice")), "own link"); + t3.throws(() => Request.permission(user("Alice"), request("Bob")), "someone else's"); + t3.doesNotThrow(() => Request.permission(user("Alice", [permission]), request("Bob")), "with permission"); + }); + } + t2.end(); +}); diff --git a/exp_discord/tsconfig.browser.json b/exp_discord/tsconfig.browser.json new file mode 100644 index 00000000..a621b69f --- /dev/null +++ b/exp_discord/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ], +} diff --git a/exp_discord/tsconfig.json b/exp_discord/tsconfig.json new file mode 100644 index 00000000..303b8f28 --- /dev/null +++ b/exp_discord/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/exp_discord/tsconfig.node.json b/exp_discord/tsconfig.node.json new file mode 100644 index 00000000..3218f2e7 --- /dev/null +++ b/exp_discord/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/exp_discord/web/components/DiscordLoginForm.tsx b/exp_discord/web/components/DiscordLoginForm.tsx new file mode 100644 index 00000000..39f7645e --- /dev/null +++ b/exp_discord/web/components/DiscordLoginForm.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Alert, Button, Space, Spin, Typography } from "antd"; + +declare const webRoot: string; + +const { Paragraph, Text } = Typography; +const sessionKey = "exp_discord_session"; + +type Accounts = { + discord: { id: string, name: string }, + accounts: string[], + max_accounts: number, +}; + +async function api(method: "GET" | "POST", route: string, session: string) { + const response = await fetch(`${webRoot}api/exp_discord/${route}`, { + method, + headers: { Authorization: `Bearer ${session}` }, + }); + const json = await response.json(); + if (!response.ok) { + throw new Error(json.error ?? `Bad server response: ${response.status}`); + } + return json; +} + +/** Take the outcome of the Discord redirect out of the url, so a reload does not reuse it. */ +function readRedirect() { + const params = new URLSearchParams(window.location.hash.slice(1)); + const session = params.get("exp_discord_session"); + const error = params.get("exp_discord_error"); + if (session || error) { + window.history.replaceState(null, "", window.location.pathname + window.location.search); + } + if (session) { + sessionStorage.setItem(sessionKey, session); + } + return { session: session ?? sessionStorage.getItem(sessionKey), error }; +} + +export default function DiscordLoginForm(props: { setToken(token: string): void }) { + const [redirect] = useState(readRedirect); + const [session, setSession] = useState(redirect.session); + const [error, setError] = useState(redirect.error); + const [accounts, setAccounts] = useState(null); + const [code, setCode] = useState(null); + const linkedCount = useRef(null); + + function forget(message: string | null) { + sessionStorage.removeItem(sessionKey); + setSession(null); + setAccounts(null); + setCode(null); + linkedCount.current = null; + setError(message); + } + + useEffect(() => { + if (!session) { + return undefined; + } + + let cancelled = false; + async function load() { + try { + const json = await api("GET", "accounts", session!) as Accounts; + if (!cancelled) { + // The code was used once a new account shows up + if (linkedCount.current !== null && json.accounts.length > linkedCount.current) { + setCode(null); + } + linkedCount.current = json.accounts.length; + setAccounts(json); + } + } catch (err: any) { + if (!cancelled) { + forget(err.message); + } + } + } + + load(); + // While a code is shown, poll until it is used in game + const interval = code ? setInterval(load, 3e3) : undefined; + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [session, code]); + + async function login(name: string) { + try { + const json = await api("POST", `login?name=${encodeURIComponent(name)}`, session!); + sessionStorage.removeItem(sessionKey); + props.setToken(json.token); + } catch (err: any) { + setError(err.message); + } + } + + async function getCode() { + try { + const json = await api("POST", "link_code", session!); + setCode(json.code); + } catch (err: any) { + forget(err.message); + } + } + + const errorAlert = error && ; + + if (!session) { + return <> + {errorAlert} + Log in with your Discord account. Your Factorio account needs to be linked to it first, which you can do after logging in. + + ; + } + + if (!accounts) { + return ; + } + + const canLinkMore = accounts.accounts.length < accounts.max_accounts; + return <> + {errorAlert} + Logged in to Discord as {accounts.discord.name}. + + {accounts.accounts.length + ? + {accounts.accounts.map(name => )} + + : No Factorio accounts are linked to this Discord account yet.} + + {code + ? + Join any of our servers and type this in the chat: + + /link-discord {code} + + The code is valid for 10 minutes. This page continues once it has been used. + } + /> + : canLinkMore && + + } + + + ; +} diff --git a/exp_discord/web/components/UserDiscordLink.tsx b/exp_discord/web/components/UserDiscordLink.tsx new file mode 100644 index 00000000..afb0d4a3 --- /dev/null +++ b/exp_discord/web/components/UserDiscordLink.tsx @@ -0,0 +1,55 @@ +import React, { useContext, useEffect, useState } from "react"; +import { Button, Descriptions, Popconfirm } from "antd"; + +import { + ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount, +} from "@clusterio/web_ui"; + +import { DiscordLinkGetRequest, DiscordUnlinkRequest } from "../../messages.js"; + +type Link = { id: string, name: string } | null; + +/** The Discord account a user is linked to, shown on their user page. */ +export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPage">) { + const control = useContext(ControlContext); + const account = useAccount(); + const own = account.name.toLowerCase() === props.user.name.toLowerCase(); + const canGet = own || account.hasPermission("exp_discord.link.get"); + const canDelete = own || account.hasPermission("exp_discord.link.delete"); + const [link, setLink] = useState(undefined); + + useEffect(() => { + if (!canGet) { + return; + } + control.send(new DiscordLinkGetRequest(props.user.name)) + .then(setLink) + .catch(notifyErrorHandler("Error loading Discord link")); + }, [props.user.name, canGet]); + + if (!canGet || link === undefined) { + return null; + } + + async function unlink() { + await control.send(new DiscordUnlinkRequest(props.user.name)); + setLink(null); + } + + return <> + + + + {link ? <> + {link.name} + {canDelete && { unlink().catch(notifyErrorHandler("Error unlinking Discord")); }} + > + + } + : "Not linked"} + + + ; +} diff --git a/exp_discord/web/index.tsx b/exp_discord/web/index.tsx new file mode 100644 index 00000000..31b21b69 --- /dev/null +++ b/exp_discord/web/index.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { WebPluginContext } from "@clusterio/web_ui"; + +import DiscordLoginForm from "./components/DiscordLoginForm"; +import UserDiscordLink from "./components/UserDiscordLink"; + +export default function registerPlugin(context: WebPluginContext) { + context.control.hooks.loginForms.attach("exp_discord", () => [ + { + name: "exp_discord", + title: "Discord", + Component: DiscordLoginForm, + }, + ]); + + context.control.hooks.extensionComponents.attach("exp_discord", () => ({ + UserViewPage: UserDiscordLink, + })); +} diff --git a/exp_discord/webpack.config.cjs b/exp_discord/webpack.config.cjs new file mode 100644 index 00000000..ad9e8b82 --- /dev/null +++ b/exp_discord/webpack.config.cjs @@ -0,0 +1,34 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "exp_discord", + library: { type: "window", name: "plugin_exp_discord" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + "./web": "./web/index.tsx", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + "react-router": { import: false }, + "react-router-dom": { import: false }, + }, + }), + ], +}); diff --git a/exp_scenario/permissions.ts b/exp_scenario/permissions.ts index c7cc576b..13068a1e 100644 --- a/exp_scenario/permissions.ts +++ b/exp_scenario/permissions.ts @@ -47,6 +47,7 @@ const definitions = [ ["exp_scenario.command.kill.always", "/kill (any player)", "Kill any player, not just yourself."], ["exp_scenario.command.kill_enemies", "/kill-enemies", "Kill all enemy units."], ["exp_scenario.command.lawnmower", "/lawnmower", "Clean up biter corpse, decoratives and nuclear hole."], + ["exp_scenario.command.link_discord", "/link-discord", "Link your account to Discord with the code from the website.", true], ["exp_scenario.command.locate", "/locate", "Opens remote view at the location of the player's last location.", true], ["exp_scenario.command.me", "/me", "Sends an action message in the chat."], ["exp_scenario.command.protect_area", "/protect-area", "Toggles area protection selection, hold shift to remove protection."], @@ -84,6 +85,7 @@ const definitions = [ ["exp_scenario.command.teleport", "/teleport", "Teleports a player to another player."], ["exp_scenario.command.unassign_role", "/unassign-role", "Unassigns a role lower than your own from a player."], ["exp_scenario.command.unjail", "/unjail", "Removes a player from jail, restoring their other roles."], + ["exp_scenario.command.unlink_discord", "/unlink-discord", "Remove the link between your account and Discord.", true], ["exp_scenario.command.vlayer_info", "/vlayer-info", "Print all vlayer information."], ["exp_scenario.command.waterfill", "/waterfill", "Replace tiles with shallow water."], ["exp_scenario.decon.fast_trees", "Fast tree deconstruction", "Trees and rocks marked for deconstruction are removed instantly, when enabled from the toolbar."], diff --git a/tsconfig.json b/tsconfig.json index 68f733fd..660ff4d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./exp_auto_restart/" }, { "path": "./exp_commands/" }, + { "path": "./exp_discord/" }, { "path": "./exp_groups/" }, { "path": "./exp_gui/" }, { "path": "./exp_legacy/" }, From e4fda182764a68906f28c56c8d62ca2a8b245120 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:07:14 +0000 Subject: [PATCH 2/3] Link Discord from the user page and document the setup The user page gets a Link Discord button. It asks the controller for a one-time ticket bound to the logged in user, then goes through the authorize route with it. The callback links the Discord account to that user directly, without the in-game code, and goes back to the user page. The link flow always shows the Discord consent screen. The README covers registering the redirect URL and the config. Co-Authored-By: Claude Opus 5.5 --- exp_discord/README.md | 43 +++++++ exp_discord/controller.ts | 111 +++++++++++++----- exp_discord/index.ts | 1 + exp_discord/messages.ts | 35 ++++++ exp_discord/test/controller.test.js | 63 ++++++++++ .../web/components/UserDiscordLink.tsx | 38 +++++- 6 files changed, 261 insertions(+), 30 deletions(-) create mode 100644 exp_discord/README.md diff --git a/exp_discord/README.md b/exp_discord/README.md new file mode 100644 index 00000000..0c2402ba --- /dev/null +++ b/exp_discord/README.md @@ -0,0 +1,43 @@ +# ExpGaming - Discord + +Lets players log in to the web interface with Discord, and links their Factorio accounts to a Discord account. It only uses the OAuth2 `identify` scope, so no bot is needed. + +## Setup + +1. Create an application in the [Discord developer portal](https://discord.com/developers/applications). +2. Under OAuth2, copy the Client ID and reset the Client Secret to get one. +3. Under OAuth2 → Redirects, add `api/exp_discord/callback`. For a controller with `controller.public_url` set to `https://example.com/cluster/` that is `https://example.com/cluster/api/exp_discord/callback`. It must match exactly, including the trailing path and http or https. +4. Set the config on the controller: + +``` +npx clusterioctl controller config set controller.public_url https://example.com/cluster/ +npx clusterioctl controller config set exp_discord.client_id +npx clusterioctl controller config set exp_discord.client_secret +``` + +`controller.public_url` has to be set, because the redirect URL is built from it and Discord rejects any URL that isn't registered. + +## Config + +All fields are on the controller. + +| Field | Default | Description | +|---|---|---| +| `exp_discord.client_id` | | Application ID of the Discord application. | +| `exp_discord.client_secret` | | OAuth2 client secret of the Discord application. Only readable on the controller. | +| `exp_discord.max_accounts` | 3 | How many Factorio accounts one Discord account can link. | + +## Linking + +There are two ways to link a Factorio account: + +- On the user page, a logged in user clicks Link Discord and approves it on Discord. +- On the login page, a player logs in with Discord and gets a code, then types `/link-discord CODE` on any server. This is how a player who can't log in to the web interface yet links their account. + +Linking a Factorio account that is already linked to another Discord account moves it. `/link-discord` without a code shows the current link, and `/unlink-discord` removes it. The user page has an Unlink button too. + +Users can always see and remove their own link. The `exp_discord.link.get` and `exp_discord.link.delete` permissions allow it for other users. + +Once linked, the Discord tab on the login page has a "Log in as" button for each linked account. Banned users can't log in this way. + +Links are stored in `database/exp_discord/accounts.json` on the controller. diff --git a/exp_discord/controller.ts b/exp_discord/controller.ts index b9a99379..2edc6f4d 100644 --- a/exp_discord/controller.ts +++ b/exp_discord/controller.ts @@ -15,6 +15,7 @@ const codeLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; type DiscordUser = { id: string, name: string }; type Session = DiscordUser & { expiresMs: number, code?: string }; type LinkCode = { sessionId: string, expiresMs: number }; +type WebLink = { playerName: string, expiresMs: number }; /** What an http route answers with, kept apart from express so it can be tested. */ export type RouteResult = @@ -55,6 +56,10 @@ export class ControllerPlugin { sessions = new Map(); /** Codes waiting to be used in game, by code */ codes = new Map(); + /** Links started from the user page, by ticket until authorize turns them into a state */ + linkTickets = new Map(); + /** Links waiting for the Discord callback, by OAuth state */ + pendingLinks = new Map(); cleanupInterval?: ReturnType; private constructor( @@ -80,6 +85,7 @@ export class ControllerPlugin { controller.handle(messages.DiscordLinkRequest, plugin.handleLinkRequest.bind(plugin)); controller.handle(messages.DiscordUnlinkRequest, plugin.handleUnlinkRequest.bind(plugin)); controller.handle(messages.DiscordLinkGetRequest, plugin.handleLinkGetRequest.bind(plugin)); + controller.handle(messages.DiscordLinkStartRequest, plugin.handleLinkStartRequest.bind(plugin)); plugin.addRoutes(); plugin.cleanupInterval = setInterval(() => plugin.removeExpired(), 60e3); @@ -105,9 +111,11 @@ export class ControllerPlugin { this.sessions.delete(id); } } - for (const [code, entry] of this.codes) { - if (entry.expiresMs < now) { - this.codes.delete(code); + for (const map of [this.codes, this.linkTickets, this.pendingLinks]) { + for (const [key, entry] of map) { + if (entry.expiresMs < now) { + map.delete(key); + } } } } @@ -133,7 +141,7 @@ export class ControllerPlugin { } ); - app.get("/api/exp_discord/authorize", route(async () => this.authorize())); + app.get("/api/exp_discord/authorize", route(async req => this.authorize(req.query.link))); app.get("/api/exp_discord/callback", route(async req => this.callback( req.query, readCookie(req.headers.cookie, "exp_discord_state"), ))); @@ -152,19 +160,36 @@ export class ControllerPlugin { } /** Back to the web ui, with the outcome in the fragment so it never reaches a server log. */ - webRedirect(key: "session" | "error", value: string): RouteResult { - const url = new URL(this.controller.getControllerUrl()); + webRedirect(key: "session" | "error", value: string, page = ""): RouteResult { + const url = new URL(page, this.controller.getControllerUrl()); url.hash = `exp_discord_${key}=${encodeURIComponent(value)}`; return { redirect: url.href }; } - async authorize(): Promise { + userPage(playerName: string) { + return `users/${encodeURIComponent(playerName)}/view`; + } + + async authorize(linkTicket?: unknown): Promise { + let link: WebLink | undefined; + if (linkTicket !== undefined) { + link = typeof linkTicket === "string" ? this.linkTickets.get(linkTicket) : undefined; + if (!link || link.expiresMs < Date.now()) { + return this.webRedirect("error", "Discord link expired, please try again", "users"); + } + this.linkTickets.delete(linkTicket as string); + } + const page = link ? this.userPage(link.playerName) : ""; + const clientId = this.controller.config.get("exp_discord.client_id"); if (!clientId || !this.controller.config.get("exp_discord.client_secret")) { - return this.webRedirect("error", "Discord login is not configured on this controller"); + return this.webRedirect("error", "Discord login is not configured on this controller", page); } const state = crypto.randomBytes(16).toString("base64url"); + if (link) { + this.pendingLinks.set(state, { playerName: link.playerName, expiresMs: Date.now() + stateTimeoutS * 1e3 }); + } const url = new URL("https://discord.com/oauth2/authorize"); url.search = new URLSearchParams({ response_type: "code", @@ -172,7 +197,8 @@ export class ControllerPlugin { scope: "identify", state, redirect_uri: this.redirectUri(), - prompt: "none", + // Linking always shows the Discord consent screen, so it can't happen without the user noticing + ...(link ? {} : { prompt: "none" }), }).toString(); const callbackUrl = new URL(this.redirectUri()); @@ -184,14 +210,24 @@ export class ControllerPlugin { } async callback(query: Record, cookieState: string | undefined): Promise { - if (typeof query.error === "string") { - return this.webRedirect("error", query.error === "access_denied" ? "Discord login was cancelled" : query.error); + const link = typeof query.state === "string" ? this.pendingLinks.get(query.state) : undefined; + if (link) { + this.pendingLinks.delete(query.state as string); } - if (typeof query.state !== "string" || !cookieState || query.state !== cookieState) { - return this.webRedirect("error", "Discord login expired, please try again"); + const page = link ? this.userPage(link.playerName) : ""; + + if (typeof query.error === "string") { + const message = query.error === "access_denied" ? "Discord login was cancelled" : query.error; + return this.webRedirect("error", message, page); + } + if ( + typeof query.state !== "string" || !cookieState || query.state !== cookieState + || (link && link.expiresMs < Date.now()) + ) { + return this.webRedirect("error", "Discord login expired, please try again", page); } if (typeof query.code !== "string") { - return this.webRedirect("error", "Discord did not return a login code"); + return this.webRedirect("error", "Discord did not return a login code", page); } let discordUser; @@ -199,7 +235,16 @@ export class ControllerPlugin { discordUser = await this.fetchDiscordUser(query.code); } catch (err: any) { this.logger.warn(`Discord login failed: ${err.message}`); - return this.webRedirect("error", "Discord login failed, please try again"); + return this.webRedirect("error", "Discord login failed, please try again", page); + } + + if (link) { + try { + this.linkAccount(discordUser, link.playerName); + } catch (err: any) { + return this.webRedirect("error", err.message, page); + } + return { redirect: new URL(page, this.controller.getControllerUrl()).href }; } const account = this.accounts.getMutable(discordUser.id); @@ -351,34 +396,46 @@ export class ControllerPlugin { throw new lib.RequestError("This code is invalid or has expired, get a new one from the website"); } - const name = request.playerName.toLowerCase(); - const existing = this.accounts.get(session.id); + const linked = this.linkAccount(session, request.playerName); + this.codes.delete(code); + delete session.code; + return linked; + } + + /** Link a player who proved they own the Factorio account, it moves over from any other Discord account. */ + linkAccount(discordUser: DiscordUser, playerName: string) { + const name = playerName.toLowerCase(); + const existing = this.accounts.get(discordUser.id); const alreadyLinked = existing?.factorioNames.some(linked => linked.toLowerCase() === name); const maxAccounts = this.controller.config.get("exp_discord.max_accounts"); if (!alreadyLinked && (existing?.factorioNames.length ?? 0) >= maxAccounts) { throw new lib.RequestError( - `${session.name} already has ${maxAccounts} Factorio accounts linked, unlink one first` + `${discordUser.name} already has ${maxAccounts} Factorio accounts linked, unlink one first` ); } - this.codes.delete(code); - delete session.code; if (!alreadyLinked) { - // The player proved they own this Factorio account, so it moves over from any other Discord account - this.removeLink(request.playerName); - const account = this.accounts.getMutable(session.id) - ?? new messages.DiscordAccountRecord(session.id, session.name, []); - account.name = session.name; - account.factorioNames = [...account.factorioNames, request.playerName]; + this.removeLink(playerName); + const account = this.accounts.getMutable(discordUser.id) + ?? new messages.DiscordAccountRecord(discordUser.id, discordUser.name, []); + account.name = discordUser.name; + account.factorioNames = [...account.factorioNames, playerName]; this.accounts.set(account); } - return { id: session.id, name: session.name }; + return { id: discordUser.id, name: discordUser.name }; } async handleUnlinkRequest(request: messages.DiscordUnlinkRequest) { return this.removeLink(request.playerName); } + /** A ticket for /api/exp_discord/authorize?link= which links the Discord account to the user. */ + async handleLinkStartRequest(request: messages.DiscordLinkStartRequest) { + const ticket = crypto.randomBytes(16).toString("base64url"); + this.linkTickets.set(ticket, { playerName: request.playerName, expiresMs: Date.now() + 60e3 }); + return ticket; + } + async handleLinkGetRequest(request: messages.DiscordLinkGetRequest) { const account = this.findAccount(request.playerName); return account ? { id: account.id, name: account.name } : null; diff --git a/exp_discord/index.ts b/exp_discord/index.ts index da0bbe2c..f247c341 100644 --- a/exp_discord/index.ts +++ b/exp_discord/index.ts @@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = { messages.DiscordLinkRequest, messages.DiscordUnlinkRequest, messages.DiscordLinkGetRequest, + messages.DiscordLinkStartRequest, ], permissions: [ diff --git a/exp_discord/messages.ts b/exp_discord/messages.ts index ab71d22a..4836866c 100644 --- a/exp_discord/messages.ts +++ b/exp_discord/messages.ts @@ -64,6 +64,14 @@ function selfOrPermission(permission: lib.PermissionName) { }; } +/** Only the user themselves, no permission lets someone link another user to their Discord. */ +function selfOnly(user: lib.IUser, message: lib.MessageRequest) { + const playerName = (message.data as { player_name: string }).player_name; + if (user.name.toLowerCase() !== playerName.toLowerCase()) { + throw new lib.PermissionError("You can only link your own account"); + } +} + const LinkResponse = lib.plainJson(Type.Union([ Type.Null(), Type.Object({ @@ -157,3 +165,30 @@ export class DiscordLinkGetRequest { return new this(json.player_name); } } + +/** Start linking the Discord account from the user page, responds with a ticket for the authorize route. */ +export class DiscordLinkStartRequest { + declare ["constructor"]: typeof DiscordLinkStartRequest; + static plugin = "exp_discord" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = selfOnly; + static Response = lib.plainJson(Type.String()); + + constructor( + public playerName: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName }; + } + + static fromJSON(json: Static) { + return new this(json.player_name); + } +} diff --git a/exp_discord/test/controller.test.js b/exp_discord/test/controller.test.js index b06869f3..707eaf32 100644 --- a/exp_discord/test/controller.test.js +++ b/exp_discord/test/controller.test.js @@ -217,6 +217,60 @@ t.test("linking", t2 => { t2.end(); }); +t.test("linking from the user page", t2 => { + /** Start a link for playerName and follow authorize, returning the OAuth state. */ + async function start(plugin, playerName) { + const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest(playerName)); + const result = await plugin.authorize(ticket); + return new URL(result.redirect).searchParams; + } + + t2.test("links the Discord account and goes back to the user page", async t3 => { + const { plugin } = await startPlugin(t3); + const params = await start(plugin, "Player1"); + t3.equal(params.get("prompt"), null, "consent screen is shown"); + plugin.fetchDiscordUser = async () => alice; + const result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state")); + t3.equal(result.redirect, "https://example.com/cluster/users/Player1/view"); + t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1"]); + t3.equal(plugin.sessions.size, 0, "no login session is started"); + t3.equal(plugin.pendingLinks.size, 0); + }); + + t2.test("a ticket can only be used once", async t3 => { + const { plugin } = await startPlugin(t3); + const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest("Player1")); + await plugin.authorize(ticket); + const result = await plugin.authorize(ticket); + t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_error=Discord%20link%20expired/); + }); + + t2.test("errors go back to the user page", async t3 => { + const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 1 } }); + await link(plugin, await discordLogin(plugin, alice), "Player2"); + + let params = await start(plugin, "Player1"); + let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state")); + t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=Discord%20login%20was%20cancelled$/); + + params = await start(plugin, "Player1"); + plugin.fetchDiscordUser = async () => alice; + result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state")); + t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=.*already%20has%201/); + t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player2"]); + }); + + t2.test("a state without the cookie does not link", async t3 => { + const { plugin } = await startPlugin(t3); + const params = await start(plugin, "Player1"); + plugin.fetchDiscordUser = async () => t3.fail("should not fetch"); + const result = await plugin.callback({ code: "c", state: params.get("state") }, undefined); + t3.match(result.redirect, "/users/Player1/view#exp_discord_error="); + t3.notOk(plugin.accounts.has(alice.id)); + }); + t2.end(); +}); + t.test("permissions", t2 => { const user = (name, granted = []) => ({ name, @@ -228,6 +282,15 @@ t.test("permissions", t2 => { }); const request = name => ({ data: { player_name: name } }); + t2.test("DiscordLinkStartRequest", async t3 => { + const permission = messages.DiscordLinkStartRequest.permission; + t3.doesNotThrow(() => permission(user("Alice"), request("alice")), "own account"); + t3.throws( + () => permission(user("Alice", ["exp_discord.link.get", "exp_discord.link.delete"]), request("Bob")), + "never someone else's", + ); + }); + for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) { t2.test(Request.name, async t3 => { const permission = Request === messages.DiscordUnlinkRequest diff --git a/exp_discord/web/components/UserDiscordLink.tsx b/exp_discord/web/components/UserDiscordLink.tsx index afb0d4a3..47ea1249 100644 --- a/exp_discord/web/components/UserDiscordLink.tsx +++ b/exp_discord/web/components/UserDiscordLink.tsx @@ -1,14 +1,25 @@ import React, { useContext, useEffect, useState } from "react"; -import { Button, Descriptions, Popconfirm } from "antd"; +import { Alert, Button, Descriptions, Popconfirm } from "antd"; import { ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount, } from "@clusterio/web_ui"; -import { DiscordLinkGetRequest, DiscordUnlinkRequest } from "../../messages.js"; +import { DiscordLinkGetRequest, DiscordLinkStartRequest, DiscordUnlinkRequest } from "../../messages.js"; + +declare const webRoot: string; type Link = { id: string, name: string } | null; +/** An error from linking on Discord, taken out of the url so a reload does not show it again. */ +function readError() { + const error = new URLSearchParams(window.location.hash.slice(1)).get("exp_discord_error"); + if (error) { + window.history.replaceState(null, "", window.location.pathname + window.location.search); + } + return error; +} + /** The Discord account a user is linked to, shown on their user page. */ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPage">) { const control = useContext(ControlContext); @@ -17,6 +28,7 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag const canGet = own || account.hasPermission("exp_discord.link.get"); const canDelete = own || account.hasPermission("exp_discord.link.delete"); const [link, setLink] = useState(undefined); + const [error, setError] = useState(readError); useEffect(() => { if (!canGet) { @@ -36,8 +48,21 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag setLink(null); } + async function startLink() { + const ticket = await control.send(new DiscordLinkStartRequest(props.user.name)); + window.location.href = `${webRoot}api/exp_discord/authorize?link=${encodeURIComponent(ticket)}`; + } + return <> + {error && setError(null)} + style={{ marginBottom: 16 }} + />} {link ? <> @@ -48,7 +73,14 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag > } - : "Not linked"} + : <> + Not linked + {own && } + } ; From 1c713ba0aa1233043a1c1a19dd340031b46281d1 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:38:20 +0000 Subject: [PATCH 3/3] Address review of the Discord plugin The plugin is now exp_discord_auth, so later Discord plugins can sit next to it. Config keys, permissions, routes and the Lua module moved with it, and the Discord redirect is now api/exp_discord_auth/callback. The controller registers its handlers and routes in the constructor and uses the express types, the instance plugin is a closure, and the Lua commands file returns its commands. The get and unlink responses carry the controller url so the in game messages can name the website. The login form asks the new status route whether Discord is configured and shows the error straight away instead of a button that fails. The button is full width like the link button, and a "Not you?" link next to the Discord name replaces the separate switch account button. Co-Authored-By: Claude Opus 5.5 --- exp_discord/instance.ts | 82 ---------- exp_discord/module/module_exports.lua | 3 - exp_discord_auth/.npmignore | 1 + {exp_discord => exp_discord_auth}/README.md | 18 +-- .../controller.ts | 141 +++++++++--------- {exp_discord => exp_discord_auth}/index.ts | 24 +-- exp_discord_auth/instance.ts | 67 +++++++++ {exp_discord => exp_discord_auth}/messages.ts | 30 ++-- .../module/commands.lua | 20 ++- .../module/control.lua | 42 +++--- .../module/globals.lua | 4 +- .../module/locale/en.cfg | 6 +- .../module/module.json | 2 +- exp_discord_auth/module/module_exports.lua | 3 + .../package.json | 5 +- .../test/controller.test.js | 63 ++++---- .../tsconfig.browser.json | 0 .../tsconfig.json | 0 .../tsconfig.node.json | 0 .../web/components/DiscordLoginForm.tsx | 36 +++-- .../web/components/UserDiscordLink.tsx | 10 +- .../web/index.tsx | 6 +- .../webpack.config.cjs | 4 +- tsconfig.json | 2 +- 24 files changed, 296 insertions(+), 273 deletions(-) delete mode 100644 exp_discord/instance.ts delete mode 100644 exp_discord/module/module_exports.lua create mode 100644 exp_discord_auth/.npmignore rename {exp_discord => exp_discord_auth}/README.md (63%) rename {exp_discord => exp_discord_auth}/controller.ts (80%) rename {exp_discord => exp_discord_auth}/index.ts (77%) create mode 100644 exp_discord_auth/instance.ts rename {exp_discord => exp_discord_auth}/messages.ts (85%) rename {exp_discord => exp_discord_auth}/module/commands.lua (53%) rename {exp_discord => exp_discord_auth}/module/control.lua (63%) rename {exp_discord => exp_discord_auth}/module/globals.lua (75%) rename {exp_discord => exp_discord_auth}/module/locale/en.cfg (82%) rename {exp_discord => exp_discord_auth}/module/module.json (84%) create mode 100644 exp_discord_auth/module/module_exports.lua rename {exp_discord => exp_discord_auth}/package.json (94%) rename {exp_discord => exp_discord_auth}/test/controller.test.js (86%) rename {exp_discord => exp_discord_auth}/tsconfig.browser.json (100%) rename {exp_discord => exp_discord_auth}/tsconfig.json (100%) rename {exp_discord => exp_discord_auth}/tsconfig.node.json (100%) rename {exp_discord => exp_discord_auth}/web/components/DiscordLoginForm.tsx (77%) rename {exp_discord => exp_discord_auth}/web/components/UserDiscordLink.tsx (88%) rename {exp_discord => exp_discord_auth}/web/index.tsx (68%) rename {exp_discord => exp_discord_auth}/webpack.config.cjs (89%) diff --git a/exp_discord/instance.ts b/exp_discord/instance.ts deleted file mode 100644 index 622cc78b..00000000 --- a/exp_discord/instance.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { Instance, InstancePluginContext } from "@clusterio/host"; -import type * as lib from "@clusterio/lib"; -import * as messages from "./messages.js"; - -/** Sent by the lua side when a player uses a link code. */ -export type IpcLink = { - player_name: string, - code: string, -}; - -/** Sent by the lua side when a player unlinks their Discord account, or asks which one they are linked to. */ -export type IpcPlayer = { - player_name: string, -}; - -/** Bridges the lua side and the controller, the answer is printed to the player once it arrives. */ -export class InstancePlugin { - private constructor( - public instance: Instance, - public logger: lib.Logger, - public name: string, - ) {} - - static async fromContext(context: InstancePluginContext) { - const instance = context.instance; - const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - - instance.server.handle("exp_discord:link", plugin.handleLinkIPC.bind(plugin)); - instance.server.handle("exp_discord:unlink", plugin.handleUnlinkIPC.bind(plugin)); - instance.server.handle("exp_discord:get", plugin.handleGetIPC.bind(plugin)); - return plugin; - } - - async handleLinkIPC(event: IpcLink) { - try { - const discord = await this.instance.sendTo("controller", new messages.DiscordLinkRequest( - event.player_name, event.code, - )); - await this.luaSend("receive_linked", { player_name: event.player_name, discord_name: discord!.name }); - } catch (err: any) { - await this.luaSend("receive_error", { player_name: event.player_name, message: err.message }); - } - } - - async handleUnlinkIPC(event: IpcPlayer) { - try { - const discord = await this.instance.sendTo("controller", new messages.DiscordUnlinkRequest(event.player_name)); - await this.luaSend("receive_unlinked", { player_name: event.player_name, discord_name: discord?.name }); - } catch (err: any) { - await this.luaSend("receive_error", { player_name: event.player_name, message: err.message }); - } - } - - async handleGetIPC(event: IpcPlayer) { - try { - const discord = await this.instance.sendTo("controller", new messages.DiscordLinkGetRequest(event.player_name)); - await this.luaSend("receive_get", { player_name: event.player_name, discord_name: discord?.name }); - } catch (err: any) { - await this.luaSend("receive_error", { player_name: event.player_name, message: err.message }); - } - } - - /** Hand an answer to lua, unless the instance stopped while it was being fetched. */ - async luaSend(receiver: string, json: any) { - if (this.instance.status !== "running") { - return; - } - // Discord names can contain anything, so pick a long bracket level the json does not close - const text = JSON.stringify(json); - let level = "="; - while (text.includes(`]${level}]`)) { - level += "="; - } - await this.instance.sendRcon( - `/sc exp_discord.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true - ); - } -} - -export default async function (context: InstancePluginContext) { - await InstancePlugin.fromContext(context); -} diff --git a/exp_discord/module/module_exports.lua b/exp_discord/module/module_exports.lua deleted file mode 100644 index 77d115e0..00000000 --- a/exp_discord/module/module_exports.lua +++ /dev/null @@ -1,3 +0,0 @@ - --- Access the exports from other modules using require("modules/exp_discord") -return require("modules/exp_discord/control") diff --git a/exp_discord_auth/.npmignore b/exp_discord_auth/.npmignore new file mode 100644 index 00000000..36e20175 --- /dev/null +++ b/exp_discord_auth/.npmignore @@ -0,0 +1 @@ +# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/ \ No newline at end of file diff --git a/exp_discord/README.md b/exp_discord_auth/README.md similarity index 63% rename from exp_discord/README.md rename to exp_discord_auth/README.md index 0c2402ba..7ea795e8 100644 --- a/exp_discord/README.md +++ b/exp_discord_auth/README.md @@ -1,4 +1,4 @@ -# ExpGaming - Discord +# ExpGaming - Discord Auth Lets players log in to the web interface with Discord, and links their Factorio accounts to a Discord account. It only uses the OAuth2 `identify` scope, so no bot is needed. @@ -6,13 +6,13 @@ Lets players log in to the web interface with Discord, and links their Factorio 1. Create an application in the [Discord developer portal](https://discord.com/developers/applications). 2. Under OAuth2, copy the Client ID and reset the Client Secret to get one. -3. Under OAuth2 → Redirects, add `api/exp_discord/callback`. For a controller with `controller.public_url` set to `https://example.com/cluster/` that is `https://example.com/cluster/api/exp_discord/callback`. It must match exactly, including the trailing path and http or https. +3. Under OAuth2 → Redirects, add `api/exp_discord_auth/callback`. For a controller with `controller.public_url` set to `https://example.com/cluster/` that is `https://example.com/cluster/api/exp_discord_auth/callback`. It must match exactly, including the trailing path and http or https. 4. Set the config on the controller: ``` npx clusterioctl controller config set controller.public_url https://example.com/cluster/ -npx clusterioctl controller config set exp_discord.client_id -npx clusterioctl controller config set exp_discord.client_secret +npx clusterioctl controller config set exp_discord_auth.client_id +npx clusterioctl controller config set exp_discord_auth.client_secret ``` `controller.public_url` has to be set, because the redirect URL is built from it and Discord rejects any URL that isn't registered. @@ -23,9 +23,9 @@ All fields are on the controller. | Field | Default | Description | |---|---|---| -| `exp_discord.client_id` | | Application ID of the Discord application. | -| `exp_discord.client_secret` | | OAuth2 client secret of the Discord application. Only readable on the controller. | -| `exp_discord.max_accounts` | 3 | How many Factorio accounts one Discord account can link. | +| `exp_discord_auth.client_id` | | Application ID of the Discord application. | +| `exp_discord_auth.client_secret` | | OAuth2 client secret of the Discord application. Only readable on the controller. | +| `exp_discord_auth.max_accounts` | 3 | How many Factorio accounts one Discord account can link. | ## Linking @@ -36,8 +36,8 @@ There are two ways to link a Factorio account: Linking a Factorio account that is already linked to another Discord account moves it. `/link-discord` without a code shows the current link, and `/unlink-discord` removes it. The user page has an Unlink button too. -Users can always see and remove their own link. The `exp_discord.link.get` and `exp_discord.link.delete` permissions allow it for other users. +Users can always see and remove their own link. The `exp_discord_auth.link.get` and `exp_discord_auth.link.delete` permissions allow it for other users. Once linked, the Discord tab on the login page has a "Log in as" button for each linked account. Banned users can't log in this way. -Links are stored in `database/exp_discord/accounts.json` on the controller. +Links are stored in `database/exp_discord_auth/accounts.json` on the controller. diff --git a/exp_discord/controller.ts b/exp_discord_auth/controller.ts similarity index 80% rename from exp_discord/controller.ts rename to exp_discord_auth/controller.ts index 2edc6f4d..0d575cb5 100644 --- a/exp_discord/controller.ts +++ b/exp_discord_auth/controller.ts @@ -1,4 +1,5 @@ import type { Controller, ControllerPluginContext } from "@clusterio/controller"; +import type { NextFunction, Request, Response } from "express"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import * as crypto from "node:crypto"; @@ -22,17 +23,6 @@ export type RouteResult = | { redirect: string, cookie?: string } | { status: number, json: unknown }; -type HttpRequest = { - query: Record, - headers: Record, -}; -type HttpResponse = { - status(code: number): HttpResponse, - json(body: unknown): unknown, - redirect(url: string): void, - setHeader(name: string, value: string): unknown, -}; - function readCookie(header: string | string[] | undefined, name: string) { for (const part of String(header ?? "").split(";")) { const [key, ...value] = part.trim().split("="); @@ -60,14 +50,46 @@ export class ControllerPlugin { linkTickets = new Map(); /** Links waiting for the Discord callback, by OAuth state */ pendingLinks = new Map(); - cleanupInterval?: ReturnType; + cleanupInterval = setInterval(() => this.removeExpired(), 60e3).unref(); private constructor( public controller: Controller, public logger: lib.Logger, public name: string, public accounts: lib.SubscribableDatastore, - ) {} + ) { + controller.handle(messages.DiscordLinkRequest, this.handleLinkRequest.bind(this)); + controller.handle(messages.DiscordUnlinkRequest, this.handleUnlinkRequest.bind(this)); + controller.handle(messages.DiscordLinkGetRequest, this.handleLinkGetRequest.bind(this)); + controller.handle(messages.DiscordLinkStartRequest, this.handleLinkStartRequest.bind(this)); + + const route = (handler: (req: Request) => Promise) => ( + (req: Request, res: Response, next: NextFunction) => { + handler(req).then(result => { + if ("redirect" in result) { + if (result.cookie) { + res.setHeader("Set-Cookie", result.cookie); + } + res.redirect(result.redirect); + } else { + res.status(result.status).json(result.json); + } + }).catch(next); + } + ); + const app = controller.app; + app.get("/api/exp_discord_auth/status", route(async () => this.status())); + app.get("/api/exp_discord_auth/authorize", route(async req => this.authorize(req.query.link))); + app.get("/api/exp_discord_auth/callback", route(async req => this.callback( + req.query, readCookie(req.headers.cookie, "exp_discord_auth_state"), + ))); + app.get("/api/exp_discord_auth/accounts", route(async req => this.listAccounts(this.sessionId(req)))); + app.post("/api/exp_discord_auth/login", route(async req => this.login(this.sessionId(req), req.query.name))); + app.post("/api/exp_discord_auth/link_code", route(async req => this.linkCode(this.sessionId(req)))); + + controller.hooks.save.attach(name, this.onSave.bind(this)); + controller.hooks.shutdown.attach(name, this.onShutdown.bind(this)); + } static async fromContext(context: ControllerPluginContext) { const controller = context.controller; @@ -75,25 +97,12 @@ export class ControllerPlugin { const accounts = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( - path.join(databaseDirectory, "exp_discord", "accounts.json"), + path.join(databaseDirectory, "exp_discord_auth", "accounts.json"), messages.DiscordAccountRecord.fromJSON.bind(messages.DiscordAccountRecord), ).bootstrap() ); - const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, accounts); - - controller.handle(messages.DiscordLinkRequest, plugin.handleLinkRequest.bind(plugin)); - controller.handle(messages.DiscordUnlinkRequest, plugin.handleUnlinkRequest.bind(plugin)); - controller.handle(messages.DiscordLinkGetRequest, plugin.handleLinkGetRequest.bind(plugin)); - controller.handle(messages.DiscordLinkStartRequest, plugin.handleLinkStartRequest.bind(plugin)); - - plugin.addRoutes(); - plugin.cleanupInterval = setInterval(() => plugin.removeExpired(), 60e3); - plugin.cleanupInterval.unref(); - - controller.hooks.save.attach(plugin.name, plugin.onSave.bind(plugin)); - controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin)); - return plugin; + return new ControllerPlugin(controller, context.logger, context.plugin.name, accounts); } async onSave() { @@ -124,45 +133,19 @@ export class ControllerPlugin { Http routes */ - addRoutes() { - const app = this.controller.app; - const route = (handler: (req: HttpRequest) => Promise) => ( - (req: HttpRequest, res: HttpResponse, next: (err: unknown) => void) => { - handler(req).then(result => { - if ("redirect" in result) { - if (result.cookie) { - res.setHeader("Set-Cookie", result.cookie); - } - res.redirect(result.redirect); - } else { - res.status(result.status).json(result.json); - } - }).catch(next); - } - ); - - app.get("/api/exp_discord/authorize", route(async req => this.authorize(req.query.link))); - app.get("/api/exp_discord/callback", route(async req => this.callback( - req.query, readCookie(req.headers.cookie, "exp_discord_state"), - ))); - app.get("/api/exp_discord/accounts", route(async req => this.listAccounts(this.sessionId(req)))); - app.post("/api/exp_discord/login", route(async req => this.login(this.sessionId(req), req.query.name))); - app.post("/api/exp_discord/link_code", route(async req => this.linkCode(this.sessionId(req)))); - } - - sessionId(req: HttpRequest) { + sessionId(req: Request) { const header = String(req.headers.authorization ?? ""); return header.startsWith("Bearer ") ? header.slice(7) : ""; } redirectUri() { - return new URL("api/exp_discord/callback", this.controller.getControllerUrl()).href; + return new URL("api/exp_discord_auth/callback", this.controller.getControllerUrl()).href; } /** Back to the web ui, with the outcome in the fragment so it never reaches a server log. */ webRedirect(key: "session" | "error", value: string, page = ""): RouteResult { const url = new URL(page, this.controller.getControllerUrl()); - url.hash = `exp_discord_${key}=${encodeURIComponent(value)}`; + url.hash = `exp_discord_auth_${key}=${encodeURIComponent(value)}`; return { redirect: url.href }; } @@ -170,6 +153,18 @@ export class ControllerPlugin { return `users/${encodeURIComponent(playerName)}/view`; } + isConfigured() { + return Boolean( + this.controller.config.get("exp_discord_auth.client_id") + && this.controller.config.get("exp_discord_auth.client_secret") + ); + } + + /** Lets the login form say Discord is not set up before anyone presses the button. */ + async status(): Promise { + return { status: 200, json: { configured: this.isConfigured() } }; + } + async authorize(linkTicket?: unknown): Promise { let link: WebLink | undefined; if (linkTicket !== undefined) { @@ -181,8 +176,7 @@ export class ControllerPlugin { } const page = link ? this.userPage(link.playerName) : ""; - const clientId = this.controller.config.get("exp_discord.client_id"); - if (!clientId || !this.controller.config.get("exp_discord.client_secret")) { + if (!this.isConfigured()) { return this.webRedirect("error", "Discord login is not configured on this controller", page); } @@ -193,7 +187,7 @@ export class ControllerPlugin { const url = new URL("https://discord.com/oauth2/authorize"); url.search = new URLSearchParams({ response_type: "code", - client_id: clientId, + client_id: this.controller.config.get("exp_discord_auth.client_id")!, scope: "identify", state, redirect_uri: this.redirectUri(), @@ -205,7 +199,7 @@ export class ControllerPlugin { const secure = callbackUrl.protocol === "https:" ? "; Secure" : ""; return { redirect: url.href, - cookie: `exp_discord_state=${state}; Path=${callbackUrl.pathname}; Max-Age=${stateTimeoutS}; HttpOnly; SameSite=Lax${secure}`, + cookie: `exp_discord_auth_state=${state}; Path=${callbackUrl.pathname}; Max-Age=${stateTimeoutS}; HttpOnly; SameSite=Lax${secure}`, }; } @@ -264,8 +258,8 @@ export class ControllerPlugin { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - client_id: this.controller.config.get("exp_discord.client_id") ?? "", - client_secret: this.controller.config.get("exp_discord.client_secret") ?? "", + client_id: this.controller.config.get("exp_discord_auth.client_id") ?? "", + client_secret: this.controller.config.get("exp_discord_auth.client_secret") ?? "", grant_type: "authorization_code", code, redirect_uri: this.redirectUri(), @@ -304,7 +298,7 @@ export class ControllerPlugin { json: { discord: { id: session.id, name: session.name }, accounts: this.accounts.get(session.id)?.factorioNames ?? [], - max_accounts: this.controller.config.get("exp_discord.max_accounts"), + max_accounts: this.controller.config.get("exp_discord_auth.max_accounts"), }, }; } @@ -393,7 +387,9 @@ export class ControllerPlugin { const entry = this.codes.get(code); const session = entry ? this.sessions.get(entry.sessionId) : undefined; if (!entry || !session || entry.expiresMs < Date.now()) { - throw new lib.RequestError("This code is invalid or has expired, get a new one from the website"); + throw new lib.RequestError( + `This code is invalid or has expired, get a new one from ${this.controller.getControllerUrl()}` + ); } const linked = this.linkAccount(session, request.playerName); @@ -407,7 +403,7 @@ export class ControllerPlugin { const name = playerName.toLowerCase(); const existing = this.accounts.get(discordUser.id); const alreadyLinked = existing?.factorioNames.some(linked => linked.toLowerCase() === name); - const maxAccounts = this.controller.config.get("exp_discord.max_accounts"); + const maxAccounts = this.controller.config.get("exp_discord_auth.max_accounts"); if (!alreadyLinked && (existing?.factorioNames.length ?? 0) >= maxAccounts) { throw new lib.RequestError( `${discordUser.name} already has ${maxAccounts} Factorio accounts linked, unlink one first` @@ -426,10 +422,10 @@ export class ControllerPlugin { } async handleUnlinkRequest(request: messages.DiscordUnlinkRequest) { - return this.removeLink(request.playerName); + return { link: this.removeLink(request.playerName), url: this.controller.getControllerUrl() }; } - /** A ticket for /api/exp_discord/authorize?link= which links the Discord account to the user. */ + /** A ticket for /api/exp_discord_auth/authorize?link= which links the Discord account to the user. */ async handleLinkStartRequest(request: messages.DiscordLinkStartRequest) { const ticket = crypto.randomBytes(16).toString("base64url"); this.linkTickets.set(ticket, { playerName: request.playerName, expiresMs: Date.now() + 60e3 }); @@ -438,10 +434,11 @@ export class ControllerPlugin { async handleLinkGetRequest(request: messages.DiscordLinkGetRequest) { const account = this.findAccount(request.playerName); - return account ? { id: account.id, name: account.name } : null; + return { + link: account ? { id: account.id, name: account.name } : null, + url: this.controller.getControllerUrl(), + }; } } -export default async function (context: ControllerPluginContext) { - await ControllerPlugin.fromContext(context); -} +export default ControllerPlugin.fromContext.bind(ControllerPlugin); diff --git a/exp_discord/index.ts b/exp_discord_auth/index.ts similarity index 77% rename from exp_discord/index.ts rename to exp_discord_auth/index.ts index f247c341..3c0d9cb2 100644 --- a/exp_discord/index.ts +++ b/exp_discord_auth/index.ts @@ -3,19 +3,19 @@ import * as messages from "./messages.js"; declare module "@clusterio/lib" { export interface ControllerConfigFields { - "exp_discord.client_id": string | null; - "exp_discord.client_secret": string | null; - "exp_discord.max_accounts": number; + "exp_discord_auth.client_id": string | null; + "exp_discord_auth.client_secret": string | null; + "exp_discord_auth.max_accounts": number; } export interface Permissions { - "exp_discord.link.get": never; - "exp_discord.link.delete": never; + "exp_discord_auth.link.get": never; + "exp_discord_auth.link.delete": never; } } export const plugin: lib.PluginDeclaration = { - name: "exp_discord", - title: "ExpGaming - Discord", + name: "exp_discord_auth", + title: "ExpGaming - Discord Auth", description: "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord", features: [ @@ -32,13 +32,13 @@ export const plugin: lib.PluginDeclaration = { permissions: [ { - name: "exp_discord.link.get", + name: "exp_discord_auth.link.get", title: "Get Discord Links", description: "See which Discord account any user is linked to, users can always see their own.", grantByDefault: false, }, { - name: "exp_discord.link.delete", + name: "exp_discord_auth.link.delete", title: "Remove Discord Links", description: "Unlink any user from their Discord account, users can always unlink themselves.", grantByDefault: false, @@ -49,20 +49,20 @@ export const plugin: lib.PluginDeclaration = { controllerEntrypoint: "./dist/node/controller.js", controllerConfigFields: { - "exp_discord.client_id": { + "exp_discord_auth.client_id": { title: "Client ID", description: "Application ID of the Discord application used for logging in.", type: "string", optional: true, }, - "exp_discord.client_secret": { + "exp_discord_auth.client_secret": { title: "Client Secret", description: "OAuth2 client secret of the Discord application used for logging in.", type: "string", credential: ["controller"], optional: true, }, - "exp_discord.max_accounts": { + "exp_discord_auth.max_accounts": { title: "Max Accounts", description: "Number of Factorio accounts which can be linked to one Discord account.", type: "number", diff --git a/exp_discord_auth/instance.ts b/exp_discord_auth/instance.ts new file mode 100644 index 00000000..20a29219 --- /dev/null +++ b/exp_discord_auth/instance.ts @@ -0,0 +1,67 @@ +import type { InstancePluginContext } from "@clusterio/host"; +import * as messages from "./messages.js"; + +/** Sent by the lua side when a player uses a link code. */ +export type IpcLink = { + player_name: string, + code: string, +}; + +/** Sent by the lua side when a player unlinks their Discord account, or asks which one they are linked to. */ +export type IpcPlayer = { + player_name: string, +}; + +/** Bridges the lua side and the controller, the answer is printed to the player once it arrives. */ +export default async function (context: InstancePluginContext) { + const { instance } = context; + + /** Hand an answer to lua, unless the instance stopped while it was being fetched. */ + async function luaSend(receiver: string, json: any) { + if (instance.status !== "running") { + return; + } + // Discord names can contain anything, so pick a long bracket level the json does not close + const text = JSON.stringify(json); + let level = "="; + while (text.includes(`]${level}]`)) { + level += "="; + } + await instance.sendRcon( + `/sc exp_discord_auth.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true + ); + } + + instance.server.handle("exp_discord_auth:link", async (event: IpcLink) => { + try { + const discord = await instance.sendTo("controller", new messages.DiscordLinkRequest( + event.player_name, event.code, + )); + await luaSend("receive_linked", { player_name: event.player_name, discord_name: discord!.name }); + } catch (err: any) { + await luaSend("receive_error", { player_name: event.player_name, message: err.message }); + } + }); + + instance.server.handle("exp_discord_auth:unlink", async (event: IpcPlayer) => { + try { + const status = await instance.sendTo("controller", new messages.DiscordUnlinkRequest(event.player_name)); + await luaSend("receive_unlinked", { + player_name: event.player_name, discord_name: status.link?.name, url: status.url, + }); + } catch (err: any) { + await luaSend("receive_error", { player_name: event.player_name, message: err.message }); + } + }); + + instance.server.handle("exp_discord_auth:get", async (event: IpcPlayer) => { + try { + const status = await instance.sendTo("controller", new messages.DiscordLinkGetRequest(event.player_name)); + await luaSend("receive_get", { + player_name: event.player_name, discord_name: status.link?.name, url: status.url, + }); + } catch (err: any) { + await luaSend("receive_error", { player_name: event.player_name, message: err.message }); + } + }); +} diff --git a/exp_discord/messages.ts b/exp_discord_auth/messages.ts similarity index 85% rename from exp_discord/messages.ts rename to exp_discord_auth/messages.ts index 4836866c..a011e82c 100644 --- a/exp_discord/messages.ts +++ b/exp_discord_auth/messages.ts @@ -72,13 +72,19 @@ function selfOnly(user: lib.IUser, message: lib.MessageRequest) { } } -const LinkResponse = lib.plainJson(Type.Union([ +const Link = Type.Union([ Type.Null(), Type.Object({ id: Type.String(), name: Type.String(), }), -])); +]); +const LinkResponse = lib.plainJson(Link); +/** The website is included so the player can be told where to link their account. */ +const LinkStatusResponse = lib.plainJson(Type.Object({ + link: Link, + url: Type.String(), +})); /* Link requests @@ -87,7 +93,7 @@ const LinkResponse = lib.plainJson(Type.Union([ /** Link a player to the Discord account which generated the code, sent when the player uses the code in game. */ export class DiscordLinkRequest { declare ["constructor"]: typeof DiscordLinkRequest; - static plugin = "exp_discord" as const; + static plugin = "exp_discord_auth" as const; static type = "request" as const; static src = "instance" as const; static dst = "controller" as const; @@ -112,15 +118,15 @@ export class DiscordLinkRequest { } } -/** Remove the link of a player, responds with the account it was linked to. */ +/** Remove the link of a player, responds with the account it was linked to and the website url. */ export class DiscordUnlinkRequest { declare ["constructor"]: typeof DiscordUnlinkRequest; - static plugin = "exp_discord" as const; + static plugin = "exp_discord_auth" as const; static type = "request" as const; static src = ["control", "instance"] as const; static dst = "controller" as const; - static permission = selfOrPermission("exp_discord.link.delete"); - static Response = LinkResponse; + static permission = selfOrPermission("exp_discord_auth.link.delete"); + static Response = LinkStatusResponse; constructor( public playerName: string, @@ -139,15 +145,15 @@ export class DiscordUnlinkRequest { } } -/** Get the Discord account a player is linked to. */ +/** Get the Discord account a player is linked to and the website url. */ export class DiscordLinkGetRequest { declare ["constructor"]: typeof DiscordLinkGetRequest; - static plugin = "exp_discord" as const; + static plugin = "exp_discord_auth" as const; static type = "request" as const; static src = ["control", "instance"] as const; static dst = "controller" as const; - static permission = selfOrPermission("exp_discord.link.get"); - static Response = LinkResponse; + static permission = selfOrPermission("exp_discord_auth.link.get"); + static Response = LinkStatusResponse; constructor( public playerName: string, @@ -169,7 +175,7 @@ export class DiscordLinkGetRequest { /** Start linking the Discord account from the user page, responds with a ticket for the authorize route. */ export class DiscordLinkStartRequest { declare ["constructor"]: typeof DiscordLinkStartRequest; - static plugin = "exp_discord" as const; + static plugin = "exp_discord_auth" as const; static type = "request" as const; static src = "control" as const; static dst = "controller" as const; diff --git a/exp_discord/module/commands.lua b/exp_discord_auth/module/commands.lua similarity index 53% rename from exp_discord/module/commands.lua rename to exp_discord_auth/module/commands.lua index eb8f3df6..6b09b3de 100644 --- a/exp_discord/module/commands.lua +++ b/exp_discord_auth/module/commands.lua @@ -1,30 +1,34 @@ ---[[-- ExpDiscord - Commands +--[[-- ExpDiscordAuth - Commands Commands which link and unlink the player's Discord account ]] local Commands = require("modules/exp_commands") -local Discord = require("modules/exp_discord") +local Discord = require("modules/exp_discord_auth") + +--- @class ExpDiscordAuth.commands +local commands = {} --- Links your account to the Discord account which made the code, printed once the controller answers -Commands.new("link-discord", { "exp-discord_commands.description-link" }) - :optional("code", { "exp-discord_commands.arg-code" }, Commands.types.string) +commands.link_discord = Commands.new("link-discord", { "exp-discord-auth_commands.description-link" }) + :optional("code", { "exp-discord-auth_commands.arg-code" }, Commands.types.string) :add_aliases{ "link" } :register(function(player, code) --- @cast code string? if code then Discord.link(player, code) else - Discord.get_link(player) + Discord.get_linked(player) end end) --- Removes the link to your Discord account, printed once the controller answers -Commands.new("unlink-discord", { "exp-discord_commands.description-unlink" }) +commands.unlink_discord = Commands.new("unlink-discord", { "exp-discord-auth_commands.description-unlink" }) :add_aliases{ "unlink" } :register(function(player) Discord.unlink(player) end) --- Access the exports from other modules using require("modules/exp_discord") -return require("modules/exp_discord/control") +return { + commands = commands, +} diff --git a/exp_discord/module/control.lua b/exp_discord_auth/module/control.lua similarity index 63% rename from exp_discord/module/control.lua rename to exp_discord_auth/module/control.lua index 2aa6869a..ee3e1026 100644 --- a/exp_discord/module/control.lua +++ b/exp_discord_auth/module/control.lua @@ -1,4 +1,4 @@ ---[[-- ExpDiscord +--[[-- ExpDiscordAuth Links Factorio accounts to Discord accounts, the links are kept on the controller. A player logs in with Discord on the web interface, which gives them a code. @@ -10,28 +10,28 @@ printed to the player once it arrives, if they are still online. local clusterio_api = require("modules/clusterio/api") local ExpUtil = require("modules/exp_util") ---- @class ExpDiscord -local ExpDiscord = {} +--- @class ExpDiscordAuth +local ExpDiscordAuth = {} local error_settings = { color = ExpUtil.color.orange_red, sound_path = "utility/wire_pickup" } --- Link the player to the Discord account which made the code --- @param player LuaPlayer --- @param code string -function ExpDiscord.link(player, code) - clusterio_api.send_json("exp_discord:link", { player_name = player.name, code = code }) +function ExpDiscordAuth.link(player, code) + clusterio_api.send_json("exp_discord_auth:link", { player_name = player.name, code = code }) end --- Remove the link between the player and their Discord account --- @param player LuaPlayer -function ExpDiscord.unlink(player) - clusterio_api.send_json("exp_discord:unlink", { player_name = player.name }) +function ExpDiscordAuth.unlink(player) + clusterio_api.send_json("exp_discord_auth:unlink", { player_name = player.name }) end --- Print the Discord account the player is linked to --- @param player LuaPlayer -function ExpDiscord.get_link(player) - clusterio_api.send_json("exp_discord:get", { player_name = player.name }) +function ExpDiscordAuth.get_linked(player) + clusterio_api.send_json("exp_discord_auth:get", { player_name = player.name }) end --- A player who can still be printed to, nil once they have left @@ -47,42 +47,42 @@ end --- The controller linked the player --- @param payload { player_name: string, discord_name: string } -function ExpDiscord.receive_linked(payload) +function ExpDiscordAuth.receive_linked(payload) local player = get_online_player(payload.player_name) if not player then return end - player.print{ "exp-discord.linked", payload.discord_name } + player.print{ "exp-discord-auth.linked", payload.discord_name } end --- The controller removed the link, discord_name is nil if there was none ---- @param payload { player_name: string, discord_name: string? } -function ExpDiscord.receive_unlinked(payload) +--- @param payload { player_name: string, discord_name: string?, url: string } +function ExpDiscordAuth.receive_unlinked(payload) local player = get_online_player(payload.player_name) if not player then return end if payload.discord_name then - player.print{ "exp-discord.unlinked", payload.discord_name } + player.print{ "exp-discord-auth.unlinked", payload.discord_name } else - player.print({ "exp-discord.not-linked" }, error_settings) + player.print({ "exp-discord-auth.not-linked", payload.url }, error_settings) end end --- The controller answered which account the player is linked to ---- @param payload { player_name: string, discord_name: string? } -function ExpDiscord.receive_get(payload) +--- @param payload { player_name: string, discord_name: string?, url: string } +function ExpDiscordAuth.receive_get(payload) local player = get_online_player(payload.player_name) if not player then return end if payload.discord_name then - player.print{ "exp-discord.linked-to", payload.discord_name } + player.print{ "exp-discord-auth.linked-to", payload.discord_name } else - player.print{ "exp-discord.not-linked" } + player.print{ "exp-discord-auth.not-linked", payload.url } end end --- The controller refused a request --- @param payload { player_name: string, message: string } -function ExpDiscord.receive_error(payload) +function ExpDiscordAuth.receive_error(payload) local player = get_online_player(payload.player_name) if not player then return end player.print(payload.message, error_settings) end -return ExpDiscord +return ExpDiscordAuth diff --git a/exp_discord/module/globals.lua b/exp_discord_auth/module/globals.lua similarity index 75% rename from exp_discord/module/globals.lua rename to exp_discord_auth/module/globals.lua index 862e1014..17c8f414 100644 --- a/exp_discord/module/globals.lua +++ b/exp_discord_auth/module/globals.lua @@ -6,5 +6,5 @@ Therefore, we advise that this should be the only file in your module to expose --- @diagnostic disable: global-in-non-module --- Access using `/sc exp_discord.foo()` -exp_discord = require("modules/exp_discord/control") +-- Access using `/sc exp_discord_auth.foo()` +exp_discord_auth = require("modules/exp_discord_auth/control") diff --git a/exp_discord/module/locale/en.cfg b/exp_discord_auth/module/locale/en.cfg similarity index 82% rename from exp_discord/module/locale/en.cfg rename to exp_discord_auth/module/locale/en.cfg index 57d0ac13..d5e6ec8a 100644 --- a/exp_discord/module/locale/en.cfg +++ b/exp_discord_auth/module/locale/en.cfg @@ -1,10 +1,10 @@ -[exp-discord] +[exp-discord-auth] linked=Your account is now linked to the Discord account __1__. linked-to=Your account is linked to the Discord account __1__. Use /unlink-discord to remove it. unlinked=Your account is no longer linked to the Discord account __1__. -not-linked=Your account is not linked to a Discord account. Log in with Discord on the website to get a code, then use /link-discord . +not-linked=Your account is not linked to a Discord account. Log in with Discord at __1__ to get a code, then use /link-discord . -[exp-discord_commands] +[exp-discord-auth_commands] description-link=Links your account to Discord using the code from the website, or shows the account you are linked to. description-unlink=Removes the link between your account and Discord. arg-code=Code shown on the website after logging in with Discord. diff --git a/exp_discord/module/module.json b/exp_discord_auth/module/module.json similarity index 84% rename from exp_discord/module/module.json rename to exp_discord_auth/module/module.json index 7fea5b7c..fce6233a 100644 --- a/exp_discord/module/module.json +++ b/exp_discord_auth/module/module.json @@ -1,5 +1,5 @@ { - "name": "exp_discord", + "name": "exp_discord_auth", "load": [ ], "require": [ diff --git a/exp_discord_auth/module/module_exports.lua b/exp_discord_auth/module/module_exports.lua new file mode 100644 index 00000000..ee07ae7e --- /dev/null +++ b/exp_discord_auth/module/module_exports.lua @@ -0,0 +1,3 @@ + +-- Access the exports from other modules using require("modules/exp_discord_auth") +return require("modules/exp_discord_auth/control") diff --git a/exp_discord/package.json b/exp_discord_auth/package.json similarity index 94% rename from exp_discord/package.json rename to exp_discord_auth/package.json index 80694ab6..7777710c 100644 --- a/exp_discord/package.json +++ b/exp_discord_auth/package.json @@ -1,5 +1,5 @@ { - "name": "@expcluster/discord", + "name": "@expcluster/discord-auth", "version": "7.0.0", "description": "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord", "author": "Cooldude2606 ", @@ -13,7 +13,7 @@ "coverage": "tap --coverage-report=text test/*.test.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "@clusterio/controller": "workspace:^", @@ -26,6 +26,7 @@ "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", + "@types/express": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", diff --git a/exp_discord/test/controller.test.js b/exp_discord_auth/test/controller.test.js similarity index 86% rename from exp_discord/test/controller.test.js rename to exp_discord_auth/test/controller.test.js index 707eaf32..c6d084b3 100644 --- a/exp_discord/test/controller.test.js +++ b/exp_discord_auth/test/controller.test.js @@ -26,8 +26,8 @@ async function startPlugin(t2, { config = {} } = {}) { // testdir() empties the folder, so a reload passes the directory in "controller.database_directory": config["controller.database_directory"] ?? t2.testdir(), "controller.public_url": "https://example.com/cluster/", - "exp_discord.client_id": "123", - "exp_discord.client_secret": "secret", + "exp_discord_auth.client_id": "123", + "exp_discord_auth.client_secret": "secret", "controller.auth_secret": Buffer.from("test secret").toString("base64"), ...config, }); @@ -36,7 +36,7 @@ async function startPlugin(t2, { config = {} } = {}) { controller.users.createUser(name); } - const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_discord" }, controller, logger }); + const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_discord_auth" }, controller, logger }); t2.teardown(() => clearInterval(plugin.cleanupInterval)); return { plugin, controller }; } @@ -57,9 +57,9 @@ async function link(plugin, session, playerName) { t.test("entrypoint", async t2 => { const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); const controller = new Controller(logger, [], controllerConfig); - await entrypoint({ controller, logger, plugin: { name: "exp_discord" }, metrics: undefined }); + await entrypoint({ controller, logger, plugin: { name: "exp_discord_auth" }, metrics: undefined }); for (const hook of ["save", "shutdown"]) { - t2.ok([...controller.hooks[hook].attached].includes("exp_discord"), `attached to ${hook}`); + t2.ok([...controller.hooks[hook].attached].includes("exp_discord_auth"), `attached to ${hook}`); } await controller.hooks.shutdown.invoke(); }); @@ -71,16 +71,23 @@ t.test("oauth", t2 => { const url = new URL(result.redirect); t3.equal(url.origin + url.pathname, "https://discord.com/oauth2/authorize"); t3.equal(url.searchParams.get("client_id"), "123"); - t3.equal(url.searchParams.get("redirect_uri"), "https://example.com/cluster/api/exp_discord/callback"); + t3.equal(url.searchParams.get("redirect_uri"), "https://example.com/cluster/api/exp_discord_auth/callback"); const state = url.searchParams.get("state"); - t3.match(result.cookie, `exp_discord_state=${state}; Path=/cluster/api/exp_discord/callback`); + t3.match(result.cookie, `exp_discord_auth_state=${state}; Path=/cluster/api/exp_discord_auth/callback`); t3.match(result.cookie, "Secure"); }); + t2.test(".status() tells the login form whether Discord is configured", async t3 => { + const { plugin } = await startPlugin(t3); + t3.same(await plugin.status(), { status: 200, json: { configured: true } }); + const { plugin: unconfigured } = await startPlugin(t3, { config: { "exp_discord_auth.client_id": null } }); + t3.same(await unconfigured.status(), { status: 200, json: { configured: false } }); + }); + t2.test(".authorize() without credentials goes back with an error", async t3 => { - const { plugin } = await startPlugin(t3, { config: { "exp_discord.client_secret": null } }); + const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.client_secret": null } }); const result = await plugin.authorize(); - t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_error=/); + t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_auth_error=/); }); t2.test(".callback() rejects a state which does not match the cookie", async t3 => { @@ -88,7 +95,7 @@ t.test("oauth", t2 => { plugin.fetchDiscordUser = async () => t3.fail("should not fetch"); for (const cookie of [undefined, "other"]) { const result = await plugin.callback({ code: "c", state: "s" }, cookie); - t3.match(result.redirect, "#exp_discord_error="); + t3.match(result.redirect, "#exp_discord_auth_error="); } t3.equal(plugin.sessions.size, 0); }); @@ -97,7 +104,7 @@ t.test("oauth", t2 => { const { plugin } = await startPlugin(t3); plugin.fetchDiscordUser = async () => { throw new Error("401"); }; const result = await plugin.callback({ code: "c", state: "s" }, "s"); - t3.match(result.redirect, "#exp_discord_error="); + t3.match(result.redirect, "#exp_discord_auth_error="); }); t2.test(".callback() starts a session", async t3 => { @@ -137,7 +144,8 @@ t.test("linking", t2 => { const { json } = await plugin.linkCode(session); await plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code)); await t3.rejects( - plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player2", json.code)), /invalid or has expired/, + plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player2", json.code)), + /invalid or has expired, get a new one from https:\/\/example\.com\/cluster\//, ); }); @@ -156,7 +164,7 @@ t.test("linking", t2 => { }); t2.test("several accounts up to the limit", async t3 => { - const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 2 } }); + const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.max_accounts": 2 } }); const session = await discordLogin(plugin, alice); await link(plugin, session, "Player1"); await link(plugin, session, "Player2"); @@ -189,10 +197,13 @@ t.test("linking", t2 => { t2.test("unlink and get", async t3 => { const { plugin } = await startPlugin(t3); await link(plugin, await discordLogin(plugin, alice), "Player1"); - t3.same(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("player1")), alice); - t3.same(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), alice); - t3.equal(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("Player1")), null); - t3.equal(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), null); + const url = "https://example.com/cluster/"; + const get = name => plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest(name)); + const unlink = name => plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest(name)); + t3.same(await get("player1"), { link: alice, url }); + t3.same(await unlink("Player1"), { link: alice, url }); + t3.same(await get("Player1"), { link: null, url }); + t3.same(await unlink("Player1"), { link: null, url }); }); t2.test("links are saved and loaded", async t3 => { @@ -242,21 +253,21 @@ t.test("linking from the user page", t2 => { const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest("Player1")); await plugin.authorize(ticket); const result = await plugin.authorize(ticket); - t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_error=Discord%20link%20expired/); + t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_auth_error=Discord%20link/); }); t2.test("errors go back to the user page", async t3 => { - const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 1 } }); + const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.max_accounts": 1 } }); await link(plugin, await discordLogin(plugin, alice), "Player2"); let params = await start(plugin, "Player1"); let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state")); - t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=Discord%20login%20was%20cancelled$/); + t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_error=Discord%20login%20was%20cancelled$/); params = await start(plugin, "Player1"); plugin.fetchDiscordUser = async () => alice; result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state")); - t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=.*already%20has%201/); + t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_error=.*already%20has%201/); t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player2"]); }); @@ -265,7 +276,7 @@ t.test("linking from the user page", t2 => { const params = await start(plugin, "Player1"); plugin.fetchDiscordUser = async () => t3.fail("should not fetch"); const result = await plugin.callback({ code: "c", state: params.get("state") }, undefined); - t3.match(result.redirect, "/users/Player1/view#exp_discord_error="); + t3.match(result.redirect, "/users/Player1/view#exp_discord_auth_error="); t3.notOk(plugin.accounts.has(alice.id)); }); t2.end(); @@ -286,7 +297,9 @@ t.test("permissions", t2 => { const permission = messages.DiscordLinkStartRequest.permission; t3.doesNotThrow(() => permission(user("Alice"), request("alice")), "own account"); t3.throws( - () => permission(user("Alice", ["exp_discord.link.get", "exp_discord.link.delete"]), request("Bob")), + () => permission( + user("Alice", ["exp_discord_auth.link.get", "exp_discord_auth.link.delete"]), request("Bob"), + ), "never someone else's", ); }); @@ -294,8 +307,8 @@ t.test("permissions", t2 => { for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) { t2.test(Request.name, async t3 => { const permission = Request === messages.DiscordUnlinkRequest - ? "exp_discord.link.delete" - : "exp_discord.link.get"; + ? "exp_discord_auth.link.delete" + : "exp_discord_auth.link.get"; t3.doesNotThrow(() => Request.permission(user("Alice"), request("alice")), "own link"); t3.throws(() => Request.permission(user("Alice"), request("Bob")), "someone else's"); t3.doesNotThrow(() => Request.permission(user("Alice", [permission]), request("Bob")), "with permission"); diff --git a/exp_discord/tsconfig.browser.json b/exp_discord_auth/tsconfig.browser.json similarity index 100% rename from exp_discord/tsconfig.browser.json rename to exp_discord_auth/tsconfig.browser.json diff --git a/exp_discord/tsconfig.json b/exp_discord_auth/tsconfig.json similarity index 100% rename from exp_discord/tsconfig.json rename to exp_discord_auth/tsconfig.json diff --git a/exp_discord/tsconfig.node.json b/exp_discord_auth/tsconfig.node.json similarity index 100% rename from exp_discord/tsconfig.node.json rename to exp_discord_auth/tsconfig.node.json diff --git a/exp_discord/web/components/DiscordLoginForm.tsx b/exp_discord_auth/web/components/DiscordLoginForm.tsx similarity index 77% rename from exp_discord/web/components/DiscordLoginForm.tsx rename to exp_discord_auth/web/components/DiscordLoginForm.tsx index 39f7645e..2c33a5bc 100644 --- a/exp_discord/web/components/DiscordLoginForm.tsx +++ b/exp_discord_auth/web/components/DiscordLoginForm.tsx @@ -3,8 +3,8 @@ import { Alert, Button, Space, Spin, Typography } from "antd"; declare const webRoot: string; -const { Paragraph, Text } = Typography; -const sessionKey = "exp_discord_session"; +const { Link, Paragraph, Text } = Typography; +const sessionKey = "exp_discord_auth_session"; type Accounts = { discord: { id: string, name: string }, @@ -13,7 +13,7 @@ type Accounts = { }; async function api(method: "GET" | "POST", route: string, session: string) { - const response = await fetch(`${webRoot}api/exp_discord/${route}`, { + const response = await fetch(`${webRoot}api/exp_discord_auth/${route}`, { method, headers: { Authorization: `Bearer ${session}` }, }); @@ -27,8 +27,8 @@ async function api(method: "GET" | "POST", route: string, session: string) { /** Take the outcome of the Discord redirect out of the url, so a reload does not reuse it. */ function readRedirect() { const params = new URLSearchParams(window.location.hash.slice(1)); - const session = params.get("exp_discord_session"); - const error = params.get("exp_discord_error"); + const session = params.get("exp_discord_auth_session"); + const error = params.get("exp_discord_auth_error"); if (session || error) { window.history.replaceState(null, "", window.location.pathname + window.location.search); } @@ -44,6 +44,7 @@ export default function DiscordLoginForm(props: { setToken(token: string): void const [error, setError] = useState(redirect.error); const [accounts, setAccounts] = useState(null); const [code, setCode] = useState(null); + const [configured, setConfigured] = useState(null); const linkedCount = useRef(null); function forget(message: string | null) { @@ -55,6 +56,16 @@ export default function DiscordLoginForm(props: { setToken(token: string): void setError(message); } + useEffect(() => { + if (session || configured !== null) { + return; + } + fetch(`${webRoot}api/exp_discord_auth/status`) + .then(response => response.json()) + .then(json => setConfigured(json.configured)) + .catch(err => setError(`Failed to load Discord login: ${err.message}`)); + }, [session, configured]); + useEffect(() => { if (!session) { return undefined; @@ -110,10 +121,15 @@ export default function DiscordLoginForm(props: { setToken(token: string): void const errorAlert = error && ; if (!session) { + if (configured === false) { + return ; + } return <> {errorAlert} - Log in with your Discord account. Your Factorio account needs to be linked to it first, which you can do after logging in. - + Log in with a linked Discord account. New accounts can be linked after logging in. + ; } @@ -124,7 +140,9 @@ export default function DiscordLoginForm(props: { setToken(token: string): void const canLinkMore = accounts.accounts.length < accounts.max_accounts; return <> {errorAlert} - Logged in to Discord as {accounts.discord.name}. + + Logged in to Discord as {accounts.discord.name}. forget(null)}>Not you? + {accounts.accounts.length ? @@ -155,7 +173,5 @@ export default function DiscordLoginForm(props: { setToken(token: string): void {accounts.accounts.length ? "Link another Factorio account" : "Link a Factorio account"} } - - ; } diff --git a/exp_discord/web/components/UserDiscordLink.tsx b/exp_discord_auth/web/components/UserDiscordLink.tsx similarity index 88% rename from exp_discord/web/components/UserDiscordLink.tsx rename to exp_discord_auth/web/components/UserDiscordLink.tsx index 47ea1249..24d782c9 100644 --- a/exp_discord/web/components/UserDiscordLink.tsx +++ b/exp_discord_auth/web/components/UserDiscordLink.tsx @@ -13,7 +13,7 @@ type Link = { id: string, name: string } | null; /** An error from linking on Discord, taken out of the url so a reload does not show it again. */ function readError() { - const error = new URLSearchParams(window.location.hash.slice(1)).get("exp_discord_error"); + const error = new URLSearchParams(window.location.hash.slice(1)).get("exp_discord_auth_error"); if (error) { window.history.replaceState(null, "", window.location.pathname + window.location.search); } @@ -25,8 +25,8 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag const control = useContext(ControlContext); const account = useAccount(); const own = account.name.toLowerCase() === props.user.name.toLowerCase(); - const canGet = own || account.hasPermission("exp_discord.link.get"); - const canDelete = own || account.hasPermission("exp_discord.link.delete"); + const canGet = own || account.hasPermission("exp_discord_auth.link.get"); + const canDelete = own || account.hasPermission("exp_discord_auth.link.delete"); const [link, setLink] = useState(undefined); const [error, setError] = useState(readError); @@ -35,7 +35,7 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag return; } control.send(new DiscordLinkGetRequest(props.user.name)) - .then(setLink) + .then(response => setLink(response.link)) .catch(notifyErrorHandler("Error loading Discord link")); }, [props.user.name, canGet]); @@ -50,7 +50,7 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag async function startLink() { const ticket = await control.send(new DiscordLinkStartRequest(props.user.name)); - window.location.href = `${webRoot}api/exp_discord/authorize?link=${encodeURIComponent(ticket)}`; + window.location.href = `${webRoot}api/exp_discord_auth/authorize?link=${encodeURIComponent(ticket)}`; } return <> diff --git a/exp_discord/web/index.tsx b/exp_discord_auth/web/index.tsx similarity index 68% rename from exp_discord/web/index.tsx rename to exp_discord_auth/web/index.tsx index 31b21b69..cdcd6e87 100644 --- a/exp_discord/web/index.tsx +++ b/exp_discord_auth/web/index.tsx @@ -5,15 +5,15 @@ import DiscordLoginForm from "./components/DiscordLoginForm"; import UserDiscordLink from "./components/UserDiscordLink"; export default function registerPlugin(context: WebPluginContext) { - context.control.hooks.loginForms.attach("exp_discord", () => [ + context.control.hooks.loginForms.attach("exp_discord_auth", () => [ { - name: "exp_discord", + name: "exp_discord_auth", title: "Discord", Component: DiscordLoginForm, }, ]); - context.control.hooks.extensionComponents.attach("exp_discord", () => ({ + context.control.hooks.extensionComponents.attach("exp_discord_auth", () => ({ UserViewPage: UserDiscordLink, })); } diff --git a/exp_discord/webpack.config.cjs b/exp_discord_auth/webpack.config.cjs similarity index 89% rename from exp_discord/webpack.config.cjs rename to exp_discord_auth/webpack.config.cjs index ad9e8b82..0b445740 100644 --- a/exp_discord/webpack.config.cjs +++ b/exp_discord_auth/webpack.config.cjs @@ -13,8 +13,8 @@ module.exports = (env = {}) => merge(common(env), { }, plugins: [ new webpack.container.ModuleFederationPlugin({ - name: "exp_discord", - library: { type: "window", name: "plugin_exp_discord" }, + name: "exp_discord_auth", + library: { type: "window", name: "plugin_exp_discord_auth" }, exposes: { "./": "./index.ts", "./package.json": "./package.json", diff --git a/tsconfig.json b/tsconfig.json index 660ff4d9..aa571e33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "references": [ { "path": "./exp_auto_restart/" }, { "path": "./exp_commands/" }, - { "path": "./exp_discord/" }, + { "path": "./exp_discord_auth/" }, { "path": "./exp_groups/" }, { "path": "./exp_gui/" }, { "path": "./exp_legacy/" },