This commit is contained in:
2025-03-06 23:29:40 +09:00
parent 5939ff8e59
commit 4fa34c31d1
14 changed files with 0 additions and 1478 deletions

View File

@@ -1,282 +0,0 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
import {
PermissionStrings, PermissionStringsUpdate,
PermissionGroup, PermissionGroupUpdate,
InstancePermissionGroups,
PermissionInstanceId,
PermissionGroupEditEvent,
} from "./messages";
import path from "path";
import fs from "fs-extra";
export class ControllerPlugin extends BaseControllerPlugin {
static permissionGroupsPath = "exp_groups.json";
static userGroupsPath = "exp_user_groups.json";
userToGroup: Map<lib.User["id"], PermissionGroup> = new Map(); // TODO this needs to be per instance
permissionStrings!: Map<PermissionStrings["id"], PermissionStrings>;
permissionGroups!: Map<InstancePermissionGroups["id"], InstancePermissionGroups>;
async init() {
this.controller.handle(PermissionStringsUpdate, this.handlePermissionStringsUpdate.bind(this));
this.controller.handle(PermissionGroupUpdate, this.handlePermissionGroupUpdate.bind(this));
this.controller.handle(PermissionGroupEditEvent, this.handlePermissionGroupEditEvent.bind(this));
this.controller.subscriptions.handle(PermissionStringsUpdate, this.handlePermissionStringsSubscription.bind(this));
this.controller.subscriptions.handle(PermissionGroupUpdate, this.handlePermissionGroupSubscription.bind(this));
this.controller.subscriptions.handle(PermissionGroupEditEvent);
this.permissionStrings = new Map([["Global", new PermissionStrings("Global", new Set())]]);
this.permissionGroups = new Map([["Global", new InstancePermissionGroups("Global")]]);
await this.loadData();
// Add the default group if missing and add any missing cluster roles
const clusterRoles = [...this.controller.userManager.roles.values()]
for (const instanceGroups of this.permissionGroups.values()) {
const groups = instanceGroups.groups;
const instanceRoles = [...groups.values()].flatMap(group => [...group.roleIds.values()]);
const missingRoles = clusterRoles.filter(role => instanceRoles.includes(role.id));
const defaultGroup = groups.get("Default");
if (defaultGroup) {
for (const role of missingRoles) {
defaultGroup.roleIds.add(role.id)
}
} else {
groups.set("Default", new PermissionGroup(
instanceGroups.instanceId,
"Default",
groups.size,
new Set(missingRoles.map(role => role.id))
));
}
}
}
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
if (field === "exp_groups.allow_role_inconsistency") {
// Do something with this.userToGroup
}
}
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
if (field === "exp_groups.sync_permission_groups") {
const updates = []
const now = Date.now();
if (curr) {
// Global sync enabled, we dont need the instance config
const instanceGroups = this.permissionGroups.get(instance.id);
if (instanceGroups) {
this.permissionGroups.delete(instance.id);
for (const group of instanceGroups.groups.values()) {
group.updatedAtMs = now;
group.isDeleted = true;
updates.push(group);
}
}
} else {
// Global sync disabled, make a copy of the global config as a base
const global = this.permissionGroups.get("Global")!;
const oldInstanceGroups = this.permissionGroups.get(instance.id);
const instanceGroups = new InstancePermissionGroups(
instance.id, new Map([...global.groups.values()].map(group => [group.name, group.copy(instance.id)]))
)
this.permissionGroups.set(instance.id, instanceGroups);
for (const group of instanceGroups.groups.values()) {
group.updatedAtMs = now;
updates.push(group);
}
// If it has an old config (unexpected) then deal with it
if (oldInstanceGroups) {
for (const group of oldInstanceGroups.groups.values()) {
if (!instanceGroups.groups.has(group.name)) {
group.updatedAtMs = now;
group.isDeleted = true;
updates.push(group);
}
}
}
}
// Send the updates to all instances and controls
if (updates.length) {
this.controller.subscriptions.broadcast(new PermissionGroupUpdate(updates));
}
}
}
async loadPermissionGroups() {
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.permissionGroupsPath);
this.logger.verbose(`Loading ${file}`);
try {
const content = await fs.readFile(file, { encoding: "utf8" });
for (const groupRaw of JSON.parse(content)) {
const group = PermissionGroup.fromJSON(groupRaw);
const instanceGroups = this.permissionGroups.get(group.instanceId);
if (instanceGroups) {
instanceGroups.groups.set(group.name, group);
} else {
this.permissionGroups.set(group.instanceId,
new InstancePermissionGroups(group.instanceId, new Map([[group.name, group]]))
);
}
};
} catch (err: any) {
if (err.code === "ENOENT") {
this.logger.verbose("Creating new permission group database");
return;
}
throw err;
}
}
async savePermissionGroups() {
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.permissionGroupsPath);
this.logger.verbose(`Writing ${file}`);
await lib.safeOutputFile(file, JSON.stringify(
[...this.permissionGroups.values()].flatMap(instanceGroups => [...instanceGroups.groups.values()])
));
}
async loadUserGroups() {
if (!this.controller.config.get("exp_groups.allow_role_inconsistency")) return;
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.userGroupsPath);
this.logger.verbose(`Loading ${file}`);
try {
const content = await fs.readFile(file, { encoding: "utf8" });
this.userToGroup = new Map(JSON.parse(content));
} catch (err: any) {
if (err.code === "ENOENT") {
this.logger.verbose("Creating new user group database");
return;
}
throw err;
}
}
async saveUserGroups() {
if (!this.controller.config.get("exp_groups.allow_role_inconsistency")) return;
const file = path.resolve(this.controller.config.get("controller.database_directory"), ControllerPlugin.userGroupsPath);
this.logger.verbose(`Writing ${file}`);
await lib.safeOutputFile(file, JSON.stringify([...this.permissionGroups.entries()]));
}
async loadData() {
await Promise.all([
this.loadPermissionGroups(),
this.loadUserGroups(),
])
}
async onSaveData() {
await Promise.all([
this.savePermissionGroups(),
this.saveUserGroups(),
])
}
addPermisisonGroup(instanceId: PermissionInstanceId, name: string, permissions = new Set<string>(), silent = false) {
const instanceGroups = this.permissionGroups.get(instanceId);
if (!instanceGroups) {
throw new Error("Instance ID does not exist");
}
if (instanceGroups.groups.has(name)) {
return instanceGroups.groups.get(name)!;
}
for (const group of instanceGroups.groups.values()) {
group.order += 1;
}
const group = new PermissionGroup(instanceId, name, 0, new Set(), permissions, Date.now(), false);
instanceGroups.groups.set(group.id, group);
if (!silent) {
this.controller.subscriptions.broadcast(new PermissionGroupUpdate([group]));
}
return group;
}
removePermissionGroup(instanceId: PermissionInstanceId, name: string, silent = false) {
const instanceGroups = this.permissionGroups.get(instanceId);
if (!instanceGroups) {
throw new Error("Instance ID does not exist");
}
const group = instanceGroups.groups.get(name)
if (!group) {
return null;
}
for (const nextGroup of instanceGroups.groups.values()) {
if (nextGroup.order > group.order) {
nextGroup.order -= 1;
}
}
instanceGroups.groups.delete(group.id);
group.updatedAtMs = Date.now();
group.isDeleted = true;
if (!silent) {
this.controller.subscriptions.broadcast(new PermissionGroupUpdate([group]));
}
return group;
}
async handlePermissionGroupEditEvent(event: PermissionGroupEditEvent) {
// TODO
}
async handlePermissionStringsUpdate(event: PermissionStringsUpdate) {
for (const update of event.updates) {
const global = this.permissionStrings.get("Global")!
this.permissionStrings.set(update.instanceId as number, update)
global.updatedAtMs = Math.max(global.updatedAtMs, update.updatedAtMs)
for (const permission of update.permissions) {
global.permissions.add(permission)
}
// TODO maybe check if changes have happened rather than always pushing updates
this.controller.subscriptions.broadcast(new PermissionStringsUpdate([global, update]))
}
}
async handlePermissionGroupUpdate(event: PermissionGroupUpdate) {
const updates = [];
for (const group of event.updates) {
const groups = this.permissionGroups.get(group.instanceId);
if (!groups) continue;
const existingGroup = groups.groups.get(group.id);
let update
if (!existingGroup) {
update = this.addPermisisonGroup(group.instanceId, group.name, group.permissions, true);
} else if (group.isDeleted) {
update = this.removePermissionGroup(group.instanceId, group.name, true);
} else {
existingGroup.permissions = group.permissions;
existingGroup.updatedAtMs = Date.now();
update = existingGroup;
}
if (update) updates.push(update);
}
this.controller.subscriptions.broadcast(new PermissionGroupUpdate(updates));
}
async handlePermissionStringsSubscription(request: lib.SubscriptionRequest, src: lib.Address) {
const updates = [ ...this.permissionStrings.values() ]
.filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
)
return updates.length ? new PermissionStringsUpdate(updates) : null;
}
async handlePermissionGroupSubscription(request: lib.SubscriptionRequest, src: lib.Address) {
const updates = [ ...this.permissionGroups.values() ]
.flatMap(instanceGroups => [...instanceGroups.groups.values()])
.filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
)
if (src.type === lib.Address.instance) {
const instanceUpdates = updates.filter(group => group.instanceId === src.id || group.instanceId === "Global");
this.logger.info(JSON.stringify(updates))
this.logger.info(JSON.stringify(instanceUpdates))
return instanceUpdates.length ? new PermissionGroupUpdate(instanceUpdates) : null;
}
return updates.length ? new PermissionGroupUpdate(updates) : null;
}
}

View File

@@ -1,84 +0,0 @@
import * as lib from "@clusterio/lib";
import * as Messages from "./messages";
lib.definePermission({
name: "exp_groups.create_delete_groups",
title: "Create and delete permission groups",
description: "Create and delete permission groups.",
});
lib.definePermission({
name: "exp_groups.reorder_groups",
title: "Reorder permission groups",
description: "Reorder groups and link them to user roles.",
});
lib.definePermission({
name: "exp_groups.modify_permissions",
title: "Modify permission groups",
description: "Modify game permissions for groups.",
});
lib.definePermission({
name: "exp_groups.assign_players",
title: "Change player group",
description: "Change the permission group of a player",
});
lib.definePermission({
name: "exp_groups.list",
title: "View permission groups",
description: "View permission groups.",
});
lib.definePermission({
name: "exp_groups.list.subscribe",
title: "Subscribe to permission group updates",
description: "Subscribe to permission group updates.",
});
declare module "@clusterio/lib" {
export interface ControllerConfigFields {
"exp_groups.allow_role_inconsistency": boolean;
}
export interface InstanceConfigFields {
"exp_groups.sync_permission_groups": boolean;
}
}
export const plugin: lib.PluginDeclaration = {
name: "exp_groups",
title: "exp_groups",
description: "Create, modify, and link factorio permission groups to clusterio user roles.",
controllerEntrypoint: "./dist/node/controller",
controllerConfigFields: {
"exp_groups.allow_role_inconsistency": {
title: "Allow User Role Inconsistency",
description: "When true, users can be assgined to any group regardless of their roles",
type: "boolean",
initialValue: false,
},
},
instanceEntrypoint: "./dist/node/instance",
instanceConfigFields: {
"exp_groups.sync_permission_groups": {
title: "Sync Permission Groups",
description: "When true, the instance cannot deviate from the global group settings and will be hidden from the sellection dropdown.",
type: "boolean",
initialValue: true,
},
},
messages: [
Messages.PermissionGroupEditEvent,
Messages.PermissionStringsUpdate,
Messages.PermissionGroupUpdate,
],
webEntrypoint: "./web",
routes: [
"/exp_groups",
],
};

View File

@@ -1,129 +0,0 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import {
PermissionGroup, PermissionGroupEditEvent, PermissionGroupEditType,
PermissionGroupUpdate, PermissionInstanceId, PermissionStrings, PermissionStringsUpdate
} from "./messages";
const rconBase = "/sc local Groups = package.loaded['modules/exp_groups/module_exports'];"
type EditIPC = {
type: PermissionGroupEditType,
changes: string[],
group: string,
};
type CreateIPC = {
group: string,
defiantion: [boolean, string[] | {}]
}
type DeleteIPC = {
group: string,
}
export class InstancePlugin extends BaseInstancePlugin {
permissions: Set<string> = new Set();
permissionGroups = new lib.EventSubscriber(PermissionGroupUpdate, this.instance);
permissionGroupUpdates = new lib.EventSubscriber(PermissionGroupEditEvent, this.instance);
syncId: PermissionInstanceId = this.instance.config.get("exp_groups.sync_permission_groups") ? "Global" : this.instance.id;
async init() {
this.instance.server.handle("exp_groups-permission_group_edit", this.handleEditIPC.bind(this));
this.instance.server.handle("exp_groups-permission_group_create", this.handleCreateIPC.bind(this));
this.instance.server.handle("exp_groups-permission_group_delete", this.handleDeleteIPC.bind(this));
}
async onStart() {
// Send the most recent version of the permission string
const permissionsString = await this.sendRcon(rconBase + "rcon.print(Groups.get_actions_json())");
this.permissions = new Set(JSON.parse(permissionsString));
this.instance.sendTo("controller", new PermissionStringsUpdate([
new PermissionStrings(this.instance.id, this.permissions, Date.now())
]));
// Subscribe to get updates for permission groups
this.permissionGroups.subscribe(this.onPermissionGroupsUpdate.bind(this));
this.permissionGroupUpdates.subscribe(this.onPermissionGroupUpdate.bind(this));
}
async onControllerConnectionEvent(event: any) {
this.permissionGroups.handleConnectionEvent(event);
}
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
if (field === "exp_groups.sync_permission_groups") {
this.syncId = curr ? "Global" : this.instance.id;
const [snapshot, synced] = this.permissionGroups.getSnapshot();
if (synced && this.instance.status !== "running") await this.syncPermissionGroups(snapshot.values());
}
}
async onPermissionGroupsUpdate(event: PermissionGroupUpdate | null, synced: boolean) {
if (!synced || this.instance.status !== "running" || !event?.updates.length) return;
await this.syncPermissionGroups(event.updates);
}
async syncPermissionGroups(groups: Iterable<PermissionGroup>) {
const updateCommands = [rconBase];
for (const group of groups) {
if (group.instanceId === this.syncId && group.updatedAtMs > (this.permissionGroups.values.get(group.id)?.updatedAtMs ?? 0)) {
if (group.isDeleted) {
updateCommands.push(`Groups.destroy_group('${group.name}')`);
} else if (group.permissions.size < this.permissions.size / 2) {
updateCommands.push(`Groups.get_or_create('${group.name}'):from_json('${JSON.stringify([false, [...this.permissions.values()]])}')`);
} else {
const inverted = [...this.permissions.values()].filter(permission => !group.permissions.has(permission));
updateCommands.push(`Groups.get_or_create('${group.name}'):from_json('${JSON.stringify([true, inverted])}')`);
}
}
}
await this.sendRcon(updateCommands.join(";"), true);
}
async onPermissionGroupUpdate(event: PermissionGroupEditEvent | null, synced: boolean) {
if (!synced || this.instance.status !== "running" || !event) return;
if (event.src.equals(lib.Address.fromShorthand({ instanceId: this.instance.id }))) return;
const getCmd = `Groups.get_or_create('${event.group}')`;
if (event.type === "add_permissions") {
await this.sendRcon(rconBase + getCmd + `:allow_actions(Groups.json_to_actions('${JSON.stringify(event.changes)}'))`);
} else if (event.type === "remove_permissions") {
await this.sendRcon(rconBase + getCmd + `:disallow_actions(Groups.json_to_actions('${JSON.stringify(event.changes)}'))`);
} else if (event.type === "assign_players") {
await this.sendRcon(rconBase + getCmd + `:add_players(game.json_to_table('${JSON.stringify(event.changes)}'))`);
}
}
async handleEditIPC(event: EditIPC) {
this.logger.info(JSON.stringify(event))
this.instance.sendTo("controller", new PermissionGroupEditEvent(
lib.Address.fromShorthand({ instanceId: this.instance.id }),
event.type, event.group, event.changes
))
}
async handleCreateIPC(event: CreateIPC) {
this.logger.info(JSON.stringify(event))
if (!this.permissionGroups.synced) return;
let [defaultAllow, permissionsRaw] = event.defiantion;
if (!Array.isArray(permissionsRaw)) {
permissionsRaw = [] // lua outputs {} for empty arrays
}
const permissions = [...this.permissions.values()]
.filter(permission => defaultAllow !== (permissionsRaw as String[]).includes(permission));
this.instance.sendTo("controller", new PermissionGroupUpdate([ new PermissionGroup(
this.syncId, event.group, 0, new Set(), new Set(permissions)
) ]));
}
async handleDeleteIPC(event: DeleteIPC) {
if (!this.permissionGroups.synced) return;
const group = [...this.permissionGroups.values.values()]
.find(group => group.instanceId === this.syncId && group.name === event.group);
if (group) {
group.updatedAtMs = Date.now();
group.isDeleted = true;
this.instance.sendTo("controller", new PermissionGroupUpdate([ group ]));
}
}
}

View File

@@ -1,239 +0,0 @@
import { User, InstanceDetails, IControllerUser, Link, MessageRequest, StringEnum, PermissionError, Address } from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
export const PermissionInstanceIdSchema = Type.Union([InstanceDetails.jsonSchema.properties.id, Type.Literal("Global")])
export type PermissionInstanceId = InstanceDetails["id"] | "Global"
export type GamePermission = string; // todo: maybe enum this?
/**
* Data class for permission groups
*/
export class PermissionGroup {
constructor(
public instanceId: PermissionInstanceId,
public name: string,
/** A lower order assumes a lower permission group */
public order: number = 0,
/** A role will use the highest order group it is apart of */
public roleIds: User["roleIds"] = new Set(),
public permissions: Set<GamePermission> = new Set(),
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {
}
static jsonSchema = Type.Object({
instanceId: PermissionInstanceIdSchema,
name: Type.String(),
order: Type.Number(),
roleIds: Type.Array(Type.Number()),
permissions: Type.Array(Type.String()),
updatedAtMs: Type.Optional(Type.Number()),
isDeleted: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.instanceId,
json.name,
json.order,
new Set(json.roleIds),
new Set(json.permissions),
json.updatedAtMs,
json.isDeleted
);
}
toJSON(): Static<typeof PermissionGroup.jsonSchema> {
return {
instanceId: this.instanceId,
name: this.name,
order: this.order,
roleIds: [...this.roleIds.values()],
permissions: [...this.permissions.values()],
updatedAtMs: this.updatedAtMs > 0 ? this.updatedAtMs : undefined,
isDeleted: this.isDeleted ? this.isDeleted : undefined,
}
}
get id() {
return `${this.instanceId}:${this.name}`;
}
copy(newInstanceId: PermissionInstanceId) {
return new PermissionGroup(
newInstanceId,
this.name,
this.order,
new Set(this.roleIds),
new Set(this.permissions),
Date.now(),
false
)
}
}
export class InstancePermissionGroups {
constructor(
public instanceId: PermissionInstanceId,
public groups: Map<PermissionGroup["name"], PermissionGroup> = new Map(),
) {
}
static jsonSchema = Type.Object({
instanceId: PermissionInstanceIdSchema,
permissionsGroups: Type.Array(PermissionGroup.jsonSchema),
});
static fromJSON(json: Static<typeof InstancePermissionGroups.jsonSchema>) {
return new InstancePermissionGroups(
json.instanceId,
new Map(json.permissionsGroups.map(group => [group.name, PermissionGroup.fromJSON(group)])),
);
}
toJSON() {
return {
instanceId: this.instanceId,
permissionsGroups: [...this.groups.values()],
}
}
getUserGroup(user: User) {
const groups = [...user.roleIds.values()].map(roleId =>
// There will always be one and only one group for each role
[...this.groups.values()].find(group => group.roleIds.has(roleId))!
);
return groups.reduce((highest, group) => highest.order > group.order ? highest : group);
}
get id() {
return this.instanceId;
}
}
export class PermissionGroupUpdate {
declare ["constructor"]: typeof PermissionGroupUpdate;
static type = "event" as const;
static src = ["controller", "instance"] as const;
static dst = ["control", "instance", "controller"] as const;
static plugin = "exp_groups" as const;
static permission = "exp_groups.list.subscribe";
constructor(
public updates: PermissionGroup[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(PermissionGroup.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.updates.map(update => PermissionGroup.fromJSON(update))
);
}
}
export type PermissionGroupEditType = "assign_players" | "add_permissions" | "remove_permissions";
export class PermissionGroupEditEvent {
declare ["constructor"]: typeof PermissionGroupEditEvent;
static type = "event" as const;
static src = ["instance", "controller"] as const;
static dst = ["control", "instance", "controller"] as const;
static plugin = "exp_groups" as const;
static permission(user: IControllerUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof PermissionGroupEditEvent.jsonSchema>;
if (data.type === "add_permissions" || data.type === "remove_permissions") {
user.checkPermission("exp_groups.modify_permissions")
} else if (data.type === "assign_players") {
user.checkPermission("exp_groups.assign_players")
} else {
throw new PermissionError("Permission denied");
}
};
}
constructor(
public src: Address,
public type: PermissionGroupEditType,
public group: string,
public changes: String[],
) { }
static jsonSchema = Type.Object({
"src": Address.jsonSchema,
"type": StringEnum(["assign_players", "add_permissions", "remove_permissions"]),
"group": Type.String(),
"changes": Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(Address.fromJSON(json.src), json.type, json.group, json.changes);
}
}
export class PermissionStrings {
constructor(
public instanceId: PermissionInstanceId,
public permissions: Set<GamePermission>,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {
}
static jsonSchema = Type.Object({
instanceId: PermissionInstanceIdSchema,
permissions: Type.Array(Type.String()),
updatedAtMs: Type.Optional(Type.Number()),
isDeleted: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof PermissionStrings.jsonSchema>) {
return new PermissionStrings(
json.instanceId,
new Set(json.permissions),
json.updatedAtMs,
json.isDeleted
);
}
toJSON() {
return {
instanceId: this.instanceId,
permissions: [...this.permissions.values()],
updatedAtMs: this.updatedAtMs > 0 ? this.updatedAtMs : undefined,
isDeleted: this.isDeleted ? this.isDeleted : undefined,
}
}
get id() {
return this.instanceId
}
}
export class PermissionStringsUpdate {
declare ["constructor"]: typeof PermissionStringsUpdate;
static type = "event" as const;
static src = ["instance", "controller"] as const;
static dst = ["controller", "control"] as const;
static plugin = "exp_groups" as const;
static permission = "exp_groups.list.subscribe";
constructor(
public updates: PermissionStrings[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(PermissionStrings.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.updates.map(update => PermissionStrings.fromJSON(update))
);
}
}

View File

@@ -1,127 +0,0 @@
local clusterio_api = require("modules/clusterio/api")
local Global = require("modules/exp_util/global")
local Groups = require("modules/exp_groups")
local pending_updates = {}
Global.register(pending_updates, function(tbl)
pending_updates = tbl
end)
local function on_permission_group_added(event)
if not event.player_index then return end
pending_updates[event.group.name] = {
created = true,
sync_all = true,
tick = event.tick,
permissions = {},
players = {},
}
end
local function on_permission_group_deleted(event)
if not event.player_index then return end
local existing = pending_updates[event.group_name]
pending_updates[event.group_name] = nil
if not existing or not existing.created then
clusterio_api.send_json("exp_groups-permission_group_delete", {
group = event.group_name,
})
end
end
local function on_permission_group_edited(event)
if not event.player_index then return end
local pending = pending_updates[event.group.name]
if not pending then
pending = {
tick = event.tick,
permissions = {},
players = {},
}
pending_updates[event.group.name] = pending
end
pending.tick = event.tick
if event.type == "add-permission" then
if not pending.sync_all then
pending.permissions[event.action] = true
end
elseif event.type == "remove-permission" then
if not pending.sync_all then
pending.permissions[event.action] = false
end
elseif event.type == "enable-all" then
pending.sync_all = true
elseif event.type == "disable-all" then
pending.sync_all = true
elseif event.type == "add-player" then
local player = game.get_player(event.other_player_index) --- @cast player -nil
pending.players[player.name] = true
elseif event.type == "remove-player" then
local player = game.get_player(event.other_player_index) --- @cast player -nil
pending.players[player.name] = nil
elseif event.type == "rename" then
pending.created = true
pending.sync_all = true
local old = pending_updates[event.old_name]
if old then pending.players = old.players end
on_permission_group_deleted{
tick = event.tick, player_index = event.player_index, group_name = event.old_name,
}
end
end
local function send_updates()
local tick = game.tick - 600 -- 10 Seconds
local done = {}
for group_name, pending in pairs(pending_updates) do
if pending.tick < tick then
done[group_name] = true
if pending.sync_all then
clusterio_api.send_json("exp_groups-permission_group_create", {
group = group_name, defiantion = Groups.get_group(group_name):to_json(true),
})
else
if next(pending.players) then
clusterio_api.send_json("exp_groups-permission_group_edit", {
type = "assign_players", group = group_name, changes = table.get_keys(pending.players),
})
end
local add, remove = {}, {}
for permission, state in pairs(pending.permissions) do
if state then
add[#add + 1] = permission
else
remove[#remove + 1] = permission
end
end
if next(add) then
clusterio_api.send_json("exp_groups-permission_group_edit", {
type = "add_permissions", group = group_name, changes = Groups.actions_to_names(add),
})
end
if next(remove) then
clusterio_api.send_json("exp_groups-permission_group_edit", {
type = "remove_permissions", group = group_name, changes = Groups.actions_to_names(remove),
})
end
end
end
end
for group_name in pairs(done) do
pending_updates[group_name] = nil
end
end
return {
events = {
[defines.events.on_permission_group_added] = on_permission_group_added,
[defines.events.on_permission_group_deleted] = on_permission_group_deleted,
[defines.events.on_permission_group_edited] = on_permission_group_edited,
},
on_nth_tick = {
[300] = send_updates,
},
}

View File

@@ -1,12 +0,0 @@
{
"name": "exp_groups",
"load": [
"control.lua"
],
"require": [
],
"dependencies": {
"clusterio": "*",
"exp_util": "*"
}
}

View File

@@ -1,305 +0,0 @@
local Async = require("modules/exp_util/async")
local table_to_json = helpers.table_to_json
local json_to_table = helpers.json_to_table
--- Top level module table, contains event handlers and public methods
local Groups = {}
--- @class ExpGroup : LuaPermissionGroup
--- @field group LuaPermissionGroup The permission group for this group proxy
Groups._prototype = {}
Groups._metatable = {
__index = setmetatable(Groups._prototype, {
--- @type any Annotation required because otherwise it is typed as 'table'
__index = function(self, key)
return self.group[key]
end,
}),
__class = "ExpGroup",
}
local action_to_name = {}
for name, action in pairs(defines.input_action) do
action_to_name[action] = name
end
--- Async Functions
-- These are required to allow bypassing edit_permission_group
--- Add a player to a permission group, requires edit_permission_group
--- @param player LuaPlayer Player to add to the group
--- @param group LuaPermissionGroup Group to add the player to
--- @return boolean # True if successful
local function add_player_to_group(player, group)
return group.add_player(player)
end
--- Add a players to a permission group, requires edit_permission_group
--- @param players LuaPlayer[] Players to add to the group
--- @param group LuaPermissionGroup Group to add the players to
--- @return boolean # True if successful
local function add_players_to_group(players, group)
local add_player = group.add_player
if not add_player(players[1]) then
return false
end
for i = 2, #players do
add_player(players[i])
end
return true
end
-- Async will bypass edit_permission_group but takes at least one tick
local add_player_to_group_async = Async.register(add_player_to_group)
local add_players_to_group_async = Async.register(add_players_to_group)
--- Static methods for gettings, creating and removing permission groups
--- Gets the permission group proxy with the given name or group ID.
--- @param group_name string|uint32 The name or id of the permission group
--- @return ExpGroup?
function Groups.get_group(group_name)
local group = game.permissions.get_group(group_name)
if group == nil then return nil end
return setmetatable({
group = group,
}, Groups._metatable)
end
--- Gets the permission group proxy for a players group
--- @param player LuaPlayer The player to get the group of
--- @return ExpGroup?
function Groups.get_player_group(player)
local group = player.permission_group
if group == nil then return nil end
return setmetatable({
group = group,
}, Groups._metatable)
end
--- Creates a new permission group, requires add_permission_group
--- @param group_name string Name of the group to create
--- @return ExpGroup
function Groups.new_group(group_name)
local group = game.permissions.get_group(group_name)
assert(group == nil, "Group already exists with name: " .. group_name)
group = game.permissions.create_group(group_name)
assert(group ~= nil, "Requires permission add_permission_group")
return setmetatable({
group = group,
}, Groups._metatable)
end
--- Get or create a permisison group, must use the group name not the group id
--- @param group_name string Name of the group to create
--- @return ExpGroup
function Groups.get_or_create(group_name)
local group = game.permissions.get_group(group_name)
if group then
return setmetatable({
group = group,
}, Groups._metatable)
else
group = game.permissions.create_group(group_name)
assert(group ~= nil, "Requires permission add_permission_group")
return setmetatable({
group = group,
}, Groups._metatable)
end
end
--- Destory a permission group, moves all players to default group
--- @param group_name string|uint32 The name or id of the permission group to destroy
--- @param move_to_name string|uint32? The name or id of the permission group to move players to
function Groups.destroy_group(group_name, move_to_name)
local group = game.permissions.get_group(group_name)
if group == nil then return end
local players = group.players
if #players > 0 then
local move_to = game.permissions.get_group(move_to_name or "Default")
for _, player in ipairs(players) do
player.permission_group = move_to
end
end
local success = group.destroy()
assert(success, "Requires permission delete_permission_group")
end
--- Prototype methods for modifying and working with permission groups
--- Add a player to the permission group
--- @param player LuaPlayer The player to add to the group
function Groups._prototype:add_player(player)
if not add_player_to_group(player, self.group) then
add_player_to_group_async(player, self.group)
end
end
--- Add players to the permission group
--- @param players LuaPlayer[] The player to add to the group
function Groups._prototype:add_players(players)
if not add_players_to_group(players, self.group) then
add_players_to_group_async(players, self.group)
end
end
--- Move all players to another group
--- @param other_group ExpGroup The group to move players to, default is the Default group
function Groups._prototype:move_players(other_group)
if not add_players_to_group(self.group.players, other_group.group) then
add_players_to_group_async(self.group.players, other_group.group)
end
end
--- Allow a set of actions for this group
--- @param actions defines.input_action[] Actions to allow
--- @return ExpGroup
function Groups._prototype:allow_actions(actions)
local set_allow = self.group.set_allows_action
for _, action in ipairs(actions) do
set_allow(action, true)
end
return self
end
--- Disallow a set of actions for this group
--- @param actions defines.input_action[] Actions to disallow
--- @return ExpGroup
function Groups._prototype:disallow_actions(actions)
local set_allow = self.group.set_allows_action
for _, action in ipairs(actions) do
set_allow(action, false)
end
return self
end
--- Reset the allowed state of all actions
--- @param allowed boolean? default true for allow all actions, false to disallow all actions
--- @return ExpGroup
function Groups._prototype:reset(allowed)
local set_allow = self.group.set_allows_action
if allowed == nil then allowed = true end
for _, action in pairs(defines.input_action) do
set_allow(action, allowed)
end
return self
end
--- Returns if the group is allowed a given action
--- @param action string|defines.input_action Actions to test
--- @return boolean # True if successful
function Groups._prototype:allows(action)
if type(action) == "string" then
return self.group.allows_action(defines.input_action[action])
end
return self.group.allows_action(action)
end
--- Print a message to all players in the group
function Groups._prototype:print(...)
for _, player in ipairs(self.group.players) do
player.print(...)
end
end
--- Static and Prototype methods for use with IPC
--- Convert an array of strings into an array of action names
--- @param actions_names string[] An array of action names
--- @return defines.input_action[]
local function names_to_actions(actions_names)
local actions, invalid, invalid_i = {}, {}, 1
for i, action_name in ipairs(actions_names) do
local action = defines.input_action[action_name]
if action then
actions[i] = action
else
invalid[invalid_i] = i
invalid_i = invalid_i + 1
end
end
local last = #actions
for _, i in ipairs(invalid) do
actions[i] = actions[last]
last = last - 1
end
return actions
end
--- Get the action names from the action numbers
function Groups.actions_to_names(actions)
local names = {}
for i, action in ipairs(actions) do
names[i] = action_to_name[action]
end
return names
end
--- Get all input actions that are defined
function Groups.get_actions_json()
local rtn, rtn_i = {}, 1
for name in pairs(defines.input_action) do
rtn[rtn_i] = name
rtn_i = rtn_i + 1
end
return table_to_json(rtn)
end
--- Convert a json string array into an array of input actions
--- @param json string A json string representing a string array of actions
--- @return defines.input_action[]
function Groups.json_to_actions(json)
local tbl = json_to_table(json)
assert(tbl, "Invalid Json String")
--- @cast tbl string[]
return names_to_actions(tbl)
end
--- Returns the shortest defination of the allowed actions
-- The first value of the return can be passed to :reset
function Groups._prototype:to_json(raw)
local allow, disallow = {}, {}
local allow_i, disallow_i = 1, 1
local allows = self.group.allows_action
for name, action in pairs(defines.input_action) do
if allows(action) then
allow[allow_i] = name
allow_i = allow_i + 1
else
disallow[disallow_i] = name
disallow_i = disallow_i + 1
end
end
if allow_i >= disallow_i then
return raw and { true, disallow } or table_to_json{ true, disallow }
end
return raw and { false, allow } or table_to_json{ false, allow }
end
--- Restores this group to the state given in a json string
--- @param json string The json string to restore from
function Groups._prototype:from_json(json)
local tbl = json_to_table(json)
assert(tbl and type(tbl[1]) == "boolean" and type(tbl[2]) == "table", "Invalid Json String")
if tbl[1] then
self:reset(true):disallow_actions(names_to_actions(tbl[2]))
return
end
self:reset(false):allow_actions(names_to_actions(tbl[2]))
end
return Groups

View File

@@ -1,41 +0,0 @@
{
"name": "@expcluster/permission_groups",
"private": true,
"version": "0.0.0",
"description": "Example Description. Package. Change me in package.json",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.19"
},
"devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.19",
"@clusterio/web_ui": "^2.0.0-alpha.19",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"antd": "^5.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.2.0"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"clusterio",
"factorio"
]
}

View File

@@ -1,4 +0,0 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.browser.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,5 +0,0 @@
{
"extends": "../tsconfig.node.json",
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}

View File

@@ -1,129 +0,0 @@
import React, { useState } from 'react';
import { Tree } from 'antd';
import type { TreeDataNode, TreeProps } from 'antd';
const defaultData: TreeDataNode[] = [
{
title: "Group 1",
key: "G-1",
icon: false,
children: [
{
title: "Role 1",
key: "R-1"
},
{
title: "Role 2",
key: "R-2"
},
{
title: "Role 3",
key: "R-3"
}
]
},
{
title: "Group 2",
key: "G-2",
icon: false,
children: [
{
title: "Role 4",
key: "R-4"
},
{
title: "Role 5",
key: "R-5"
}
]
},
{
title: "Default",
key: "G-3",
icon: false,
children: [
{
title: "Role 6",
key: "R-6"
}
]
}
];
export function GroupTree() {
const [gData, setGData] = useState(defaultData);
const onDrop: TreeProps['onDrop'] = (info) => {
const dropKey = info.node.key;
const dragKey = info.dragNode.key;
const dropPos = info.node.pos.split('-');
const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]); // the drop position relative to the drop node, inside 0, top -1, bottom 1
const findKey = (
data: TreeDataNode[],
key: React.Key,
callback: (node: TreeDataNode, i: number, data: TreeDataNode[]) => void,
) => {
for (let i = 0; i < data.length; i++) {
if (data[i].key === key) {
return callback(data[i], i, data);
}
if (data[i].children) {
findKey(data[i].children!, key, callback);
}
}
};
const data = [...gData]
// Find dragObject
let dragObj: TreeDataNode;
findKey(data, dragKey, (item, index, arr) => {
arr.splice(index, 1);
dragObj = item;
});
if (!info.dropToGap) {
// Drop on the content
findKey(data, dropKey, (item) => {
item.children = item.children || [];
// where to insert. New item was inserted to the start of the array in this example, but can be anywhere
item.children.unshift(dragObj);
});
} else {
let ar: TreeDataNode[] = [];
let i: number;
findKey(data, dropKey, (_item, index, arr) => {
ar = arr;
i = index;
});
if (dropPosition === -1) {
// Drop on the top of the drop node
ar.splice(i!, 0, dragObj!);
} else {
// Drop on the bottom of the drop node
ar.splice(i! + 1, 0, dragObj!);
}
}
setGData(data)
};
const allowDrop: TreeProps['allowDrop'] = ({dragNode, dropNode, dropPosition}) => {
const dragType = (dragNode.key as string).charAt(0);
const dropType = (dropNode.key as string).charAt(0);
return dropType === dragType && dropPosition != 0 || dragType === "R" && dropType === "G" && dropPosition == 0
}
return (
<Tree
className="draggable-tree"
defaultExpandAll={true}
draggable
blockNode
onDrop={onDrop}
allowDrop={allowDrop}
treeData={gData}
/>
);
};

View File

@@ -1,82 +0,0 @@
import React, {
useContext, useEffect, useState,
useCallback, useSyncExternalStore,
} from "react";
// import {
//
// } from "antd";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
useInstances,
} from "@clusterio/web_ui";
import { PermissionGroupUpdate, PermissionInstanceId, PermissionStringsUpdate } from "../messages";
import * as lib from "@clusterio/lib";
import { GroupTree } from "./components/groupTree";
function MyTemplatePage() {
const control = useContext(ControlContext);
const plugin = control.plugins.get("exp_groups") as WebPlugin;
const [permissionStrings, permissionStringsSynced] = plugin.usePermissionStrings();
const [permissionGroups, permissionGroupsSynced] = plugin.usePermissionGroups();
const [instances, instancesSync] = useInstances();
let [roles, setRoles] = useState<lib.Role[]>([]);
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
setRoles(newRoles);
}).catch(notifyErrorHandler("Error fetching role list"));
}, []);
return <PageLayout nav={[{ name: "exp_groups" }]}>
<PageHeader title="exp_groups" />
Permission Strings: {String(permissionStringsSynced)} {JSON.stringify([...permissionStrings.values()])} <br/>
Permission Groups: {String(permissionGroupsSynced)} {JSON.stringify([...permissionGroups.values()])} <br/>
Instances: {String(instancesSync)} {JSON.stringify([...instances.values()].map(instance => [instance.id, instance.name]))} <br/>
Roles: {JSON.stringify([...roles.values()].map(role => [role.id, role.name]))} <br/>
<GroupTree/>
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
permissionStrings = new lib.EventSubscriber(PermissionStringsUpdate, this.control);
permissionGroups = new lib.EventSubscriber(PermissionGroupUpdate, this.control);
async init() {
this.pages = [
{
path: "/exp_groups",
sidebarName: "exp_groups",
permission: "exp_groups.list",
content: <MyTemplatePage/>,
},
];
}
useInstancePermissionStrings(instanceId?: PermissionInstanceId) {
const [permissionStrings, synced] = this.usePermissionStrings();
return [instanceId !== undefined ? permissionStrings.get(instanceId) : undefined, synced] as const;
}
usePermissionStrings() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => this.permissionStrings.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.permissionStrings.getSnapshot());
}
useInstancePermissionGroups(instanceId?: PermissionInstanceId) {
const [permissionGroups, synced] = this.usePermissionGroups();
return [instanceId !== undefined ? [...permissionGroups.values()].filter(group => group.instanceId === instanceId) : undefined, synced] as const;
}
usePermissionGroups() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => this.permissionGroups.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.permissionGroups.getSnapshot());
}
}

View File

@@ -1,32 +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_groups",
library: { type: "window", name: "plugin_exp_groups" },
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 },
},
}),
],
});