mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-14 00:14:52 +09:00
Delete exp_discord directory
This commit is contained in:
1 parent
99f17a83ce
commit
bf19ce242a
20 files changed
-1671
No files matched your search
@@ -1,43 +0,0 @@
|
||||
# ExpGaming - Discord
|
||||
|
||||
Lets players log in to the web interface with Discord, and links their Factorio accounts to a Discord account. It only uses the OAuth2 `identify` scope, so no bot is needed.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an application in the [Discord developer portal](https://discord.com/developers/applications).
|
||||
2. Under OAuth2, copy the Client ID and reset the Client Secret to get one.
|
||||
3. Under OAuth2 → Redirects, add `<controller.public_url>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 <client id>
|
||||
npx clusterioctl controller config set exp_discord.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.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.
|
||||
@@ -1,447 +0,0 @@
|
||||
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
import * as crypto from "node:crypto";
|
||||
import * as path from "node:path";
|
||||
|
||||
const discordApi = "https://discord.com/api/v10";
|
||||
const stateTimeoutS = 10 * 60;
|
||||
const sessionTimeoutMs = 30 * 60e3;
|
||||
const codeTimeoutMs = 10 * 60e3;
|
||||
const codeLength = 6;
|
||||
// 0, O, 1 and I are left out because they are easy to mix up when typing the code in game
|
||||
const codeLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
type DiscordUser = { id: string, name: string };
|
||||
type Session = DiscordUser & { expiresMs: number, code?: string };
|
||||
type LinkCode = { sessionId: string, expiresMs: number };
|
||||
type WebLink = { playerName: string, expiresMs: number };
|
||||
|
||||
/** What an http route answers with, kept apart from express so it can be tested. */
|
||||
export type RouteResult =
|
||||
| { redirect: string, cookie?: string }
|
||||
| { status: number, json: unknown };
|
||||
|
||||
type HttpRequest = {
|
||||
query: Record<string, unknown>,
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
};
|
||||
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<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?: ReturnType<typeof setInterval>;
|
||||
|
||||
private constructor(
|
||||
public controller: Controller,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
public accounts: lib.SubscribableDatastore<messages.DiscordAccountRecord>,
|
||||
) {}
|
||||
|
||||
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<RouteResult>) => (
|
||||
(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<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) : "";
|
||||
|
||||
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<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.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<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.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 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);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"exp_discord.client_id": string | null;
|
||||
"exp_discord.client_secret": string | null;
|
||||
"exp_discord.max_accounts": number;
|
||||
}
|
||||
export interface Permissions {
|
||||
"exp_discord.link.get": never;
|
||||
"exp_discord.link.delete": never;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "exp_discord",
|
||||
title: "ExpGaming - Discord",
|
||||
description: "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord",
|
||||
|
||||
features: [
|
||||
"SavePatching",
|
||||
"ScriptCommands",
|
||||
],
|
||||
|
||||
messages: [
|
||||
messages.DiscordLinkRequest,
|
||||
messages.DiscordUnlinkRequest,
|
||||
messages.DiscordLinkGetRequest,
|
||||
messages.DiscordLinkStartRequest,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
{
|
||||
name: "exp_discord.link.get",
|
||||
title: "Get Discord Links",
|
||||
description: "See which Discord account any user is linked to, users can always see their own.",
|
||||
grantByDefault: false,
|
||||
},
|
||||
{
|
||||
name: "exp_discord.link.delete",
|
||||
title: "Remove Discord Links",
|
||||
description: "Unlink any user from their Discord account, users can always unlink themselves.",
|
||||
grantByDefault: false,
|
||||
},
|
||||
],
|
||||
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
controllerConfigFields: {
|
||||
"exp_discord.client_id": {
|
||||
title: "Client ID",
|
||||
description: "Application ID of the Discord application used for logging in.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"exp_discord.client_secret": {
|
||||
title: "Client Secret",
|
||||
description: "OAuth2 client secret of the Discord application used for logging in.",
|
||||
type: "string",
|
||||
credential: ["controller"],
|
||||
optional: true,
|
||||
},
|
||||
"exp_discord.max_accounts": {
|
||||
title: "Max Accounts",
|
||||
description: "Number of Factorio accounts which can be linked to one Discord account.",
|
||||
type: "number",
|
||||
initialValue: 3,
|
||||
},
|
||||
},
|
||||
|
||||
webEntrypoint: "./web",
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { Instance, InstancePluginContext } from "@clusterio/host";
|
||||
import type * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
/** Sent by the lua side when a player uses a link code. */
|
||||
export type IpcLink = {
|
||||
player_name: string,
|
||||
code: string,
|
||||
};
|
||||
|
||||
/** Sent by the lua side when a player unlinks their Discord account, or asks which one they are linked to. */
|
||||
export type IpcPlayer = {
|
||||
player_name: string,
|
||||
};
|
||||
|
||||
/** Bridges the lua side and the controller, the answer is printed to the player once it arrives. */
|
||||
export class InstancePlugin {
|
||||
private constructor(
|
||||
public instance: Instance,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
) {}
|
||||
|
||||
static async fromContext(context: InstancePluginContext) {
|
||||
const instance = context.instance;
|
||||
const plugin = new InstancePlugin(instance, context.logger, context.plugin.name);
|
||||
|
||||
instance.server.handle("exp_discord:link", plugin.handleLinkIPC.bind(plugin));
|
||||
instance.server.handle("exp_discord:unlink", plugin.handleUnlinkIPC.bind(plugin));
|
||||
instance.server.handle("exp_discord:get", plugin.handleGetIPC.bind(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async handleLinkIPC(event: IpcLink) {
|
||||
try {
|
||||
const discord = await this.instance.sendTo("controller", new messages.DiscordLinkRequest(
|
||||
event.player_name, event.code,
|
||||
));
|
||||
await this.luaSend("receive_linked", { player_name: event.player_name, discord_name: discord!.name });
|
||||
} catch (err: any) {
|
||||
await this.luaSend("receive_error", { player_name: event.player_name, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnlinkIPC(event: IpcPlayer) {
|
||||
try {
|
||||
const discord = await this.instance.sendTo("controller", new messages.DiscordUnlinkRequest(event.player_name));
|
||||
await this.luaSend("receive_unlinked", { player_name: event.player_name, discord_name: discord?.name });
|
||||
} catch (err: any) {
|
||||
await this.luaSend("receive_error", { player_name: event.player_name, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async handleGetIPC(event: IpcPlayer) {
|
||||
try {
|
||||
const discord = await this.instance.sendTo("controller", new messages.DiscordLinkGetRequest(event.player_name));
|
||||
await this.luaSend("receive_get", { player_name: event.player_name, discord_name: discord?.name });
|
||||
} catch (err: any) {
|
||||
await this.luaSend("receive_error", { player_name: event.player_name, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand an answer to lua, unless the instance stopped while it was being fetched. */
|
||||
async luaSend(receiver: string, json: any) {
|
||||
if (this.instance.status !== "running") {
|
||||
return;
|
||||
}
|
||||
// Discord names can contain anything, so pick a long bracket level the json does not close
|
||||
const text = JSON.stringify(json);
|
||||
let level = "=";
|
||||
while (text.includes(`]${level}]`)) {
|
||||
level += "=";
|
||||
}
|
||||
await this.instance.sendRcon(
|
||||
`/sc exp_discord.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: InstancePluginContext) {
|
||||
await InstancePlugin.fromContext(context);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/*
|
||||
Data records
|
||||
*/
|
||||
|
||||
/** A Discord account and the Factorio accounts linked to it. */
|
||||
export class DiscordAccountRecord {
|
||||
constructor(
|
||||
/** Discord user id */
|
||||
public id: string,
|
||||
/** Discord display name at the time of the last login */
|
||||
public name: string,
|
||||
public factorioNames: string[],
|
||||
public updatedAtMs: number = 0,
|
||||
public isDeleted: boolean = false,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
name: Type.String(),
|
||||
factorio_names: Type.Array(Type.String()),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
const json: Static<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 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<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. */
|
||||
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<typeof this.jsonSchema>) {
|
||||
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<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" 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);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
--[[-- ExpDiscord - Commands
|
||||
Commands which link and unlink the player's Discord account
|
||||
]]
|
||||
|
||||
local Commands = require("modules/exp_commands")
|
||||
|
||||
local Discord = require("modules/exp_discord")
|
||||
|
||||
--- Links your account to the Discord account which made the code, printed once the controller answers
|
||||
Commands.new("link-discord", { "exp-discord_commands.description-link" })
|
||||
:optional("code", { "exp-discord_commands.arg-code" }, Commands.types.string)
|
||||
:add_aliases{ "link" }
|
||||
:register(function(player, code)
|
||||
--- @cast code string?
|
||||
if code then
|
||||
Discord.link(player, code)
|
||||
else
|
||||
Discord.get_link(player)
|
||||
end
|
||||
end)
|
||||
|
||||
--- Removes the link to your Discord account, printed once the controller answers
|
||||
Commands.new("unlink-discord", { "exp-discord_commands.description-unlink" })
|
||||
:add_aliases{ "unlink" }
|
||||
:register(function(player)
|
||||
Discord.unlink(player)
|
||||
end)
|
||||
|
||||
-- Access the exports from other modules using require("modules/exp_discord")
|
||||
return require("modules/exp_discord/control")
|
||||
@@ -1,88 +0,0 @@
|
||||
--[[-- ExpDiscord
|
||||
Links Factorio accounts to Discord accounts, the links are kept on the controller.
|
||||
|
||||
A player logs in with Discord on the web interface, which gives them a code.
|
||||
Typing that code in game links the account they are playing on. Nothing is
|
||||
stored in the lua state, every call goes to the controller and the answer is
|
||||
printed to the player once it arrives, if they are still online.
|
||||
]]
|
||||
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local ExpUtil = require("modules/exp_util")
|
||||
|
||||
--- @class ExpDiscord
|
||||
local ExpDiscord = {}
|
||||
|
||||
local error_settings = { color = ExpUtil.color.orange_red, sound_path = "utility/wire_pickup" }
|
||||
|
||||
--- Link the player to the Discord account which made the code
|
||||
--- @param player LuaPlayer
|
||||
--- @param code string
|
||||
function ExpDiscord.link(player, code)
|
||||
clusterio_api.send_json("exp_discord:link", { player_name = player.name, code = code })
|
||||
end
|
||||
|
||||
--- Remove the link between the player and their Discord account
|
||||
--- @param player LuaPlayer
|
||||
function ExpDiscord.unlink(player)
|
||||
clusterio_api.send_json("exp_discord:unlink", { player_name = player.name })
|
||||
end
|
||||
|
||||
--- Print the Discord account the player is linked to
|
||||
--- @param player LuaPlayer
|
||||
function ExpDiscord.get_link(player)
|
||||
clusterio_api.send_json("exp_discord:get", { player_name = player.name })
|
||||
end
|
||||
|
||||
--- A player who can still be printed to, nil once they have left
|
||||
--- @param name string
|
||||
--- @return LuaPlayer?
|
||||
local function get_online_player(name)
|
||||
local player = game.get_player(name)
|
||||
if player and player.valid and player.connected then
|
||||
return player
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- The controller linked the player
|
||||
--- @param payload { player_name: string, discord_name: string }
|
||||
function ExpDiscord.receive_linked(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
player.print{ "exp-discord.linked", payload.discord_name }
|
||||
end
|
||||
|
||||
--- The controller removed the link, discord_name is nil if there was none
|
||||
--- @param payload { player_name: string, discord_name: string? }
|
||||
function ExpDiscord.receive_unlinked(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
if payload.discord_name then
|
||||
player.print{ "exp-discord.unlinked", payload.discord_name }
|
||||
else
|
||||
player.print({ "exp-discord.not-linked" }, error_settings)
|
||||
end
|
||||
end
|
||||
|
||||
--- The controller answered which account the player is linked to
|
||||
--- @param payload { player_name: string, discord_name: string? }
|
||||
function ExpDiscord.receive_get(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
if payload.discord_name then
|
||||
player.print{ "exp-discord.linked-to", payload.discord_name }
|
||||
else
|
||||
player.print{ "exp-discord.not-linked" }
|
||||
end
|
||||
end
|
||||
|
||||
--- The controller refused a request
|
||||
--- @param payload { player_name: string, message: string }
|
||||
function ExpDiscord.receive_error(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
player.print(payload.message, error_settings)
|
||||
end
|
||||
|
||||
return ExpDiscord
|
||||
@@ -1,10 +0,0 @@
|
||||
--[[
|
||||
It is best practice to not expose any globals because all modules share a global environment
|
||||
However, sometimes you need globals, for example to access functions within rcon commands
|
||||
Therefore, we advise that this should be the only file in your module to expose globals
|
||||
]]
|
||||
|
||||
--- @diagnostic disable: global-in-non-module
|
||||
|
||||
-- Access using `/sc exp_discord.foo()`
|
||||
exp_discord = require("modules/exp_discord/control")
|
||||
@@ -1,10 +0,0 @@
|
||||
[exp-discord]
|
||||
linked=Your account is now linked to the Discord account __1__.
|
||||
linked-to=Your account is linked to the Discord account __1__. Use /unlink-discord to remove it.
|
||||
unlinked=Your account is no longer linked to the Discord account __1__.
|
||||
not-linked=Your account is not linked to a Discord account. Log in with Discord on the website to get a code, then use /link-discord <code>.
|
||||
|
||||
[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.
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "exp_discord",
|
||||
"load": [
|
||||
],
|
||||
"require": [
|
||||
"globals.lua",
|
||||
"commands.lua"
|
||||
],
|
||||
"dependencies": {
|
||||
"clusterio": "*",
|
||||
"exp_util": "*",
|
||||
"exp_commands": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
-- Access the exports from other modules using require("modules/exp_discord")
|
||||
return require("modules/exp_discord/control")
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "@expcluster/discord",
|
||||
"version": "7.0.0",
|
||||
"description": "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord",
|
||||
"author": "Cooldude2606 <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": ">=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"
|
||||
]
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller } from "@clusterio/controller";
|
||||
import entrypoint, { ControllerPlugin } from "../dist/node/controller.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
|
||||
for (const Message of pluginDeclaration.messages) {
|
||||
lib.Link.register(Message);
|
||||
}
|
||||
lib.addPluginConfigFields([pluginDeclaration]);
|
||||
|
||||
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
|
||||
|
||||
lib.logger.silent = true;
|
||||
t.after(() => {
|
||||
lib.logger.silent = false;
|
||||
});
|
||||
|
||||
const alice = { id: "100", name: "Alice" };
|
||||
const bob = { id: "200", name: "Bob" };
|
||||
|
||||
/** Build the plugin around a real controller, which is side effect free while not started. */
|
||||
async function startPlugin(t2, { config = {} } = {}) {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", {
|
||||
// testdir() empties the folder, so a reload passes the directory in
|
||||
"controller.database_directory": config["controller.database_directory"] ?? t2.testdir(),
|
||||
"controller.public_url": "https://example.com/cluster/",
|
||||
"exp_discord.client_id": "123",
|
||||
"exp_discord.client_secret": "secret",
|
||||
"controller.auth_secret": Buffer.from("test secret").toString("base64"),
|
||||
...config,
|
||||
});
|
||||
const controller = new Controller(logger, [], controllerConfig);
|
||||
for (const name of ["Player1", "Player2", "Player3", "Player4"]) {
|
||||
controller.users.createUser(name);
|
||||
}
|
||||
|
||||
const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_discord" }, controller, logger });
|
||||
t2.teardown(() => clearInterval(plugin.cleanupInterval));
|
||||
return { plugin, controller };
|
||||
}
|
||||
|
||||
/** Go through the Discord callback as the given Discord user, returning the session id. */
|
||||
async function discordLogin(plugin, discordUser) {
|
||||
plugin.fetchDiscordUser = async () => discordUser;
|
||||
const result = await plugin.callback({ code: "c", state: "s" }, "s");
|
||||
return decodeURIComponent(new URL(result.redirect).hash.split("=")[1]);
|
||||
}
|
||||
|
||||
/** Get a code on the web and use it in game as playerName. */
|
||||
async function link(plugin, session, playerName) {
|
||||
const { json } = await plugin.linkCode(session);
|
||||
return plugin.handleLinkRequest(new messages.DiscordLinkRequest(playerName, json.code.toLowerCase()));
|
||||
}
|
||||
|
||||
t.test("entrypoint", async t2 => {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
|
||||
const controller = new Controller(logger, [], controllerConfig);
|
||||
await entrypoint({ controller, logger, plugin: { name: "exp_discord" }, metrics: undefined });
|
||||
for (const hook of ["save", "shutdown"]) {
|
||||
t2.ok([...controller.hooks[hook].attached].includes("exp_discord"), `attached to ${hook}`);
|
||||
}
|
||||
await controller.hooks.shutdown.invoke();
|
||||
});
|
||||
|
||||
t.test("oauth", t2 => {
|
||||
t2.test(".authorize() redirects to Discord with a state cookie scoped to the callback", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const result = await plugin.authorize();
|
||||
const url = new URL(result.redirect);
|
||||
t3.equal(url.origin + url.pathname, "https://discord.com/oauth2/authorize");
|
||||
t3.equal(url.searchParams.get("client_id"), "123");
|
||||
t3.equal(url.searchParams.get("redirect_uri"), "https://example.com/cluster/api/exp_discord/callback");
|
||||
const state = url.searchParams.get("state");
|
||||
t3.match(result.cookie, `exp_discord_state=${state}; Path=/cluster/api/exp_discord/callback`);
|
||||
t3.match(result.cookie, "Secure");
|
||||
});
|
||||
|
||||
t2.test(".authorize() without credentials goes back with an error", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { config: { "exp_discord.client_secret": null } });
|
||||
const result = await plugin.authorize();
|
||||
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_error=/);
|
||||
});
|
||||
|
||||
t2.test(".callback() rejects a state which does not match the cookie", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
plugin.fetchDiscordUser = async () => t3.fail("should not fetch");
|
||||
for (const cookie of [undefined, "other"]) {
|
||||
const result = await plugin.callback({ code: "c", state: "s" }, cookie);
|
||||
t3.match(result.redirect, "#exp_discord_error=");
|
||||
}
|
||||
t3.equal(plugin.sessions.size, 0);
|
||||
});
|
||||
|
||||
t2.test(".callback() reports a failed token exchange as an error", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
plugin.fetchDiscordUser = async () => { throw new Error("401"); };
|
||||
const result = await plugin.callback({ code: "c", state: "s" }, "s");
|
||||
t3.match(result.redirect, "#exp_discord_error=");
|
||||
});
|
||||
|
||||
t2.test(".callback() starts a session", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
const result = await plugin.listAccounts(session);
|
||||
t3.same(result, { status: 200, json: { discord: alice, accounts: [], max_accounts: 3 } });
|
||||
});
|
||||
|
||||
t2.test(".listAccounts() rejects unknown and expired sessions", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
t3.equal((await plugin.listAccounts("nope")).status, 401);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
plugin.sessions.get(session).expiresMs = Date.now() - 1;
|
||||
t3.equal((await plugin.listAccounts(session)).status, 401);
|
||||
});
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("linking", t2 => {
|
||||
t2.test("a code links the player who uses it, and they can then log in", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
t3.same(await link(plugin, session, "Player1"), alice);
|
||||
t3.same((await plugin.listAccounts(session)).json.accounts, ["Player1"]);
|
||||
|
||||
const result = await plugin.login(session, "player1");
|
||||
t3.equal(result.status, 200);
|
||||
const payload = JSON.parse(Buffer.from(result.json.token.split(".")[1], "base64url").toString());
|
||||
t3.equal(payload.user, controller.users.getByName("Player1").id);
|
||||
t3.notOk(plugin.sessions.has(session), "session is used up");
|
||||
});
|
||||
|
||||
t2.test("a code can only be used once", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
const { json } = await plugin.linkCode(session);
|
||||
await plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code));
|
||||
await t3.rejects(
|
||||
plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player2", json.code)), /invalid or has expired/,
|
||||
);
|
||||
});
|
||||
|
||||
t2.test("asking twice gives the same code", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
t3.equal((await plugin.linkCode(session)).json.code, (await plugin.linkCode(session)).json.code);
|
||||
});
|
||||
|
||||
t2.test("expired codes are refused", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
const { json } = await plugin.linkCode(session);
|
||||
plugin.codes.get(json.code).expiresMs = Date.now() - 1;
|
||||
await t3.rejects(plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code)));
|
||||
});
|
||||
|
||||
t2.test("several accounts up to the limit", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 2 } });
|
||||
const session = await discordLogin(plugin, alice);
|
||||
await link(plugin, session, "Player1");
|
||||
await link(plugin, session, "Player2");
|
||||
await t3.rejects(link(plugin, session, "Player3"), /already has 2 Factorio accounts/);
|
||||
t3.same(await link(plugin, session, "PLAYER1"), alice, "relinking a linked account is fine at the limit");
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1", "Player2"]);
|
||||
});
|
||||
|
||||
t2.test("linking moves the account away from another Discord account", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
||||
await link(plugin, await discordLogin(plugin, bob), "Player1");
|
||||
t3.notOk(plugin.accounts.has(alice.id), "empty account is removed");
|
||||
t3.same(plugin.accounts.get(bob.id).factorioNames, ["Player1"]);
|
||||
});
|
||||
|
||||
t2.test(".login() refuses accounts not linked, missing users and banned users", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
t3.equal((await plugin.login(session, "Player2")).status, 403);
|
||||
|
||||
await link(plugin, session, "Player1");
|
||||
controller.users.getByNameMutable("Player1").set("isBanned", true);
|
||||
t3.equal((await plugin.login(session, "Player1")).status, 403);
|
||||
|
||||
await link(plugin, session, "Ghost");
|
||||
t3.equal((await plugin.login(session, "Ghost")).status, 404);
|
||||
});
|
||||
|
||||
t2.test("unlink and get", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
||||
t3.same(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("player1")), alice);
|
||||
t3.same(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), alice);
|
||||
t3.equal(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("Player1")), null);
|
||||
t3.equal(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), null);
|
||||
});
|
||||
|
||||
t2.test("links are saved and loaded", async t3 => {
|
||||
const dir = t3.testdir();
|
||||
const config = { "controller.database_directory": dir };
|
||||
const { plugin } = await startPlugin(t3, { config });
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
||||
await plugin.onShutdown();
|
||||
|
||||
const { plugin: reloaded } = await startPlugin(t3, { config });
|
||||
t3.same(reloaded.accounts.get(alice.id).factorioNames, ["Player1"]);
|
||||
});
|
||||
|
||||
t2.test(".removeExpired() drops old sessions and codes", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
await plugin.linkCode(session);
|
||||
plugin.removeExpired(Date.now() + 60 * 60e3);
|
||||
t3.equal(plugin.sessions.size, 0);
|
||||
t3.equal(plugin.codes.size, 0);
|
||||
});
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("linking from the user page", t2 => {
|
||||
/** Start a link for playerName and follow authorize, returning the OAuth state. */
|
||||
async function start(plugin, playerName) {
|
||||
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest(playerName));
|
||||
const result = await plugin.authorize(ticket);
|
||||
return new URL(result.redirect).searchParams;
|
||||
}
|
||||
|
||||
t2.test("links the Discord account and goes back to the user page", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const params = await start(plugin, "Player1");
|
||||
t3.equal(params.get("prompt"), null, "consent screen is shown");
|
||||
plugin.fetchDiscordUser = async () => alice;
|
||||
const result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
||||
t3.equal(result.redirect, "https://example.com/cluster/users/Player1/view");
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1"]);
|
||||
t3.equal(plugin.sessions.size, 0, "no login session is started");
|
||||
t3.equal(plugin.pendingLinks.size, 0);
|
||||
});
|
||||
|
||||
t2.test("a ticket can only be used once", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest("Player1"));
|
||||
await plugin.authorize(ticket);
|
||||
const result = await plugin.authorize(ticket);
|
||||
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_error=Discord%20link%20expired/);
|
||||
});
|
||||
|
||||
t2.test("errors go back to the user page", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 1 } });
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player2");
|
||||
|
||||
let params = await start(plugin, "Player1");
|
||||
let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state"));
|
||||
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=Discord%20login%20was%20cancelled$/);
|
||||
|
||||
params = await start(plugin, "Player1");
|
||||
plugin.fetchDiscordUser = async () => alice;
|
||||
result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
||||
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=.*already%20has%201/);
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player2"]);
|
||||
});
|
||||
|
||||
t2.test("a state without the cookie does not link", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const params = await start(plugin, "Player1");
|
||||
plugin.fetchDiscordUser = async () => t3.fail("should not fetch");
|
||||
const result = await plugin.callback({ code: "c", state: params.get("state") }, undefined);
|
||||
t3.match(result.redirect, "/users/Player1/view#exp_discord_error=");
|
||||
t3.notOk(plugin.accounts.has(alice.id));
|
||||
});
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("permissions", t2 => {
|
||||
const user = (name, granted = []) => ({
|
||||
name,
|
||||
checkPermission(permission) {
|
||||
if (!granted.includes(permission)) {
|
||||
throw new lib.PermissionError("denied");
|
||||
}
|
||||
},
|
||||
});
|
||||
const request = name => ({ data: { player_name: name } });
|
||||
|
||||
t2.test("DiscordLinkStartRequest", async t3 => {
|
||||
const permission = messages.DiscordLinkStartRequest.permission;
|
||||
t3.doesNotThrow(() => permission(user("Alice"), request("alice")), "own account");
|
||||
t3.throws(
|
||||
() => permission(user("Alice", ["exp_discord.link.get", "exp_discord.link.delete"]), request("Bob")),
|
||||
"never someone else's",
|
||||
);
|
||||
});
|
||||
|
||||
for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) {
|
||||
t2.test(Request.name, async t3 => {
|
||||
const permission = Request === messages.DiscordUnlinkRequest
|
||||
? "exp_discord.link.delete"
|
||||
: "exp_discord.link.get";
|
||||
t3.doesNotThrow(() => Request.permission(user("Alice"), request("alice")), "own link");
|
||||
t3.throws(() => Request.permission(user("Alice"), request("Bob")), "someone else's");
|
||||
t3.doesNotThrow(() => Request.permission(user("Alice", [permission]), request("Bob")), "with permission");
|
||||
});
|
||||
}
|
||||
t2.end();
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.browser.json",
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.node.json",
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, Space, Spin, Typography } from "antd";
|
||||
|
||||
declare const webRoot: string;
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
const sessionKey = "exp_discord_session";
|
||||
|
||||
type Accounts = {
|
||||
discord: { id: string, name: string },
|
||||
accounts: string[],
|
||||
max_accounts: number,
|
||||
};
|
||||
|
||||
async function api(method: "GET" | "POST", route: string, session: string) {
|
||||
const response = await fetch(`${webRoot}api/exp_discord/${route}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${session}` },
|
||||
});
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(json.error ?? `Bad server response: ${response.status}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Take the outcome of the Discord redirect out of the url, so a reload does not reuse it. */
|
||||
function readRedirect() {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
const session = params.get("exp_discord_session");
|
||||
const error = params.get("exp_discord_error");
|
||||
if (session || error) {
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
if (session) {
|
||||
sessionStorage.setItem(sessionKey, session);
|
||||
}
|
||||
return { session: session ?? sessionStorage.getItem(sessionKey), error };
|
||||
}
|
||||
|
||||
export default function DiscordLoginForm(props: { setToken(token: string): void }) {
|
||||
const [redirect] = useState(readRedirect);
|
||||
const [session, setSession] = useState<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 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) {
|
||||
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) {
|
||||
return <>
|
||||
{errorAlert}
|
||||
<Paragraph>Log in with your Discord account. Your Factorio account needs to be linked to it first, which you can do after logging in.</Paragraph>
|
||||
<Button type="primary" href={`${webRoot}api/exp_discord/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>.</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>}
|
||||
|
||||
<Button type="link" onClick={() => forget(null)}>Use a different Discord account</Button>
|
||||
</>;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Alert, Button, Descriptions, Popconfirm } from "antd";
|
||||
|
||||
import {
|
||||
ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount,
|
||||
} from "@clusterio/web_ui";
|
||||
|
||||
import { DiscordLinkGetRequest, DiscordLinkStartRequest, DiscordUnlinkRequest } from "../../messages.js";
|
||||
|
||||
declare const webRoot: string;
|
||||
|
||||
type Link = { id: string, name: string } | null;
|
||||
|
||||
/** An error from linking on Discord, taken out of the url so a reload does not show it again. */
|
||||
function readError() {
|
||||
const error = new URLSearchParams(window.location.hash.slice(1)).get("exp_discord_error");
|
||||
if (error) {
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** The Discord account a user is linked to, shown on their user page. */
|
||||
export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPage">) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const own = account.name.toLowerCase() === props.user.name.toLowerCase();
|
||||
const canGet = own || account.hasPermission("exp_discord.link.get");
|
||||
const canDelete = own || account.hasPermission("exp_discord.link.delete");
|
||||
const [link, setLink] = useState<Link | undefined>(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 <>
|
||||
<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>
|
||||
</>;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
import { WebPluginContext } from "@clusterio/web_ui";
|
||||
|
||||
import DiscordLoginForm from "./components/DiscordLoginForm";
|
||||
import UserDiscordLink from "./components/UserDiscordLink";
|
||||
|
||||
export default function registerPlugin(context: WebPluginContext) {
|
||||
context.control.hooks.loginForms.attach("exp_discord", () => [
|
||||
{
|
||||
name: "exp_discord",
|
||||
title: "Discord",
|
||||
Component: DiscordLoginForm,
|
||||
},
|
||||
]);
|
||||
|
||||
context.control.hooks.extensionComponents.attach("exp_discord", () => ({
|
||||
UserViewPage: UserDiscordLink,
|
||||
}));
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "exp_discord",
|
||||
library: { type: "window", name: "plugin_exp_discord" },
|
||||
exposes: {
|
||||
"./": "./index.ts",
|
||||
"./package.json": "./package.json",
|
||||
"./web": "./web/index.tsx",
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
"react-router": { import: false },
|
||||
"react-router-dom": { import: false },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in new issue
Block a user