Add exp_discord_auth plugin for Discord login and account linking (#493)

* Add exp_discord plugin for Discord login and account linking

Players log in to the web interface with Discord through OAuth2 with the
identify scope, so no bot token is needed. After logging in they get a
code from the login page and type /link-discord <code> in game to link
that Factorio account. One Discord account can hold up to
exp_discord.max_accounts Factorio accounts (default 3), and linking a
name moves it off any other Discord account.

Sessions and link codes live in memory on the controller plugin, the
links are stored in database/exp_discord/accounts.json. The user page
shows the linked Discord account and lets the user, or anyone with
exp_discord.link.delete, unlink it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Link Discord from the user page and document the setup

The user page gets a Link Discord button. It asks the controller for a
one-time ticket bound to the logged in user, then goes through the
authorize route with it. The callback links the Discord account to that
user directly, without the in-game code, and goes back to the user page.
The link flow always shows the Discord consent screen.

The README covers registering the redirect URL and the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review of the Discord plugin

The plugin is now exp_discord_auth, so later Discord plugins can sit
next to it. Config keys, permissions, routes and the Lua module moved
with it, and the Discord redirect is now api/exp_discord_auth/callback.

The controller registers its handlers and routes in the constructor and
uses the express types, the instance plugin is a closure, and the Lua
commands file returns its commands. The get and unlink responses carry
the controller url so the in game messages can name the website.

The login form asks the new status route whether Discord is configured
and shows the error straight away instead of a button that fails. The
button is full width like the link button, and a "Not you?" link next
to the Discord name replaces the separate switch account button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cooldude2606andClaude Opus 5.5 authored and GitHub committed 2026-10-14 21:46:20 +01:00
commit b19bbbe5f3
23 files changed
+1697

No files matched your search

+1
View File
@@ -0,0 +1 @@
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
+43
View File
@@ -0,0 +1,43 @@
# ExpGaming - Discord Auth
Lets players log in to the web interface with Discord, and links their Factorio accounts to a Discord account. It only uses the OAuth2 `identify` scope, so no bot is needed.
## 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 `<controller.public_url>api/exp_discord_auth/callback`. For a controller with `controller.public_url` set to `https://example.com/cluster/` that is `https://example.com/cluster/api/exp_discord_auth/callback`. It must match exactly, including the trailing path and http or https.
4. Set the config on the controller:
```
npx clusterioctl controller config set controller.public_url https://example.com/cluster/
npx clusterioctl controller config set exp_discord_auth.client_id <client id>
npx clusterioctl controller config set exp_discord_auth.client_secret <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_auth.client_id` | | Application ID of the Discord application. |
| `exp_discord_auth.client_secret` | | OAuth2 client secret of the Discord application. Only readable on the controller. |
| `exp_discord_auth.max_accounts` | 3 | How many Factorio accounts one Discord account can link. |
## Linking
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_auth.link.get` and `exp_discord_auth.link.delete` permissions allow it for other users.
Once linked, the Discord tab on the login page has a "Log in as" button for each linked account. Banned users can't log in this way.
Links are stored in `database/exp_discord_auth/accounts.json` on the controller.
+444
View File
@@ -0,0 +1,444 @@
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
import type { NextFunction, Request, Response } from "express";
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
import * as crypto from "node:crypto";
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 };
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<string, Session>();
/** Codes waiting to be used in game, by code */
codes = new Map<string, LinkCode>();
/** Links started from the user page, by ticket until authorize turns them into a state */
linkTickets = new Map<string, WebLink>();
/** Links waiting for the Discord callback, by OAuth state */
pendingLinks = new Map<string, WebLink>();
cleanupInterval = setInterval(() => this.removeExpired(), 60e3).unref();
private constructor(
public controller: Controller,
public logger: lib.Logger,
public name: string,
public accounts: lib.SubscribableDatastore<messages.DiscordAccountRecord>,
) {
controller.handle(messages.DiscordLinkRequest, this.handleLinkRequest.bind(this));
controller.handle(messages.DiscordUnlinkRequest, this.handleUnlinkRequest.bind(this));
controller.handle(messages.DiscordLinkGetRequest, this.handleLinkGetRequest.bind(this));
controller.handle(messages.DiscordLinkStartRequest, this.handleLinkStartRequest.bind(this));
const route = (handler: (req: Request) => Promise<RouteResult>) => (
(req: Request, res: Response, next: NextFunction) => {
handler(req).then(result => {
if ("redirect" in result) {
if (result.cookie) {
res.setHeader("Set-Cookie", result.cookie);
}
res.redirect(result.redirect);
} else {
res.status(result.status).json(result.json);
}
}).catch(next);
}
);
const app = controller.app;
app.get("/api/exp_discord_auth/status", route(async () => this.status()));
app.get("/api/exp_discord_auth/authorize", route(async req => this.authorize(req.query.link)));
app.get("/api/exp_discord_auth/callback", route(async req => this.callback(
req.query, readCookie(req.headers.cookie, "exp_discord_auth_state"),
)));
app.get("/api/exp_discord_auth/accounts", route(async req => this.listAccounts(this.sessionId(req))));
app.post("/api/exp_discord_auth/login", route(async req => this.login(this.sessionId(req), req.query.name)));
app.post("/api/exp_discord_auth/link_code", route(async req => this.linkCode(this.sessionId(req))));
controller.hooks.save.attach(name, this.onSave.bind(this));
controller.hooks.shutdown.attach(name, this.onShutdown.bind(this));
}
static async fromContext(context: ControllerPluginContext) {
const controller = context.controller;
const databaseDirectory = controller.config.get("controller.database_directory");
const accounts = new lib.SubscribableDatastore(
...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "exp_discord_auth", "accounts.json"),
messages.DiscordAccountRecord.fromJSON.bind(messages.DiscordAccountRecord),
).bootstrap()
);
return new ControllerPlugin(controller, context.logger, context.plugin.name, accounts);
}
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
*/
sessionId(req: Request) {
const header = String(req.headers.authorization ?? "");
return header.startsWith("Bearer ") ? header.slice(7) : "";
}
redirectUri() {
return new URL("api/exp_discord_auth/callback", this.controller.getControllerUrl()).href;
}
/** Back to the web ui, with the outcome in the fragment so it never reaches a server log. */
webRedirect(key: "session" | "error", value: string, page = ""): RouteResult {
const url = new URL(page, this.controller.getControllerUrl());
url.hash = `exp_discord_auth_${key}=${encodeURIComponent(value)}`;
return { redirect: url.href };
}
userPage(playerName: string) {
return `users/${encodeURIComponent(playerName)}/view`;
}
isConfigured() {
return Boolean(
this.controller.config.get("exp_discord_auth.client_id")
&& this.controller.config.get("exp_discord_auth.client_secret")
);
}
/** Lets the login form say Discord is not set up before anyone presses the button. */
async status(): Promise<RouteResult> {
return { status: 200, json: { configured: this.isConfigured() } };
}
async authorize(linkTicket?: unknown): Promise<RouteResult> {
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) : "";
if (!this.isConfigured()) {
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: this.controller.config.get("exp_discord_auth.client_id")!,
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_auth_state=${state}; Path=${callbackUrl.pathname}; Max-Age=${stateTimeoutS}; HttpOnly; SameSite=Lax${secure}`,
};
}
async callback(query: Record<string, unknown>, cookieState: string | undefined): Promise<RouteResult> {
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<DiscordUser> {
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_auth.client_id") ?? "",
client_secret: this.controller.config.get("exp_discord_auth.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<RouteResult> {
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_auth.max_accounts"),
},
};
}
async login(sessionId: string, name: unknown): Promise<RouteResult> {
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<RouteResult> {
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 ${this.controller.getControllerUrl()}`
);
}
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_auth.max_accounts");
if (!alreadyLinked && (existing?.factorioNames.length ?? 0) >= maxAccounts) {
throw new lib.RequestError(
`${discordUser.name} already has ${maxAccounts} Factorio accounts linked, unlink one first`
);
}
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 { link: this.removeLink(request.playerName), url: this.controller.getControllerUrl() };
}
/** A ticket for /api/exp_discord_auth/authorize?link= which links the Discord account to the user. */
async handleLinkStartRequest(request: messages.DiscordLinkStartRequest) {
const ticket = crypto.randomBytes(16).toString("base64url");
this.linkTickets.set(ticket, { playerName: request.playerName, expiresMs: Date.now() + 60e3 });
return ticket;
}
async handleLinkGetRequest(request: messages.DiscordLinkGetRequest) {
const account = this.findAccount(request.playerName);
return {
link: account ? { id: account.id, name: account.name } : null,
url: this.controller.getControllerUrl(),
};
}
}
export default ControllerPlugin.fromContext.bind(ControllerPlugin);
+74
View File
@@ -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_auth.client_id": string | null;
"exp_discord_auth.client_secret": string | null;
"exp_discord_auth.max_accounts": number;
}
export interface Permissions {
"exp_discord_auth.link.get": never;
"exp_discord_auth.link.delete": never;
}
}
export const plugin: lib.PluginDeclaration = {
name: "exp_discord_auth",
title: "ExpGaming - Discord Auth",
description: "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord",
features: [
"SavePatching",
"ScriptCommands",
],
messages: [
messages.DiscordLinkRequest,
messages.DiscordUnlinkRequest,
messages.DiscordLinkGetRequest,
messages.DiscordLinkStartRequest,
],
permissions: [
{
name: "exp_discord_auth.link.get",
title: "Get Discord Links",
description: "See which Discord account any user is linked to, users can always see their own.",
grantByDefault: false,
},
{
name: "exp_discord_auth.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_auth.client_id": {
title: "Client ID",
description: "Application ID of the Discord application used for logging in.",
type: "string",
optional: true,
},
"exp_discord_auth.client_secret": {
title: "Client Secret",
description: "OAuth2 client secret of the Discord application used for logging in.",
type: "string",
credential: ["controller"],
optional: true,
},
"exp_discord_auth.max_accounts": {
title: "Max Accounts",
description: "Number of Factorio accounts which can be linked to one Discord account.",
type: "number",
initialValue: 3,
},
},
webEntrypoint: "./web",
};
+67
View File
@@ -0,0 +1,67 @@
import type { InstancePluginContext } from "@clusterio/host";
import * as messages from "./messages.js";
/** Sent by the lua side when a player uses a link code. */
export type IpcLink = {
player_name: string,
code: string,
};
/** Sent by the lua side when a player unlinks their Discord account, or asks which one they are linked to. */
export type IpcPlayer = {
player_name: string,
};
/** Bridges the lua side and the controller, the answer is printed to the player once it arrives. */
export default async function (context: InstancePluginContext) {
const { instance } = context;
/** Hand an answer to lua, unless the instance stopped while it was being fetched. */
async function luaSend(receiver: string, json: any) {
if (instance.status !== "running") {
return;
}
// Discord names can contain anything, so pick a long bracket level the json does not close
const text = JSON.stringify(json);
let level = "=";
while (text.includes(`]${level}]`)) {
level += "=";
}
await instance.sendRcon(
`/sc exp_discord_auth.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true
);
}
instance.server.handle("exp_discord_auth:link", async (event: IpcLink) => {
try {
const discord = await instance.sendTo("controller", new messages.DiscordLinkRequest(
event.player_name, event.code,
));
await luaSend("receive_linked", { player_name: event.player_name, discord_name: discord!.name });
} catch (err: any) {
await luaSend("receive_error", { player_name: event.player_name, message: err.message });
}
});
instance.server.handle("exp_discord_auth:unlink", async (event: IpcPlayer) => {
try {
const status = await instance.sendTo("controller", new messages.DiscordUnlinkRequest(event.player_name));
await luaSend("receive_unlinked", {
player_name: event.player_name, discord_name: status.link?.name, url: status.url,
});
} catch (err: any) {
await luaSend("receive_error", { player_name: event.player_name, message: err.message });
}
});
instance.server.handle("exp_discord_auth:get", async (event: IpcPlayer) => {
try {
const status = await instance.sendTo("controller", new messages.DiscordLinkGetRequest(event.player_name));
await luaSend("receive_get", {
player_name: event.player_name, discord_name: status.link?.name, url: status.url,
});
} catch (err: any) {
await luaSend("receive_error", { player_name: event.player_name, message: err.message });
}
});
}
+200
View File
@@ -0,0 +1,200 @@
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<typeof DiscordAccountRecord.jsonSchema> = {
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<typeof this.jsonSchema>) {
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 Link = Type.Union([
Type.Null(),
Type.Object({
id: Type.String(),
name: Type.String(),
}),
]);
const LinkResponse = lib.plainJson(Link);
/** The website is included so the player can be told where to link their account. */
const LinkStatusResponse = lib.plainJson(Type.Object({
link: Link,
url: Type.String(),
}));
/*
Link requests
*/
/** 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_auth" 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<typeof this.jsonSchema>) {
return new this(json.player_name, json.code);
}
}
/** Remove the link of a player, responds with the account it was linked to and the website url. */
export class DiscordUnlinkRequest {
declare ["constructor"]: typeof DiscordUnlinkRequest;
static plugin = "exp_discord_auth" as const;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = selfOrPermission("exp_discord_auth.link.delete");
static Response = LinkStatusResponse;
constructor(
public playerName: string,
) {}
static jsonSchema = Type.Object({
player_name: Type.String(),
});
toJSON() {
return { player_name: this.playerName };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.player_name);
}
}
/** Get the Discord account a player is linked to and the website url. */
export class DiscordLinkGetRequest {
declare ["constructor"]: typeof DiscordLinkGetRequest;
static plugin = "exp_discord_auth" as const;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = selfOrPermission("exp_discord_auth.link.get");
static Response = LinkStatusResponse;
constructor(
public playerName: string,
) {}
static jsonSchema = Type.Object({
player_name: Type.String(),
});
toJSON() {
return { player_name: this.playerName };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
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_auth" 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<typeof this.jsonSchema>) {
return new this(json.player_name);
}
}
+34
View File
@@ -0,0 +1,34 @@
--[[-- ExpDiscordAuth - Commands
Commands which link and unlink the player's Discord account
]]
local Commands = require("modules/exp_commands")
local Discord = require("modules/exp_discord_auth")
--- @class ExpDiscordAuth.commands
local commands = {}
--- Links your account to the Discord account which made the code, printed once the controller answers
commands.link_discord = Commands.new("link-discord", { "exp-discord-auth_commands.description-link" })
:optional("code", { "exp-discord-auth_commands.arg-code" }, Commands.types.string)
:add_aliases{ "link" }
:register(function(player, code)
--- @cast code string?
if code then
Discord.link(player, code)
else
Discord.get_linked(player)
end
end)
--- Removes the link to your Discord account, printed once the controller answers
commands.unlink_discord = Commands.new("unlink-discord", { "exp-discord-auth_commands.description-unlink" })
:add_aliases{ "unlink" }
:register(function(player)
Discord.unlink(player)
end)
return {
commands = commands,
}
+88
View File
@@ -0,0 +1,88 @@
--[[-- ExpDiscordAuth
Links Factorio accounts to Discord accounts, the links are kept on the controller.
A player logs in with Discord on the web interface, which gives them a code.
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 ExpDiscordAuth
local ExpDiscordAuth = {}
local error_settings = { color = ExpUtil.color.orange_red, sound_path = "utility/wire_pickup" }
--- Link the player to the Discord account which made the code
--- @param player LuaPlayer
--- @param code string
function ExpDiscordAuth.link(player, code)
clusterio_api.send_json("exp_discord_auth:link", { player_name = player.name, code = code })
end
--- Remove the link between the player and their Discord account
--- @param player LuaPlayer
function ExpDiscordAuth.unlink(player)
clusterio_api.send_json("exp_discord_auth:unlink", { player_name = player.name })
end
--- Print the Discord account the player is linked to
--- @param player LuaPlayer
function ExpDiscordAuth.get_linked(player)
clusterio_api.send_json("exp_discord_auth:get", { player_name = player.name })
end
--- A player who can still be printed to, nil once they have left
--- @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 ExpDiscordAuth.receive_linked(payload)
local player = get_online_player(payload.player_name)
if not player then return end
player.print{ "exp-discord-auth.linked", payload.discord_name }
end
--- The controller removed the link, discord_name is nil if there was none
--- @param payload { player_name: string, discord_name: string?, url: string }
function ExpDiscordAuth.receive_unlinked(payload)
local player = get_online_player(payload.player_name)
if not player then return end
if payload.discord_name then
player.print{ "exp-discord-auth.unlinked", payload.discord_name }
else
player.print({ "exp-discord-auth.not-linked", payload.url }, error_settings)
end
end
--- The controller answered which account the player is linked to
--- @param payload { player_name: string, discord_name: string?, url: string }
function ExpDiscordAuth.receive_get(payload)
local player = get_online_player(payload.player_name)
if not player then return end
if payload.discord_name then
player.print{ "exp-discord-auth.linked-to", payload.discord_name }
else
player.print{ "exp-discord-auth.not-linked", payload.url }
end
end
--- The controller refused a request
--- @param payload { player_name: string, message: string }
function ExpDiscordAuth.receive_error(payload)
local player = get_online_player(payload.player_name)
if not player then return end
player.print(payload.message, error_settings)
end
return ExpDiscordAuth
+10
View File
@@ -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_auth.foo()`
exp_discord_auth = require("modules/exp_discord_auth/control")
+10
View File
@@ -0,0 +1,10 @@
[exp-discord-auth]
linked=Your account is now linked to the Discord account __1__.
linked-to=Your account is linked to the Discord account __1__. Use /unlink-discord to remove it.
unlinked=Your account is no longer linked to the Discord account __1__.
not-linked=Your account is not linked to a Discord account. Log in with Discord at __1__ to get a code, then use /link-discord <code>.
[exp-discord-auth_commands]
description-link=Links your account to Discord using the code from the website, or shows the account you are linked to.
description-unlink=Removes the link between your account and Discord.
arg-code=Code shown on the website after logging in with Discord.
+14
View File
@@ -0,0 +1,14 @@
{
"name": "exp_discord_auth",
"load": [
],
"require": [
"globals.lua",
"commands.lua"
],
"dependencies": {
"clusterio": "*",
"exp_util": "*",
"exp_commands": "*"
}
}
@@ -0,0 +1,3 @@
-- Access the exports from other modules using require("modules/exp_discord_auth")
return require("modules/exp_discord_auth/control")
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@expcluster/discord-auth",
"version": "7.0.0",
"description": "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord",
"author": "Cooldude2606 <https://github.com/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": ">=22"
},
"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/express": "catalog:",
"@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"
]
}
+318
View File
@@ -0,0 +1,318 @@
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_auth.client_id": "123",
"exp_discord_auth.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_auth" }, 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_auth" }, metrics: undefined });
for (const hook of ["save", "shutdown"]) {
t2.ok([...controller.hooks[hook].attached].includes("exp_discord_auth"), `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_auth/callback");
const state = url.searchParams.get("state");
t3.match(result.cookie, `exp_discord_auth_state=${state}; Path=/cluster/api/exp_discord_auth/callback`);
t3.match(result.cookie, "Secure");
});
t2.test(".status() tells the login form whether Discord is configured", async t3 => {
const { plugin } = await startPlugin(t3);
t3.same(await plugin.status(), { status: 200, json: { configured: true } });
const { plugin: unconfigured } = await startPlugin(t3, { config: { "exp_discord_auth.client_id": null } });
t3.same(await unconfigured.status(), { status: 200, json: { configured: false } });
});
t2.test(".authorize() without credentials goes back with an error", async t3 => {
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.client_secret": null } });
const result = await plugin.authorize();
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_auth_error=/);
});
t2.test(".callback() rejects a state which does not match the cookie", async t3 => {
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_auth_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_auth_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, get a new one from https:\/\/example\.com\/cluster\//,
);
});
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_auth.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");
const url = "https://example.com/cluster/";
const get = name => plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest(name));
const unlink = name => plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest(name));
t3.same(await get("player1"), { link: alice, url });
t3.same(await unlink("Player1"), { link: alice, url });
t3.same(await get("Player1"), { link: null, url });
t3.same(await unlink("Player1"), { link: null, url });
});
t2.test("links are saved and loaded", async t3 => {
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_auth_error=Discord%20link/);
});
t2.test("errors go back to the user page", async t3 => {
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.max_accounts": 1 } });
await link(plugin, await discordLogin(plugin, alice), "Player2");
let params = await start(plugin, "Player1");
let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state"));
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_error=Discord%20login%20was%20cancelled$/);
params = await start(plugin, "Player1");
plugin.fetchDiscordUser = async () => alice;
result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_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_auth_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_auth.link.get", "exp_discord_auth.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_auth.link.delete"
: "exp_discord_auth.link.get";
t3.doesNotThrow(() => Request.permission(user("Alice"), request("alice")), "own link");
t3.throws(() => Request.permission(user("Alice"), request("Bob")), "someone else's");
t3.doesNotThrow(() => Request.permission(user("Alice", [permission]), request("Bob")), "with permission");
});
}
t2.end();
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" }
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.node.json",
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
@@ -0,0 +1,177 @@
import React, { useEffect, useRef, useState } from "react";
import { Alert, Button, Space, Spin, Typography } from "antd";
declare const webRoot: string;
const { Link, Paragraph, Text } = Typography;
const sessionKey = "exp_discord_auth_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_auth/${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_auth_session");
const error = params.get("exp_discord_auth_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<string | null>(redirect.session);
const [error, setError] = useState<string | null>(redirect.error);
const [accounts, setAccounts] = useState<Accounts | null>(null);
const [code, setCode] = useState<string | null>(null);
const [configured, setConfigured] = useState<boolean | null>(null);
const linkedCount = useRef<number | null>(null);
function forget(message: string | null) {
sessionStorage.removeItem(sessionKey);
setSession(null);
setAccounts(null);
setCode(null);
linkedCount.current = null;
setError(message);
}
useEffect(() => {
if (session || configured !== null) {
return;
}
fetch(`${webRoot}api/exp_discord_auth/status`)
.then(response => response.json())
.then(json => setConfigured(json.configured))
.catch(err => setError(`Failed to load Discord login: ${err.message}`));
}, [session, configured]);
useEffect(() => {
if (!session) {
return undefined;
}
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 && <Alert type="error" message={error} showIcon style={{ marginBottom: 16 }} />;
if (!session) {
if (configured === false) {
return <Alert type="error" message="Discord login is not configured on this controller" showIcon />;
}
return <>
{errorAlert}
<Paragraph>Log in with a linked Discord account. New accounts can be linked after logging in.</Paragraph>
<Button type="primary" block disabled={!configured} href={`${webRoot}api/exp_discord_auth/authorize`}>
Log in with Discord
</Button>
</>;
}
if (!accounts) {
return <Spin size="large" />;
}
const canLinkMore = accounts.accounts.length < accounts.max_accounts;
return <>
{errorAlert}
<Paragraph>
Logged in to Discord as <Text strong>{accounts.discord.name}</Text>. <Link onClick={() => forget(null)}>Not you?</Link>
</Paragraph>
{accounts.accounts.length
? <Space direction="vertical" style={{ width: "100%", marginBottom: 16 }}>
{accounts.accounts.map(name => <Button
key={name}
type="primary"
block
onClick={() => { login(name); }}
>Log in as {name}</Button>)}
</Space>
: <Paragraph>No Factorio accounts are linked to this Discord account yet.</Paragraph>}
{code
? <Alert
type="info"
style={{ marginBottom: 16 }}
message="Link your Factorio account"
description={<>
Join any of our servers and type this in the chat:
<Paragraph copyable={{ text: `/link-discord ${code}` }} style={{ margin: "8px 0" }}>
<Text code style={{ fontSize: 18 }}>/link-discord {code}</Text>
</Paragraph>
The code is valid for 10 minutes. This page continues once it has been used.
</>}
/>
: canLinkMore && <Paragraph>
<Button block onClick={() => { getCode(); }}>
{accounts.accounts.length ? "Link another Factorio account" : "Link a Factorio account"}
</Button>
</Paragraph>}
</>;
}
@@ -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_auth_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_auth.link.get");
const canDelete = own || account.hasPermission("exp_discord_auth.link.delete");
const [link, setLink] = useState<Link | undefined>(undefined);
const [error, setError] = useState(readError);
useEffect(() => {
if (!canGet) {
return;
}
control.send(new DiscordLinkGetRequest(props.user.name))
.then(response => setLink(response.link))
.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_auth/authorize?link=${encodeURIComponent(ticket)}`;
}
return <>
<SectionHeader title="Discord" />
{error && <Alert
type="error"
message={error}
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: 16 }}
/>}
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Linked account">
{link ? <>
{link.name}
{canDelete && <Popconfirm
title={`Unlink ${props.user.name} from ${link.name}?`}
onConfirm={() => { unlink().catch(notifyErrorHandler("Error unlinking Discord")); }}
>
<Button danger size="small" style={{ marginLeft: 8 }}>Unlink</Button>
</Popconfirm>}
</> : <>
Not linked
{own && <Button
size="small"
style={{ marginLeft: 8 }}
onClick={() => { startLink().catch(notifyErrorHandler("Error linking Discord")); }}
>Link Discord</Button>}
</>}
</Descriptions.Item>
</Descriptions>
</>;
}
+19
View File
@@ -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_auth", () => [
{
name: "exp_discord_auth",
title: "Discord",
Component: DiscordLoginForm,
},
]);
context.control.hooks.extensionComponents.attach("exp_discord_auth", () => ({
UserViewPage: UserDiscordLink,
}));
}
+34
View File
@@ -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_auth",
library: { type: "window", name: "plugin_exp_discord_auth" },
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 },
},
}),
],
});
+2
View File
@@ -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."],
+1
View File
@@ -3,6 +3,7 @@
"references": [
{ "path": "./exp_auto_restart/" },
{ "path": "./exp_commands/" },
{ "path": "./exp_discord_auth/" },
{ "path": "./exp_groups/" },
{ "path": "./exp_gui/" },
{ "path": "./exp_legacy/" },