diff --git a/frontend/src/components/App.svelte b/frontend/src/components/App.svelte
index 34c8bec..905bf96 100644
--- a/frontend/src/components/App.svelte
+++ b/frontend/src/components/App.svelte
@@ -21,7 +21,7 @@
hash = hash.substring(1, hash.length);
if (hash !== "") {
- routeInfo = hash.split("||");
+ routeInfo = hash.split(",,");
}
function doChatLogin() {
@@ -185,6 +185,26 @@
}
}
+ async function fuzzyConfirmHandoff({ detail: yn }) {
+ if (yn === "Yes") {
+ if (!apiClient.token) {
+ view = { type: "MESSAGE_DISPLAY", header: "Authorization failed", content: "You need to be logged in to give applications account access." };
+ return;
+ }
+ apiClient.postRequest("/tokens/handoff/fulfill", {
+ handoffToken: view.handoffToken
+ }).then(() => {
+ view = { type: "MESSAGE_DISPLAY", header: "Application authorized", content: "You can now close this tab." };
+ }).catch(() => {
+ view = { type: "MESSAGE_DISPLAY", header: "Authorization failed", content: "We couldn't authorize this application." };
+ });
+ } else if (yn === "No") {
+ view = { type: "MESSAGE_DISPLAY", header: "Got it!", content: "We won't give this application account access right now." };
+ } else {
+ view = { type: "MESSAGE_DISPLAY", header: "Authorization failed", content: "We can't give this application account access right now." };
+ }
+ }
+
if (routeInfo.length >= 2) {
switch (routeInfo[0]) {
@@ -192,6 +212,10 @@
view = { type: "REDEEM_TOKEN_CONFIRM_PROMPT", token: routeInfo[1] };
break;
}
+ case "token_handoff": {
+ view = { type: "HANDOFF_TOKEN_CONFIRM_PROMPT", handoffToken: routeInfo[1] };
+ break;
+ }
}
} else {
// no special route, continue normal execution
@@ -230,5 +254,7 @@
{:else if view.type === "REDEEM_TOKEN_CONFIRM_PROMPT"}
+ {:else if view.type === "HANDOFF_TOKEN_CONFIRM_PROMPT"}
+
{/if}
diff --git a/package.json b/package.json
index e4e72a0..5b63e95 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,14 @@
{
"name": "discordbridge",
"version": "1.0.0",
- "main": "index.js",
+ "main": "src/index.js",
"license": "MIT",
"type": "module",
"dependencies": {
"express": "^4.17.2",
"jsonwebtoken": "^8.5.1",
"node-fetch": "^3.2.0",
+ "uuid": "^8.3.2",
"ws": "^8.4.2"
},
"optionalDependencies": {
diff --git a/scripts/minecraft.js b/scripts/minecraft.js
index 69b5119..ab769fa 100644
--- a/scripts/minecraft.js
+++ b/scripts/minecraft.js
@@ -14,6 +14,7 @@ const TARGET_GUILD_ID = "_";
const TARGET_CHANNEL_ID = "_";
const ORIGIN = "http://localhost:4050";
const GATEWAY_ORIGIN = "ws://localhost:4050/gateway";
+const FRONTEND_ORIGIN = "http://localhost:4050/";
const messageSchema = { t: "number", d: "object" };
const messageTypes = {
HELLO: 0,
@@ -27,6 +28,9 @@ const joinNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) joined th
const leaveNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) left the game/;
const rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD);
+const pendingHandoffs = new Map();
+const usernameToToken = new Map();
+
export default class GatewayClient {
constructor(gatewayPath) {
this.gatewayPath = gatewayPath;
@@ -148,7 +152,11 @@ async function sendBridgeMessageAs(guildId, channelId, content, username=undefin
});
}
-async function sendMinecraftMessageAs(rcon, username, content, attachments=[], referencedMessage=null) {
+async function sendMinecraftMessageAs(rcon, username, content, attachments, referencedMessage=null) {
+ if (!attachments) {
+ attachments = [];
+ }
+
const tellrawPayload = [
{ text: "[" },
{ text: `${username}`, color: "gray" },
@@ -188,6 +196,26 @@ async function sendMinecraftMessageAs(rcon, username, content, attachments=[], r
rcon.send(`tellraw @a ${JSON.stringify(tellrawPayload)}`);
}
+async function startHandoffForUser(username) {
+ const handoffRes = await fetch(`${ORIGIN}/api/v1/tokens/handoff/create`, {
+ method: "POST",
+ body: JSON.stringify({}),
+ headers: {
+ "content-type": "application/json",
+ "authorization": TOKEN
+ }
+ });
+ const { token: handoffToken, dispatchUUID } = (await handoffRes.json());
+
+ pendingHandoffs.set(dispatchUUID, username);
+ // TODO: probably not a good idea
+ setTimeout(() => {
+ pendingHandoffs.delete(dispatchUUID);
+ }, 40000);
+
+ return handoffToken;
+}
+
async function main() {
rconConnection.on("error", (e) => {
console.error("rcon: got error", e);
@@ -198,9 +226,18 @@ async function main() {
}, 5000);
}
});
+
const gateway = new GatewayClient(GATEWAY_ORIGIN);
gateway.onEvent = (e) => {
- if (e.eventType === "MESSAGE_CREATE" && e.message.channel_id === TARGET_CHANNEL_ID && e.message.guild_id === TARGET_GUILD_ID && !e.message.webhook_id) {
+ if (e.eventType === "$BRIDGE_HANDOFF_FULFILLMENT") {
+ const username = pendingHandoffs.get(e.dispatchUUID);
+ if (!username)
+ return;
+
+ usernameToToken.set(username, e.token);
+
+ sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `${username} has been successfully linked to a bridge token.`, null, null);
+ } else if (e.eventType === "MESSAGE_CREATE" && e.message.channel_id === TARGET_CHANNEL_ID && e.message.guild_id === TARGET_GUILD_ID && !e.message.webhook_id) {
sendMinecraftMessageAs(rconConnection, e.message.author.username, e.message.content, e.message.attachments, e.message.referenced_message);
}
};
@@ -226,8 +263,27 @@ async function main() {
const messageResult = chatMessageRegex.exec(stringData);
if (messageResult) {
- await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null);
- return;
+ if (messageResult.groups.message === "!:minecraft-link") {
+ try {
+ const handoffToken = await startHandoffForUser(messageResult.groups.username);
+ const handoffLink = `${FRONTEND_ORIGIN}#token_handoff,,${handoffToken}`;
+ sendMinecraftMessageAs(
+ rconConnection,
+ "Minecraft Bridge System",
+ `${messageResult.groups.username}: Click on the attached link to finish linking your account. You have 40 seconds.`,
+ [
+ {
+ filename: `link account to ${messageResult.groups.username}`,
+ proxy_url: handoffLink
+ }
+ ]
+ );
+ } catch(e) {
+ sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `Failed to create link for ${messageResult.groups.username}.`, null, null);
+ }
+ } else {
+ await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null);
+ }
}
});
}
diff --git a/DiscordClient.js b/src/DiscordClient.js
similarity index 100%
rename from DiscordClient.js
rename to src/DiscordClient.js
diff --git a/GatewayServer.js b/src/GatewayServer.js
similarity index 87%
rename from GatewayServer.js
rename to src/GatewayServer.js
index 8c0458b..dd31ead 100644
--- a/GatewayServer.js
+++ b/src/GatewayServer.js
@@ -1,4 +1,4 @@
-import { WebSocketServer } from "ws";
+import { WebSocket, WebSocketServer } from "ws";
import { guildMap } from "./common.js";
import { decodeToken } from "./tokens.js";
@@ -12,7 +12,9 @@ const messageTypes = {
};
class GatewayServer {
- constructor(server, extraWebsocketServerConfig={}) {
+ constructor() {}
+
+ start(server, extraWebsocketServerConfig={}) {
this.wss = new WebSocketServer({
server,
...extraWebsocketServerConfig
@@ -99,7 +101,7 @@ class GatewayServer {
try {
message = JSON.parse(message.toString());
} catch (e) {
- return ws.close(4000, "Payload error.");
+ return ws.close(4000, "Payload JSON parse error.");
}
if (!this._checkMessageSchema(message))
@@ -123,7 +125,7 @@ class GatewayServer {
this._clientDispatch(ws, {
type: "SET_TOKEN",
- token: message.token
+ token: message.d.token
});
this._clientDispatch(ws, {
type: "AUTHENTICATE_AS",
@@ -205,6 +207,30 @@ class GatewayServer {
ws.ping();
});
}
+
+ findClientByToken(token) {
+ for (const [client, _] of this.wss.clients.entries()) {
+ if (client.readyState === WebSocket.OPEN && client.state && client.state.token === token) {
+ return client;
+ }
+ }
+ }
+
+ dispatchHandoffFulfillment(targetToken, dispatchUUID, token) {
+ const targetClient = this.findClientByToken(targetToken);
+ if (!targetClient)
+ return false;
+
+ targetClient.send(JSON.stringify({
+ t: messageTypes.EVENT,
+ d: {
+ eventType: "$BRIDGE_HANDOFF_FULFILLMENT",
+ dispatchUUID,
+ token
+ }
+ }));
+ return true;
+ }
}
export default GatewayServer;
diff --git a/WatchedGuild.js b/src/WatchedGuild.js
similarity index 100%
rename from WatchedGuild.js
rename to src/WatchedGuild.js
diff --git a/common.js b/src/common.js
similarity index 100%
rename from common.js
rename to src/common.js
diff --git a/src/commonservers.js b/src/commonservers.js
new file mode 100644
index 0000000..2b97e3a
--- /dev/null
+++ b/src/commonservers.js
@@ -0,0 +1,3 @@
+import GatewayServer from "./GatewayServer.js";
+
+export const gatewayServer = new GatewayServer();
diff --git a/config.js b/src/config.js
similarity index 52%
rename from config.js
rename to src/config.js
index 52ec2e2..e3e95fb 100644
--- a/config.js
+++ b/src/config.js
@@ -1,6 +1,7 @@
export const mainHttpListenPort = 4050;
export const watchedGuildIds = ["822089558886842418", "736292509134749807"];
export const jwtSecret = process.env.JWT_SECRET;
+export const jwtHandoffSecret = process.env.JWT_HANDOFF_SECRET;
export const discordToken = process.env.DISCORD_TOKEN;
export const dangerousAdminMode = true;
export const logContextMap = {
@@ -20,3 +21,15 @@ export const logContextMap = {
error: true,
}
};
+
+if (!jwtSecret) {
+ console.error("Missing jwtSecret. Please make sure it's set through the environment variable JWT_SECRET.");
+}
+
+if (!jwtHandoffSecret) {
+ console.error("Missing jwtHandoffSecret. Please make sure it's set through the environment variable JWT_SECRET.");
+}
+
+if (jwtSecret === jwtHandoffSecret) {
+ console.error("jwtHandoffSecret and jwtSecret are both equal. This is a stability and security risk.");
+}
diff --git a/index.js b/src/index.js
similarity index 59%
rename from index.js
rename to src/index.js
index cdfc8d4..bd5d90b 100644
--- a/index.js
+++ b/src/index.js
@@ -1,22 +1,21 @@
-import http from "node:http";
+import { createServer } from "node:http";
+import { bot, logger } from "./common.js";
+import { mainHttpListenPort } from "./config.js";
import express from "express";
import apiRoute from "./routes/api.js";
-import { mainHttpListenPort } from "./config.js";
-import { bot, logger } from "./common.js";
-import GatewayServer from "./GatewayServer.js";
+import { gatewayServer } from "./commonservers.js";
-const log = logger("log", "ServerMain");
-
-// might introduce bugs and probably a bad idea
+// (probably) a bad idea
Object.freeze(Object.prototype);
Object.freeze(Object);
-const app = express();
-const httpServer = http.createServer(app);
-const gatewayServer = new GatewayServer(httpServer, {
- path: "/gateway"
-});
+const log = logger("log", "main");
+
+export const app = express();
+export const httpServer = createServer(app);
+
+gatewayServer.start(httpServer);
app.use(express.json());
app.use("/", express.static("frontend/public/"));
@@ -25,4 +24,4 @@ app.use("/api/v1", apiRoute);
httpServer.listen(mainHttpListenPort, () => {
log(`http listen on ${mainHttpListenPort}`);
bot.connect();
-});
\ No newline at end of file
+});
diff --git a/routes/api.js b/src/routes/api.js
similarity index 77%
rename from routes/api.js
rename to src/routes/api.js
index 3bc5003..05bc2d8 100644
--- a/routes/api.js
+++ b/src/routes/api.js
@@ -1,7 +1,9 @@
import express from "express";
import { guildMap, logger } from "../common.js";
import { dangerousAdminMode } from "../config.js";
-import { checkAuth, createToken } from "../tokens.js";
+import { checkAuth, createHandoffToken, createToken, decodeHandoffToken } from "../tokens.js";
+import { v4 } from "uuid";
+import { gatewayServer } from "../commonservers.js";
const error = logger("error", "API");
@@ -164,4 +166,41 @@ router.get("/events/poll", checkAuth(async (req, res) => {
}
}));
+router.post("/tokens/handoff/create", checkAuth(async (req, res) => {
+ const { username, isSuperToken=false } = req.user;
+ if (!isSuperToken)
+ return res.status(403).send({ error: true, message: "ERROR_NO_SUPERTOKEN" });
+
+ try {
+ const dispatchUUID = v4();
+ const token = await createHandoffToken({ displayName: username, dispatchUUID, consumerToken: req.token });
+ res.status(200).send({ error: false, message: "SUCCESS_HANDOFF_CREATED", token, dispatchUUID });
+ } catch(e) {
+ res.status(500).send({ error: true, message: "ERROR_TOKEN_CREATE_FAILURE" });
+ }
+}));
+
+router.post("/tokens/handoff/fulfill", checkAuth(async (req, res) => {
+ const { isSuperToken=false } = req.user;
+ if (isSuperToken)
+ return res.status(403).send({ error: true, message: "ERROR_SUPERTOKEN_SECURITY" });
+
+ const handoffToken = req.body.handoffToken;
+ if (!handoffToken) {
+ return res.status(400).send({ error: true, message: "ERROR_BAD_HANDOFF_TOKEN" });
+ }
+
+ try {
+ const { consumerToken, dispatchUUID } = await decodeHandoffToken(handoffToken);
+ if (gatewayServer.dispatchHandoffFulfillment(consumerToken, dispatchUUID, req.token)) {
+ res.status(200).send({ error: false, message: "SUCCESS_HANDOFF_FULFILLED" });
+ } else {
+ res.status(400).send({ error: true, message: "ERROR_NO_HANDOFF_CONSUMERS" });
+ }
+ } catch(e) {
+ console.error(e);
+ res.status(400).send({ error: true, message: "ERROR_BAD_HANDOFF_TOKEN" });
+ }
+}));
+
export default router;
diff --git a/tokens.js b/src/tokens.js
similarity index 67%
rename from tokens.js
rename to src/tokens.js
index 7cf0705..9192ded 100644
--- a/tokens.js
+++ b/src/tokens.js
@@ -1,5 +1,5 @@
import jsonwebtoken from "jsonwebtoken";
-import { jwtSecret } from "./config.js";
+import { jwtHandoffSecret, jwtSecret } from "./config.js";
export function createToken({ username, avatarURL, discordID, guildAccess, isSuperToken=false }) {
return new Promise((resolve, reject) => {
@@ -12,6 +12,17 @@ export function createToken({ username, avatarURL, discordID, guildAccess, isSup
});
}
+export function createHandoffToken({ dispatchUUID, displayName, consumerToken }) {
+ return new Promise((resolve, reject) => {
+ jsonwebtoken.sign({ dispatchUUID, displayName, consumerToken }, jwtHandoffSecret, (err, token) => {
+ if (err)
+ return reject(err);
+
+ resolve(token);
+ });
+ });
+}
+
export function decodeToken(token) {
return new Promise((resolve, reject) => {
jsonwebtoken.verify(token, jwtSecret, (err, token) => {
@@ -23,6 +34,17 @@ export function decodeToken(token) {
});
}
+export function decodeHandoffToken(token) {
+ return new Promise((resolve, reject) => {
+ jsonwebtoken.verify(token, jwtHandoffSecret, (err, token) => {
+ if (err)
+ return reject(err);
+
+ resolve(token);
+ });
+ });
+}
+
export function checkAuth(callback) {
return async (req, res) => {
const token = req.get("authorization");
@@ -37,6 +59,7 @@ export function checkAuth(callback) {
if (user) {
req.user = user;
req.authenticated = true;
+ req.token = token;
return await callback(req, res);
} else {
res.status(401).send({ error: true, message: "ERROR_UNAUTHORIZED" });
diff --git a/yarn.lock b/yarn.lock
index 91276e2..794ca88 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -470,6 +470,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+uuid@^8.3.2:
+ version "8.3.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
+ integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
+
vary@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"