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 new file mode 100644 index 00000000..2edc6f4d --- /dev/null +++ b/exp_discord/controller.ts @@ -0,0 +1,447 @@ +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 new file mode 100644 index 00000000..f247c341 --- /dev/null +++ b/exp_discord/index.ts @@ -0,0 +1,74 @@ +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 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..4836866c --- /dev/null +++ b/exp_discord/messages.ts @@ -0,0 +1,194 @@ +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 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..707eaf32 --- /dev/null +++ b/exp_discord/test/controller.test.js @@ -0,0 +1,305 @@ +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 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..47ea1249 --- /dev/null +++ b/exp_discord/web/components/UserDiscordLink.tsx @@ -0,0 +1,87 @@ +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 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/" },