mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge pull request #915 from Cooldude2606/feature/player-auth-display-version
Add version and connect button to player auth
This commit is contained in:
5 files changed
+286
-66
No files matched your search
@@ -3,11 +3,12 @@ import express, { type Request, type Response } from "express";
|
||||
import util from "util";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
const { basicType } = lib;
|
||||
|
||||
import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages";
|
||||
import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages";
|
||||
|
||||
|
||||
async function generateCode(length: number): Promise<string> {
|
||||
@@ -46,12 +47,33 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}, 60e3).unref();
|
||||
|
||||
this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
|
||||
let servers: string[] = [];
|
||||
for (let instance of this.controller.instances.values()) {
|
||||
if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) {
|
||||
servers.push(instance.config.get("factorio.settings")["name"] as string || "unnamed server");
|
||||
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
|
||||
|
||||
for (const instance of this.controller.instances.values()) {
|
||||
const pluginLoaded = instance.config.get("player_auth.load_plugin");
|
||||
const assignedHost = instance.config.get("instance.assigned_host");
|
||||
if (instance.status !== "running" || !pluginLoaded || assignedHost === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const host = this.controller.hosts.get(assignedHost);
|
||||
if (!host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address = instance.gamePort !== undefined
|
||||
? `${host.publicAddress}:${instance.gamePort}`
|
||||
: host.publicAddress;
|
||||
|
||||
const settings = instance.config.get("factorio.settings");
|
||||
const includeAddress = this.controller.config.get("player_auth.show_connect_address");
|
||||
servers.push({
|
||||
name: settings["name"] as string || "unnamed server",
|
||||
factorioVersion: instance.factorioVersion,
|
||||
address: (includeAddress && host.publicAddress !== "") ? address : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
res.send(servers);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"player_auth.code_length": number;
|
||||
"player_auth.code_timeout": number;
|
||||
"player_auth.show_connect_address": boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +32,12 @@ export const plugin: lib.PluginDeclaration = {
|
||||
type: "number",
|
||||
initialValue: 120,
|
||||
},
|
||||
"player_auth.show_connect_address": {
|
||||
title: "Show Connect Address",
|
||||
description: "Show server connect addresses to unauthenticated users.",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
},
|
||||
|
||||
features: [
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
export class PlayerAuthServer {
|
||||
declare ["constructor"]: typeof PlayerAuthServer;
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
address: Type.Optional(Type.String()),
|
||||
factorioVersion: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public address?: string,
|
||||
public factorioVersion?: string,
|
||||
) {}
|
||||
|
||||
toJSON(): Static<typeof PlayerAuthServer.jsonSchema> {
|
||||
const json = { name: this.name } as Static<typeof PlayerAuthServer.jsonSchema>;
|
||||
if (this.address !== undefined) {
|
||||
json.address = this.address;
|
||||
}
|
||||
if (this.factorioVersion !== undefined) {
|
||||
json.factorioVersion = this.factorioVersion;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof PlayerAuthServer.jsonSchema>) {
|
||||
return new this(json.name, json.address, json.factorioVersion);
|
||||
}
|
||||
}
|
||||
|
||||
class FetchPlayerCodeResponse {
|
||||
constructor(
|
||||
public playerCode: string,
|
||||
|
||||
@@ -7,9 +7,11 @@ const mock = require("../../../test/mock");
|
||||
const controller = require("../dist/node/controller");
|
||||
const instance = require("../dist/node/instance");
|
||||
const info = require("../dist/node/index").plugin;
|
||||
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../dist/node/messages");
|
||||
const { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } = require("../dist/node/messages");
|
||||
const { testRoundTripJsonSerialisable, testMatrix } = require("../../../test/common");
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
|
||||
function postJSON(url, body) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
@@ -19,6 +21,44 @@ function postJSON(url, body) {
|
||||
}
|
||||
|
||||
describe("player_auth", function() {
|
||||
describe("messages.js", function() {
|
||||
describe("PlayerAuthServer", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(PlayerAuthServer, testMatrix(
|
||||
["server1", "server2"], // name
|
||||
[undefined, "127.0.0.1", "10.0.0.1:1234"], // address
|
||||
[undefined, "1.1.0", "2.0.0"], // version
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FetchPlayerCodeResponse", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(FetchPlayerCodeRequest.Response, testMatrix(
|
||||
["abc123", "player-code"], // playerCode
|
||||
["http://localhost", "https://example.com"], // controllerUrl
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FetchPlayerCodeRequest", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(FetchPlayerCodeRequest, testMatrix(
|
||||
["player1", "player2"], // player
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetVerifyCodeRequest", function() {
|
||||
it("should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(SetVerifyCodeRequest, testMatrix(
|
||||
["player1", "player2"], // player
|
||||
["code123", "verify456"], // verifyCode
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller.js", function() {
|
||||
describe("generateCode()", function() {
|
||||
it("should generate a code of the given length", async function() {
|
||||
@@ -60,33 +100,52 @@ describe("player_auth", function() {
|
||||
|
||||
describe("/api/player_auth/servers", function() {
|
||||
it("should return a list of running servers with player_auth loaded", async function() {
|
||||
function addInstance(id, status, load, name) {
|
||||
function addInstance(id, status, load, name, version, gamePort, host=1) {
|
||||
controllerPlugin.controller.instances.records.set(id, {
|
||||
config: {
|
||||
get(field) {
|
||||
if (field === "player_auth.load_plugin") {
|
||||
return load;
|
||||
} else if (field === "factorio.settings") {
|
||||
}
|
||||
if (field === "factorio.settings") {
|
||||
return { name };
|
||||
} else if (field === "instance.id") {
|
||||
}
|
||||
if (field === "instance.id") {
|
||||
return id;
|
||||
}
|
||||
if (field === "instance.assigned_host") {
|
||||
return host;
|
||||
}
|
||||
throw new Error(`field ${field} not implemented`);
|
||||
},
|
||||
},
|
||||
status,
|
||||
gamePort,
|
||||
factorioVersion: version,
|
||||
});
|
||||
}
|
||||
addInstance(1, "running", true, "running loaded");
|
||||
addInstance(2, "stopped", true, "stopped loaded");
|
||||
addInstance(3, "running", false, "running unloaded");
|
||||
addInstance(4, "stopped", false, "stopped unloaded");
|
||||
addInstance(5, "running", true, undefined);
|
||||
|
||||
addInstance(1, "running", true, "running loaded", "1.1.1", 34197);
|
||||
addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197);
|
||||
addInstance(3, "running", false, "running unloaded", "1.1.3", 34197);
|
||||
addInstance(4, "stopped", false, "stopped unloaded", "1.1.4", 34197);
|
||||
addInstance(5, "running", true, undefined, "1.1.5", 34197);
|
||||
addInstance(6, "running", true, "no port", "1.1.6", undefined);
|
||||
addInstance(7, "running", true, "no host", "1.1.7", 34197, null);
|
||||
addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2);
|
||||
addInstance(9, "running", true, "no version", undefined, 34197);
|
||||
|
||||
controllerPlugin.controller.hosts.set(1, {
|
||||
publicAddress: "127.0.0.1",
|
||||
});
|
||||
|
||||
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
|
||||
assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]);
|
||||
for (let id of [1, 2, 3, 4, 5]) {
|
||||
controllerPlugin.controller.instances.records.delete(id);
|
||||
}
|
||||
assert.deepEqual(await result.json(), [
|
||||
{ name: "running loaded", address: "127.0.0.1:34197", factorioVersion: "1.1.1" },
|
||||
{ name: "unnamed server", address: "127.0.0.1:34197", factorioVersion: "1.1.5" },
|
||||
{ name: "no port", address: "127.0.0.1", factorioVersion: "1.1.6" },
|
||||
{ name: "no version", address: "127.0.0.1:34197" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui";
|
||||
import { Button, Form, Input, Spin, Typography } from "antd";
|
||||
import { Alert, Button, Form, Input, Modal, Space, Spin, Typography } from "antd";
|
||||
|
||||
import { PlayerAuthServer } from "../messages";
|
||||
import "./style.css";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
@@ -12,60 +13,95 @@ interface LoginFormProps {
|
||||
}
|
||||
|
||||
function LoginForm(props: LoginFormProps) {
|
||||
let [servers, setServers] = useState<string[] | null>(null);
|
||||
const [servers, setServers] = useState<PlayerAuthServer[] | null>(null);
|
||||
let [playerCode, setPlayerCode] = useState<string | null>(null);
|
||||
let [playerCodeError, setPlayerCodeError] = useState<string | null>(null);
|
||||
let [verifyCode, setVerifyCode] = useState<string | undefined>();
|
||||
let [verifyToken, setVerifyToken] = useState<string | null>(null);
|
||||
const [connectServer, setConnectServer] = useState<{ name: string, address: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/player_auth/servers`);
|
||||
if (response.ok) {
|
||||
setServers(await response.json());
|
||||
} else {
|
||||
setServers([]);
|
||||
let cancelled = false;
|
||||
async function loadServers() {
|
||||
try {
|
||||
const response = await fetch(`${webRoot}api/player_auth/servers`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bad server response: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!cancelled) {
|
||||
setServers(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled && err instanceof Error) {
|
||||
setServers([]);
|
||||
notifyErrorHandler("Error fetching servers")(err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
loadServers();
|
||||
const interval = setInterval(loadServers, 15 * 1000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyToken) {
|
||||
let checkLoop = setInterval(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/player_auth/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
verify_token: verifyToken,
|
||||
verify_code: verifyCode,
|
||||
player_code: playerCode,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bad server response: ${response.status}`);
|
||||
}
|
||||
let json = await response.json();
|
||||
if (json.error) {
|
||||
// While there are other possibilites the most likely
|
||||
// cause of an error response here is that the code expired.
|
||||
setPlayerCodeError("Code expired");
|
||||
setVerifyToken(null);
|
||||
clearInterval(checkLoop);
|
||||
return;
|
||||
}
|
||||
if (json.verified) {
|
||||
props.setToken(json.token);
|
||||
}
|
||||
})().catch(err => {
|
||||
clearInterval(checkLoop);
|
||||
notifyErrorHandler("Error verifying code")(err);
|
||||
});
|
||||
}, 2000);
|
||||
return () => { clearInterval(checkLoop); };
|
||||
if (!verifyToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
let cancelled = false;
|
||||
async function verify() {
|
||||
try {
|
||||
const response = await fetch(`${webRoot}api/player_auth/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
verify_token: verifyToken,
|
||||
verify_code: verifyCode,
|
||||
player_code: playerCode,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bad server response: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
setPlayerCodeError("Code expired");
|
||||
setVerifyToken(null);
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.verified) {
|
||||
props.setToken(json.token);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled && err instanceof Error) {
|
||||
clearInterval(interval);
|
||||
notifyErrorHandler("Error verifying code")(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verify();
|
||||
const interval = setInterval(verify, 2 * 1000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [verifyToken]);
|
||||
|
||||
if (!servers) {
|
||||
@@ -74,12 +110,47 @@ function LoginForm(props: LoginFormProps) {
|
||||
|
||||
if (servers.length === 0) {
|
||||
return <Paragraph>
|
||||
There are no servers in the cluster currently running that can complete
|
||||
the login via Factorio. Please try again later.
|
||||
There are no running servers available for login via Factorio at the moment.
|
||||
Please try again later.
|
||||
</Paragraph>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Modal
|
||||
open={connectServer !== null}
|
||||
title={`Connect to ${connectServer?.name ?? ""}`}
|
||||
okText="Connect"
|
||||
cancelText="Back"
|
||||
onCancel={() => setConnectServer(null)}
|
||||
onOk={() => {
|
||||
if (!connectServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `steam://run/427520//--mp-connect=${connectServer.address}`;
|
||||
setConnectServer(null);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Paragraph>
|
||||
This button launches Factorio through Steam. It only works if
|
||||
you own and have installed the Steam version of Factorio, and
|
||||
Factorio is not already running.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
If you are using the standalone version, or Factorio is already
|
||||
running, use the following address with <b>Multiplayer → Connect to address</b>:
|
||||
</Paragraph>
|
||||
|
||||
<Typography.Paragraph
|
||||
copyable={{ text: connectServer?.address ?? "" }}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{connectServer?.address}
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Paragraph>Login using your Factorio account is a 3 step process:</Paragraph>
|
||||
<style>
|
||||
</style>
|
||||
@@ -89,9 +160,39 @@ function LoginForm(props: LoginFormProps) {
|
||||
Start Factorio, join one of the following multiplayer servers
|
||||
and type <Text keyboard>/web-login</Text> into the chat:
|
||||
</Paragraph>
|
||||
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16 }}>
|
||||
<ul>
|
||||
{servers.map(text => <li key={text}>{text}</li>)}
|
||||
<div style={{ maxHeight: 160, overflowY: "auto", paddingLeft: 16, padding: "8px 0" }}>
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
{servers.map(server => (
|
||||
<li key={server.name} style={{ marginBottom: 6 }}>
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}>
|
||||
<span>
|
||||
{server.name}
|
||||
{server.factorioVersion && (
|
||||
<Text type="secondary"> ({server.factorioVersion})</Text>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{server.address && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setConnectServer({
|
||||
name: server.name,
|
||||
address: server.address!,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
Reference in new issue
Block a user