Files
factorio-expcluster/exp_roles/instance.ts
T
BastiaanandClaude Opus 5.5 8e7ac3c872 Pick a long bracket the json does not close in rcon commands (#506)
exp_reports, exp_roles and exp_groups wrapped the json they send to Lua in
a fixed [=[ ]=] long bracket. A report reason, role name or group name
containing ]=] closed it early and the rest ran as Lua through /sc. Any
player can make a report, so any player could run code on the server.

Use the next level the json does not contain, like the other plugins do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:07:47 +01:00

97 lines
3.2 KiB
TypeScript

import type { InstancePluginContext } from "@clusterio/host";
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
/** Sent by the lua side when roles are changed in game. */
export type IpcAssignmentUpdate = {
name: string,
assign: number[] | undefined,
unassign: number[] | undefined,
};
export default async function (context: InstancePluginContext) {
const { instance, logger, plugin } = context;
const syncMode = () => instance.config.get("exp_roles.sync_mode");
async function luaSend(receiver: string, json: any) {
const text = JSON.stringify(json);
// Role names and tags can contain anything, so pick a long bracket level the json does not close
let level = "=";
while (text.includes(`]${level}]`)) {
level += "=";
}
await instance.sendRcon(
`/sc exp_roles.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true
);
}
instance.handle(messages.RoleUpdatedEvent, async event => {
if (syncMode() !== "disabled") {
await luaSend("receive_role_updates", event.updates.map(role => role.toJSON()));
}
});
instance.handle(messages.AssignmentUpdatedEvent, async event => {
if (syncMode() !== "disabled") {
await luaSend("receive_assignment_updates", event.updates.map(a => a.toJSON()));
}
});
instance.server.handle("exp_roles:assignment_update", async (event: IpcAssignmentUpdate) => {
if (syncMode() !== "bidirectional") {
return;
}
const assign = event.assign ?? [];
try {
await instance.sendTo("controller", new messages.AssignmentUpdateRequest(
event.name, assign, event.unassign ?? [],
));
} catch (err: any) {
// The roles were already applied in game, so they have to be taken
// back off again now that the controller has refused them
logger.warn(`Role change for ${event.name} was rejected: ${err.message}`);
if (assign.length) {
await luaSend("reject_assignment", { name: event.name, role_ids: assign });
}
}
});
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr) => {
if (field === "exp_roles.sync_mode") {
await luaSend("set_emit_events", curr === "bidirectional");
}
});
instance.hooks.start.attach(plugin.name, async () => {
if (syncMode() === "disabled") {
return;
}
// Date.now() is used because the lua state is initialised from the full
// list below, so only updates made after this point are of interest
const subscribedAtMs = Date.now();
await instance.sendTo("controller", new lib.SubscriptionRequest(
`exp_roles:${messages.RoleUpdatedEvent.name}`, true, subscribedAtMs
));
await instance.sendTo("controller", new lib.SubscriptionRequest(
`exp_roles:${messages.AssignmentUpdatedEvent.name}`, true, subscribedAtMs
));
const [roles, assignments] = await Promise.all([
instance.sendTo("controller", new messages.RoleListRequest()),
instance.sendTo("controller", new messages.AssignmentListRequest()),
]);
if (!roles.some(role => role.isDefault)) {
logger.warn("No default role is set on the controller, players will hold no roles in game");
}
await luaSend("initialise", {
...messages.encodeRolesForLua(roles),
assignments: assignments.map(assignment => assignment.toJSON()),
});
await luaSend("set_emit_events", syncMode() === "bidirectional");
});
}