From bf19ce242a06ff118d163098335117e42d78523d Mon Sep 17 00:00:00 2001 From: PHIDIAS Date: Tue, 3 Nov 2026 20:30:50 +0900 Subject: [PATCH] Delete exp_discord directory --- exp_discord/README.md | 43 -- exp_discord/controller.ts | 447 ------------------ exp_discord/index.ts | 74 --- exp_discord/instance.ts | 82 ---- exp_discord/messages.ts | 194 -------- 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 | 305 ------------ 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 | 87 ---- exp_discord/web/index.tsx | 19 - exp_discord/webpack.config.cjs | 34 -- 20 files changed, 1671 deletions(-) delete mode 100644 exp_discord/README.md delete mode 100644 exp_discord/controller.ts delete mode 100644 exp_discord/index.ts delete mode 100644 exp_discord/instance.ts delete mode 100644 exp_discord/messages.ts delete mode 100644 exp_discord/module/commands.lua delete mode 100644 exp_discord/module/control.lua delete mode 100644 exp_discord/module/globals.lua delete mode 100644 exp_discord/module/locale/en.cfg delete mode 100644 exp_discord/module/module.json delete mode 100644 exp_discord/module/module_exports.lua delete mode 100644 exp_discord/package.json delete mode 100644 exp_discord/test/controller.test.js delete mode 100644 exp_discord/tsconfig.browser.json delete mode 100644 exp_discord/tsconfig.json delete mode 100644 exp_discord/tsconfig.node.json delete mode 100644 exp_discord/web/components/DiscordLoginForm.tsx delete mode 100644 exp_discord/web/components/UserDiscordLink.tsx delete mode 100644 exp_discord/web/index.tsx delete mode 100644 exp_discord/webpack.config.cjs diff --git a/exp_discord/README.md b/exp_discord/README.md deleted file mode 100644 index 0c2402ba..00000000 --- a/exp_discord/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 2edc6f4d..00000000 --- a/exp_discord/controller.ts +++ /dev/null @@ -1,447 +0,0 @@ -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 }; -type WebLink = { playerName: 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(); - /** 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( - 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)); - 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; - } - - 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 map of [this.codes, this.linkTickets, this.pendingLinks]) { - for (const [key, entry] of map) { - if (entry.expiresMs < now) { - map.delete(key); - } - } - } - } - - /* - 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) { - 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, page = ""): RouteResult { - const url = new URL(page, this.controller.getControllerUrl()); - url.hash = `exp_discord_${key}=${encodeURIComponent(value)}`; - return { redirect: url.href }; - } - - 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", 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", - client_id: clientId, - scope: "identify", - state, - redirect_uri: this.redirectUri(), - // 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()); - 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 { - const link = typeof query.state === "string" ? this.pendingLinks.get(query.state) : undefined; - if (link) { - this.pendingLinks.delete(query.state as string); - } - 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", page); - } - - 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", 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); - 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 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( - `${discordUser.name} already has ${maxAccounts} Factorio accounts linked, unlink one first` - ); - } - - if (!alreadyLinked) { - 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: 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; - } -} - -export default async function (context: ControllerPluginContext) { - await ControllerPlugin.fromContext(context); -} diff --git a/exp_discord/index.ts b/exp_discord/index.ts deleted file mode 100644 index f247c341..00000000 --- a/exp_discord/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -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, - messages.DiscordLinkStartRequest, - ], - - 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 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/messages.ts b/exp_discord/messages.ts deleted file mode 100644 index 4836866c..00000000 --- a/exp_discord/messages.ts +++ /dev/null @@ -1,194 +0,0 @@ -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); - } - }; -} - -/** 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({ - 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); - } -} - -/** 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/module/commands.lua b/exp_discord/module/commands.lua deleted file mode 100644 index eb8f3df6..00000000 --- a/exp_discord/module/commands.lua +++ /dev/null @@ -1,30 +0,0 @@ ---[[-- 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 deleted file mode 100644 index 2aa6869a..00000000 --- a/exp_discord/module/control.lua +++ /dev/null @@ -1,88 +0,0 @@ ---[[-- 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 deleted file mode 100644 index 862e1014..00000000 --- a/exp_discord/module/globals.lua +++ /dev/null @@ -1,10 +0,0 @@ ---[[ -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 deleted file mode 100644 index 57d0ac13..00000000 --- a/exp_discord/module/locale/en.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[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 deleted file mode 100644 index 7fea5b7c..00000000 --- a/exp_discord/module/module.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 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/package.json b/exp_discord/package.json deleted file mode 100644 index 80694ab6..00000000 --- a/exp_discord/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "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 deleted file mode 100644 index 707eaf32..00000000 --- a/exp_discord/test/controller.test.js +++ /dev/null @@ -1,305 +0,0 @@ -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("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, - checkPermission(permission) { - if (!granted.includes(permission)) { - throw new lib.PermissionError("denied"); - } - }, - }); - 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 - ? "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 deleted file mode 100644 index a621b69f..00000000 --- a/exp_discord/tsconfig.browser.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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 deleted file mode 100644 index 303b8f28..00000000 --- a/exp_discord/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.browser.json" } - ] -} diff --git a/exp_discord/tsconfig.node.json b/exp_discord/tsconfig.node.json deleted file mode 100644 index 3218f2e7..00000000 --- a/exp_discord/tsconfig.node.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index 39f7645e..00000000 --- a/exp_discord/web/components/DiscordLoginForm.tsx +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index 47ea1249..00000000 --- a/exp_discord/web/components/UserDiscordLink.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useContext, useEffect, useState } from "react"; -import { Alert, Button, Descriptions, Popconfirm } from "antd"; - -import { - ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount, -} from "@clusterio/web_ui"; - -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); - 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); - const [error, setError] = useState(readError); - - 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); - } - - 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 ? <> - {link.name} - {canDelete && { unlink().catch(notifyErrorHandler("Error unlinking Discord")); }} - > - - } - : <> - Not linked - {own && } - } - - - ; -} diff --git a/exp_discord/web/index.tsx b/exp_discord/web/index.tsx deleted file mode 100644 index 31b21b69..00000000 --- a/exp_discord/web/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index ad9e8b82..00000000 --- a/exp_discord/webpack.config.cjs +++ /dev/null @@ -1,34 +0,0 @@ -"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 }, - }, - }), - ], -});