Merge pull request #478 from Hornwitser/mod-management

Add mod pack oriented mod management
This commit is contained in:
Hornwitser authored and GitHub committed 2023-02-15 18:32:37 +01:00
commit bf92dde28d
61 files changed
+5005 -569

No files matched your search

+1 -2
View File
@@ -13,8 +13,7 @@ factorio
factorio.tar.gz
# We build internal mods during build, mods from local test env should not be applied
sharedMods
sharedModsDisabled
mods
# These files should never be required during the build
.git
+2 -2
View File
@@ -26,8 +26,8 @@ jobs:
node-version: ${{ matrix.node-version }}
- run: pnpm i --no-optional --no-frozen-lockfile
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- run: node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
- run: wget -q -O temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
- run: node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/mods
- run: wget -q -O temp/test/mods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
- name: Run tests
if: ${{ matrix.node-version != '12.x' }}
run: pnpm test
+1 -1
View File
@@ -32,7 +32,7 @@ node_modules
Factorio*
/factorio
/instances
/sharedMods
/mods
/database
/plugin-list.json
/config-master.json
+4 -4
View File
@@ -22,15 +22,15 @@ RUN pnpm install
#RUN pnpm install @clusterio/plugin-subspace_storage
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/mods/
# Build Lua library mod
RUN node packages/lib/build_mod --build --source-dir packages/slave/lua/clusterio_lib \
&& mv dist/* sharedMods/ \
&& mv dist/* mods/ \
&& mkdir temp \
&& mkdir temp/test \
&& cp sharedMods/ temp/test/ -r \
&& ls sharedMods
&& cp mods/ temp/test/ -r \
&& ls mods
# Remove node_modules
RUN find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \;
+4
View File
@@ -89,6 +89,10 @@ class Control extends libLink.Link {
async saveListUpdateEventHandler() { }
async modPackUpdateEventHandler() { }
async modUpdateEventHandler() { }
async userUpdateEventHandler() { }
async setLogSubscriptions({
+462
View File
@@ -14,6 +14,7 @@ const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libConfig = require("@clusterio/lib/config");
const libCommand = require("@clusterio/lib/command");
const libData = require("@clusterio/lib/data");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libHelpers = require("@clusterio/lib/helpers");
@@ -860,6 +861,465 @@ instanceCommands.add(new libCommand.Command({
},
}));
const modPackCommands = new libCommand.CommandTree({ name: "mod-pack", description: "Mod Pack" });
modPackCommands.add(new libCommand.Command({
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
}],
handler: async function(args, control) {
let mods = new Map(
(await libLink.messages.listMods.send(control)).list.map(m => [`${m.name}_${m.version}`, m])
);
let response = await libLink.messages.getModPack.send(control, {
id: await libCommand.resolveModPack(control, args.modPack),
});
for (let [field, value] of Object.entries(response.mod_pack)) {
if (field === "mods") {
print(`${field}:`);
for (let entry of value) {
const mod = mods.get(`${entry.name}_${entry.version}`);
const missing = !mod && "? ";
const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! ";
const warn = missing || badChecksum || "";
const enabled = entry.enabled ? "" : "(disabled) ";
print(` ${warn}${enabled}${entry.name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`);
}
} else if (field === "settings") {
print(`${field}:`);
for (let [scope, settings] of Object.entries(value)) {
print(` ${scope}:`);
for (let [setting, settingValue] of Object.entries(settings)) {
print(` ${setting}: ${JSON.stringify(settingValue.value)}`);
}
}
} else if (field === "export_manifest") {
print(`${field}:`);
print(" assets:");
for (let [name, fileName] of Object.entries(value.assets)) {
print(` ${name}: ${fileName}`);
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modPackCommands.add(new libCommand.Command({
definition: [["list", "l"], "List mod packs in the cluster"],
handler: async function(args, control) {
let response = await libLink.messages.listModPacks.send(control);
let fields = ["id", "name", "factorio_version"];
for (let entry of response.list) {
for (let field of Object.keys(entry)) {
if (!fields.includes(field)) {
delete entry[field];
}
}
}
print(asTable(response.list));
},
}));
function setModPackSettings(modPack, args) {
function doSettings(settings, cast) {
for (let i = 0; i + 2 < settings.length; i += 3) {
const [scope, name, value] = settings.slice(i, i + 3);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new libErrors.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
modPack.settings[scope].set(name, { value: cast(value) });
}
}
doSettings(args.boolSetting || [], value => {
if (!["true", "false"].includes(value)) {
throw new libErrors.CommandError(`boolean value must be one of true or false, not ${value}`);
}
return value === "true";
});
doSettings(args.intSetting || [], value => {
// eslint-disable-next-line radix
let number = Number.parseInt(value);
if (isNaN(number)) {
throw new libErrors.CommandError(`int value must be an integer, not ${value}`);
}
return number;
});
doSettings(args.doubleSetting || [], value => {
let number = Number.parseFloat(value);
if (isNaN(number)) {
throw new libErrors.CommandError(`double value must be a number, not ${value}`);
}
return number;
});
doSettings(args.stringSetting || [], value => value);
}
function setModPackMods(modPack, mods) {
for (let mod of mods || []) {
const [name, version, sha1] = mod.split(":");
if (!version) {
throw new libErrors.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new libErrors.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
throw new libErrors.CommandError("sha1 must be a 40 digit lower case hex string");
}
modPack.mods.set(name, { name, enabled: true, version, sha1 });
}
}
function setModPackModsEnabled(modPack, mods, enabled) {
for (let mod of mods || []) {
if (!modPack.mods.has(mod)) {
throw new libErrors.CommandError(`Mod named ${mod} does not exist in the mod pack`);
}
modPack.mods.get(mod).enabled = enabled;
}
}
modPackCommands.add(new libCommand.Command({
definition: ["create <name> <factorio-version>", "Create mod pack", (yargs) => {
yargs.positional("name", { describe: "Name of mod pack to create", type: "string" });
yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" });
yargs.options({
"description": { describe: "Description for mod pack", type: "string" },
"mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" },
"disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
});
}],
handler: async function(args, control) {
const modPack = new libData.ModPack();
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
throw new libErrors.CommandError("factorio-version must match the format digit.digit.digit");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.mods);
setModPackModsEnabled(modPack, args.disabledMods, false);
setModPackSettings(modPack, args);
await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() });
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new libCommand.Command({
definition: ["import <string>", "Import mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack string to import", type: "string" });
}],
handler: async function(args, control) {
const modPack = libData.ModPack.fromModPackString(args.string);
await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() });
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new libCommand.Command({
definition: ["export <mod-pack>", "Export mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack to export", type: "string" });
}],
handler: async function(args, control) {
const response = await libLink.messages.getModPack.send(control, {
id: await libCommand.resolveModPack(control, args.modPack),
});
const modPack = new libData.ModPack(response.mod_pack);
print(modPack.toModPackString());
},
}));
modPackCommands.add(new libCommand.Command({
definition: ["edit <mod-pack>", "Edit mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" });
yargs.options({
"name": { describe: "New name for mod pack", type: "string" },
"description": { describe: "New description for mod pack", type: "string" },
"factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" },
"add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" },
"enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" },
"disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" },
"remove-mods": { describe: "Name of mods to remove", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
});
}],
handler: async function(args, control) {
let response = await libLink.messages.getModPack.send(control, {
id: await libCommand.resolveModPack(control, args.modPack),
});
let modPack = new libData.ModPack(response.mod_pack);
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
throw new libErrors.CommandError("factorio-version must match the format digit.digit.digit");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.addMods);
for (let name of args.removeMods || []) {
if (modPack.mods.has(name)) {
modPack.mods.delete(name);
} else {
logger.warn(`Mod ${name} did not exist on ${modPack.name}`);
}
}
setModPackModsEnabled(modPack, args.disableMods, false);
setModPackModsEnabled(modPack, args.enableMods, true);
setModPackSettings(modPack, args);
if (args.removeSetting) {
for (let i = 0; i + 1 < args.removeSetting.length; i += 2) {
const [scope, name] = settings.slice(i, i + 2);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new libErrors.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
if (modPack.settings[scope].has(name)) {
modPack.settings[scope].delete(name);
} else {
logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`);
}
}
}
await libLink.messages.updateModPack.send(control, { mod_pack: modPack.toJSON() });
},
}));
modPackCommands.add(new libCommand.Command({
definition: ["delete <mod-pack>", "Delete mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
}],
handler: async function(args, control) {
const id = await libCommand.resolveModPack(control, args.modPack);
await libLink.messages.deleteModPack.send(control, { id });
},
}));
const modCommands = new libCommand.CommandTree({ name: "mod", description: "Manage uploaded mods" });
modCommands.add(new libCommand.Command({
definition: ["show <name> <mod-version>", "Show details for a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Mod name to show details for", type: "string" });
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args, control) {
let response = await libLink.messages.getMod.send(control, { name: args.name, version: args.modVersion });
for (let [field, value] of Object.entries(response.mod)) {
if (value instanceof Array) {
print(`${field}:`);
for (let entry of value) {
print(` ${entry}`);
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modCommands.add(new libCommand.Command({
definition: [["list", "l"], "List mods stored in the cluster", (yargs) => {
yargs.options({
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(args, control) {
let response = await libLink.messages.listMods.send(control);
if (!args.fields.includes("all")) {
for (let entry of response.list) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
delete entry[field];
}
}
}
}
print(asTable(response.list));
},
}));
modCommands.add(new libCommand.Command({
definition: ["search <factorio-version> [query]", "Search mods stored in the cluster", (yargs) => {
yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" });
yargs.positional("query", { describe: "Search query", type: "string", default: "" });
yargs.options({
"page": {
describe: "Result page to show",
type: "number",
default: 1,
},
"page-size": {
describe: "Results per page to show",
type: "number",
default: 10,
},
"sort": {
describe: "sort results by given field",
type: "string",
},
"sort-order": {
describe: "order to sort results in (asc/desc)",
type: "string",
default: "asc",
},
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(args, control) {
let response = await libLink.messages.searchMods.send(control, {
"query": args.query,
"factorio_version": args.factorioVersion,
"page_size": args.pageSize,
"page": args.page,
"sort": args.sort,
"sort_order": args.sortOrder,
});
let results = response.results.flatMap(result => result.versions);
if (!args.fields.includes("all")) {
for (let entry of results) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
delete entry[field];
}
}
}
}
for (let issue of response.query_issues) {
print(issue);
}
print(`page ${args.page} of ${response.page_count} (${response.result_count} results)`);
print(asTable(results));
},
}));
modCommands.add(new libCommand.Command({
definition: ["upload <file>", "Upload mod to the cluster", (yargs) => {
yargs.positional("file", { describe: "File to upload", type: "string" });
}],
handler: async function(args, control) {
let filename = path.basename(args.file);
if (!filename.endsWith(".zip")) {
throw new libErrors.CommandError("Mod filename must end with .zip");
}
// phin doesn't support streaming requests :(
let content = await fs.readFile(args.file);
let url = new URL(control.config.get("control.master_url"));
url.pathname += "api/upload-mod";
url.searchParams.append("filename", filename);
let result = await phin({
url, method: "POST",
headers: {
"X-Access-Token": control.config.get("control.master_token"),
"Content-Type": "application/zip",
},
core: { ca: control.tlsCa },
data: content,
parse: "json",
});
for (let error of result.body.errors || []) {
logger.error(error);
}
for (let requestError of result.body.request_errors || []) {
logger.error(requestError);
}
if (result.body.mods && result.body.mods.length) {
const mod = new libData.ModInfo(result.body.mods[0]);
logger.info(`Successfully uploaded ${mod.filename}`);
}
if ((result.body.errors || []).length || (result.body.request_errors || []).length) {
throw new libErrors.CommandError("Uploading mod failed");
}
},
}));
modCommands.add(new libCommand.Command({
definition: ["download <name> <mod-version>", "Download a mod from the cluster", (yargs) => {
yargs.positional("name", { describe: "Internal name of mod to download", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args, control) {
let result = await libLink.messages.downloadMod.send(control, {
name: args.name,
version: args.modVersion,
});
let url = new URL(control.config.get("control.master_url"));
url.pathname += `api/stream/${result.stream_id}`;
let response = await phin({
url, method: "GET",
core: { ca: control.tlsCa },
stream: true,
});
let writeStream;
let filename = `${args.name}_${args.modVersion}.zip`;
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
tempFilename = await libFileOps.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
response.pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${filename}`);
},
}));
modCommands.add(new libCommand.Command({
definition: ["delete <name> <mod-version>", "Delete a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Name of mod to delete", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args, control) {
await libLink.messages.deleteMod.send(control, { name: args.name, version: args.modVersion });
},
}));
const permissionCommands = new libCommand.CommandTree({ name: "permission", description: "Permission inspection" });
permissionCommands.add(new libCommand.Command({
definition: [["list", "l"], "List permissions in the cluster"],
@@ -1194,6 +1654,8 @@ async function registerCommands(controlPlugins, yargs) {
rootCommands.add(masterCommands);
rootCommands.add(slaveCommands);
rootCommands.add(instanceCommands);
rootCommands.add(modPackCommands);
rootCommands.add(modCommands);
rootCommands.add(permissionCommands);
rootCommands.add(roleCommands);
rootCommands.add(userCommands);
+1
View File
@@ -1,4 +1,5 @@
export { default as libConfig } from "./config";
export { default as libData } from "./data";
export { default as libErrors } from "./errors";
export { default as libFactorio } from "./factorio/exchange_string";
export { default as libHelpers } from "./helpers";
+34
View File
@@ -198,6 +198,39 @@ async function resolveInstance(client, instanceName) {
return instanceId;
}
/**
* Resolve a string to a mod pack ID
*
* Resolevs a string with either a mod pack name or an id into an integer
* with the mod pack ID.
*
* @param {module:lib/link.Link} client -
* link to master server to query mod pack on.
* @param {string} modPackName - string with name or id of mod pack.
* @returns {Promise<number>} mod pack ID.
* @static
*/
async function resolveModPack(client, modPackName) {
let modPackId;
if (/^-?\d+/.test(modPackName)) {
modPackId = parseInt(modPackName, 10);
} else {
let response = await libLink.messages.listModPacks.send(client);
for (let mod_pack of response.list) {
if (mod_pack["name"] === modPackName) {
modPackId = mod_pack["id"];
break;
}
}
if (modPackId === undefined) {
throw new libErrors.CommandError(`No mod pack named ${modPackName}`);
}
}
return modPackId;
}
/**
* Retrieve role object from string
*
@@ -244,5 +277,6 @@ module.exports = {
resolveSlave,
resolveInstance,
resolveModPack,
retrieveRole,
};
+34
View File
@@ -19,6 +19,13 @@ MasterGroup.define({
type: "string",
initial_value: "Your Cluster",
});
MasterGroup.define({
name: "mods_directory",
title: "Mods Directory",
description: "Path to directory where mods shared with the cluster are stored.",
type: "string",
initial_value: "mods",
});
MasterGroup.define({
name: "database_directory",
title: "Database directory",
@@ -108,6 +115,13 @@ MasterGroup.define({
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "default_mod_pack_id",
title: "Default Mod Pack",
description: "Mod pack used by default for instances.",
type: "number",
optional: true,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
@@ -153,6 +167,20 @@ SlaveGroup.define({
type: "string",
initial_value: "factorio",
});
SlaveGroup.define({
name: "mods_directory",
title: "Mods Directory",
description: "Path to directory where mods for instances are cached.",
type: "string",
initial_value: "mods",
});
SlaveGroup.define({
name: "mods_directory_is_shared",
title: "Mods Directory is Shared",
description: "True if the mods directory is shared with the master server.",
type: "boolean",
initial_value: true,
});
SlaveGroup.define({
name: "instances_directory",
description: "Path to directory to store instances in.",
@@ -273,6 +301,12 @@ FactorioGroup.define({
type: "number",
initial_value: 5,
});
FactorioGroup.define({
name: "mod_pack",
description: "Mod pack to use on this server",
type: "number",
optional: true,
});
FactorioGroup.define({
name: "enable_save_patching",
description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.",
+46
View File
@@ -0,0 +1,46 @@
"use strict";
/**
* Export manifest for a mod pack
*
* Tracks files stored on disk for data that extracted from Factorio using a
* given mod pack.
* @alias module:lib/data.ExportManifest
*/
class ExportManifest {
/**
* Mapping between known assets and their file names.
*
* Currently exported assets are
* - settings: JSON of mod settings prototypes.
* - prototypes: JSON of all game prototypes except settings.
* - item-spritesheet: PNG containing icons from the game.
* - item-metadata: JSON of Map entries describing icons in item-spritesheet
* - locale: JSON of flattened Map entries of en locale strings from the game.
* @type {Object<string, string>}
*/
assets = {};
static jsonSchema = {
type: "object",
additionalProperties: false,
required: ["assets"],
properties: {
"assets": {
type: "object",
additionalProperties: { type: "string" },
},
},
};
constructor(json = {}) {
if (json.assets) { this.assets = json.assets; }
}
toJSON() {
return { assets: this.assets };
}
}
module.exports = ExportManifest;
+231
View File
@@ -0,0 +1,231 @@
"use strict";
const fs = require("fs-extra");
const JSZip = require("jszip");
const path = require("path");
const libHash = require("../hash");
const libSchema = require("../schema");
const { findRoot } = require("../zip_ops");
const { integerModVersion, integerFactorioVersion, modVersionRegExp } = require("./version");
/**
* Info about a mod available on the master server.
*
* See https://wiki.factorio.com/Tutorial:Mod_structure#info.json for
* details of the invidiual fields sourced from info.json.
* @alias module:lib/data.ModInfo
*/
class ModInfo {
/**
* Internal name of this mod.
* This is the name of the zip file as well as the name that appears in
* info.json.
* @type {string}
*/
name = "";
/**
* Version of the mod.
* Sourced from info.json.
* @type {string}
*/
version = "";
/**
* Integer representation of the version
* @type {number}
*/
get integerVersion() {
return integerModVersion(this.version);
}
/**
* Display name of the mod.
* Sourced from info.json.
* @type {string}
*/
title = "";
/**
* Author of the mod.
* Sourced from info.json.
* @type {string}
*/
author = "";
/**
* Contact field for mod.
* Sourced from info.json.
* @type {string}
*/
contact = "";
/**
* Homepage of the mod.
* Sourced from info.json.
* @type {string}
*/
homepage = "";
/**
* Description of the mod.
* Sourced from info.json.
* @type {string}
*/
description = "";
/**
* Major version of Factorio this mod supports.
* Sourced from info.json.
* @type {string}
*/
factorioVersion = "0.12";
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
}
/**
* Dependiences for this mod.
* Sourced from info.json.
* @type {Array<string>}
*/
dependencies = ["base"];
/**
* Expected name of zip file containing this mod.
* @type {number}
*/
get filename() {
return `${this.name}_${this.version}.zip`;
}
/**
* Size of the mod in bytes
* @type {number}
*/
size = 0;
/**
* SHA1 hash of this mod
* @type {string=}
*/
sha1;
/**
* True if this mod has been deleted
* @type {boolean}
*/
isDeleted = false;
// Content of info.json found in mod files
static infoJsonSchema = {
type: "object",
required: ["name", "version", "title", "author"],
properties: {
"name": { type: "string" },
"version": { type: "string" },
"title": { type: "string" },
"author": { type: "string" },
"contact": { type: "string" },
"homepage": { type: "string" },
"description": { type: "string" },
"factorio_version": { type: "string" },
"dependencies": { type: "array", items: { type: "string" } },
},
};
static validateInfo = libSchema.compile(this.infoJsonSchema);
static jsonSchema = {
type: "object",
properties: {
...this.infoJsonSchema.properties,
"size": { type: "integer" },
"sha1": { type: "string" },
"is_deleted": { type: "boolean" },
},
};
static validate = libSchema.compile(this.jsonSchema);
constructor(json = {}) {
// info.json fields
if (json.name) { this.name = json.name; }
if (json.version) { this.version = json.version; }
if (json.title) { this.title = json.title; }
if (json.author) { this.author = json.author; }
if (json.contact) { this.contact = json.contact; }
if (json.homepage) { this.homepage = json.homepage; }
if (json.description) { this.description = json.description; }
if (json.factorio_version) { this.factorioVersion = json.factorio_version; }
if (json.dependencies) { this.dependencies = json.dependencies; }
// Additional data
if (json.size) { this.size = json.size; }
if (json.sha1) { this.sha1 = json.sha1; }
if (json.is_deleted) { this.isDeleted = json.is_deleted; }
}
toJSON() {
let json = {
name: this.name,
version: this.version,
title: this.title,
author: this.author,
};
if (this.contact) { json.contact = this.contact; }
if (this.homepage) { json.homepage = this.homepage; }
if (this.description) { json.description = this.description; }
if (this.factorio_version !== "0.12") { json.factorio_version = this.factorioVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
json.dependencies = this.dependencies;
}
if (this.size) { json.size = this.size; }
if (this.sha1) { json.sha1 = this.sha1; }
if (this.isDeleted) { json.is_deleted = this.isDeleted; }
return json;
}
static async fromModFile(modPath) {
let modInfo;
{
// XXX: JSZip needs the whole archive loaded in memory to work.
// This is clearly untenable and will be replaced later.
let zip = await JSZip.loadAsync(await fs.readFile(modPath));
let root = zip.folder(findRoot(zip));
let infoFile = root.file("info.json");
if (!infoFile) {
throw new Error("Mod contains no info.json file");
}
modInfo = JSON.parse(await infoFile.async("string"));
}
let valid = this.validateInfo(modInfo);
if (!valid) {
throw new Error("Mod's info.json is not valid");
}
if (!modVersionRegExp.test(modInfo.version)) {
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
}
return new this({
...modInfo,
size: (await fs.stat(modPath)).size,
sha1: await libHash.hashFile(modPath),
is_deleted: false,
});
}
}
module.exports = ModInfo;
+355
View File
@@ -0,0 +1,355 @@
"use strict";
const zlib = require("zlib");
const libSchema = require("../schema");
const ExportManifest = require("./ExportManifest");
const { integerFactorioVersion } = require("./version");
/**
* A setting for a mod.
* @typedef {object} module:lib/data.ModPack~ModSetting
* @property {boolean|number|string} value - Value of the given mod setting.
*/
const ModSettingJsonSchema = {
type: "object",
additionalProperties: false,
required: ["value"],
properties: {
"value": { type: ["boolean", "number", "string"] },
},
};
const ModSettingsJsonSchema = {
type: "object",
additionalProperties: ModSettingJsonSchema,
};
/**
* A mod in a mod pack
* @typedef {object} module:lib/data.ModPack~ModRecord
* @property {string} name - name of the mod.
* @property {boolean} enabled - if mod is to be loaded.
* @property {string} version - version of the mod.
* @property {string=} sha1 - SHA1 hash of the zip file.
*/
const ModRecordJsonSchema = {
type: "object",
required: ["name", "enabled", "version"],
properties: {
"name": { type: "string" },
"enabled": { type: "boolean" },
"version": { type: "string" },
"sha1": { type: "string" },
},
};
/**
* Factorio Mod Pack
*
* Tracks mods and settings for a collection of Factorio mods.
* @alias module:lib/data.ModPack
*/
class ModPack {
/**
* Id of this mod pack
* @type {number}
*/
id;
/**
* Name of this mod pack.
* @type {string}
*/
name = "";
/**
* Description for this modpcak.
* @type {string}
*/
description = "";
/**
* Version of Factorio this mod pack is for
* @type {string}
*/
factorioVersion = "1.1.0";
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
}
/**
* Mods included in this mod pack
* @type {Map<string, module:lib/data.ModPack~ModRecord>}
*/
mods = new Map();
/**
* Mod settings for this mod pack
* @type {Object<string, Map<string, module:lib/data.ModPack~ModSetting>>=}
*/
settings = {
"startup": new Map(),
"runtime-global": new Map(),
"runtime-per-user": new Map(),
};
/**
* Mapping to files containing exported data for this modpack
* @type {module:lib/data.ExportManifest|undefined}
*/
exportManifest;
/**
* True if this mod pack has been deleted from the list of mod packs.
* @type {boolean}
*/
isDeleted = false;
static jsonSchema = {
type: "object",
additionalProperties: false,
required: ["name", "description", "factorio_version", "mods", "settings"],
properties: {
"id": { type: "integer" },
"name": { type: "string" },
"description": { type: "string" },
"factorio_version": { type: "string" },
"mods": {
type: "array",
items: ModRecordJsonSchema,
},
"settings": {
type: "object",
additionalProperties: false,
required: ["startup", "runtime-global", "runtime-per-user"],
properties: {
"startup": ModSettingsJsonSchema,
"runtime-global": ModSettingsJsonSchema,
"runtime-per-user": ModSettingsJsonSchema,
},
},
"export_manifest": ExportManifest.jsonSchema,
"is_deleted": { type: "boolean" },
},
};
static validate = libSchema.compile(this.jsonSchema);
constructor(json = {}) {
if (json.id) {
this.id = json.id;
} else {
this.id = Math.random() * 2**31 | 0;
}
if (json.name) { this.name = json.name; }
if (json.description) { this.description = json.description; }
if (json.factorio_version) { this.factorioVersion = json.factorio_version; }
if (json.mods) { this.mods = new Map(json.mods.map(m => [m.name, m])); }
if (json.settings) {
this.settings = {
"startup": new Map(Object.entries(json.settings["startup"])),
"runtime-global": new Map(Object.entries(json.settings["runtime-global"])),
"runtime-per-user": new Map(Object.entries(json.settings["runtime-per-user"])),
};
}
if (json.export_manifest) { this.exportManifest = new ExportManifest(json.export_manifest); }
if (json.is_deleted) { this.isDeleted = json.is_deleted; }
if (!this.mods.has("base")) {
this.mods.set("base", { name: "base", enabled: true, version: this.factorioVersion });
}
}
toJSON() {
let json = {
id: this.id,
name: this.name,
description: this.description,
factorio_version: this.factorioVersion,
mods: [...this.mods.values()],
settings: {
"startup": Object.fromEntries(this.settings["startup"]),
"runtime-global": Object.fromEntries(this.settings["runtime-global"]),
"runtime-per-user": Object.fromEntries(this.settings["runtime-per-user"]),
},
};
if (this.exportManifest) { json.export_manifest = this.exportManifest.toJSON(); }
if (this.isDeleted) { json.is_deleted = this.isDeleted; }
return json;
}
toModPackString() {
const json = this.toJSON();
delete json.id;
delete json.export_manifest;
// eslint-disable-next-line node/no-sync
let buf = zlib.deflateSync(JSON.stringify(json));
return buf.toString("base64");
}
static fromModPackString(modPackString) {
let buf = Buffer.from(modPackString, "base64");
try {
// eslint-disable-next-line node/no-sync
buf = zlib.inflateSync(buf);
} catch (err) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed mod pack string: zlib inflate failed");
}
}
let json;
try {
json = JSON.parse(buf);
} catch (err) {
throw new Error(`Malformed mod pack string: ${err.message}`);
}
if (!this.validate(json)) {
throw new Error("Malformed mod pack string: Schema validation failed");
}
return new this(json);
}
toModSettingsDat() {
function uint8Byte(int) {
return Buffer.from(Uint8Array.from([int]).buffer);
}
function int16Bytes(int) {
return Buffer.from(Int16Array.from([int]).buffer);
}
function uint32Bytes(int) {
return Buffer.from(Uint32Array.from([int]).buffer);
}
function uint32SpaceOptimizedBytes(int) {
if (0 <= int && int < 0xff) {
return uint8Byte(int);
}
return Buffer.concat([
uint8Byte(0xff),
uint32Bytes(int),
]);
}
function doubleBytes(double) {
return Buffer.from(Float64Array.from([double]).buffer);
}
function stringBytes(str) {
const buf = Buffer.from(str, "utf8");
return Buffer.concat([
uint32SpaceOptimizedBytes(buf.length),
buf,
]);
}
function immutableStringBytes(str) {
if (str === undefined) {
return uint8Byte(1); // empty
}
return Buffer.concat([
uint8Byte(0), // empty
stringBytes(str),
]);
}
function versionBytes(version) {
const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
return Buffer.concat([
int16Bytes(main),
int16Bytes(major),
int16Bytes(minor),
Buffer.alloc(2), // developer
Buffer.alloc(1), // reserved byte
]);
}
function propertyTreeListBytes(entries) {
const sizeBytes = uint32Bytes(entries.length);
const itemBytes = entries.flatMap(([key, item]) => [
immutableStringBytes(key),
// eslint-disable-next-line no-use-before-define
propertyTreeBytes(item),
]);
return Buffer.concat([
sizeBytes,
...itemBytes,
]);
}
function propertyTreeBytes(element) {
let type;
let dataBytes;
if (typeof element === null) {
type = 1;
dataBytes = Buffer.alloc(0);
} else if (typeof element === "boolean") {
type = 1;
dataBytes = uint8Byte(element);
} else if (typeof element === "number") {
type = 2;
dataBytes = doubleBytes(element);
} else if (typeof element === "string") {
type = 3;
dataBytes = immutableStringBytes(element);
} else if (element instanceof Array) {
type = 4;
dataBytes = propertyTreeListBytes(element.map(item => [undefined, item]));
} else if (typeof element === "object") {
type = 5;
dataBytes = propertyTreeListBytes(Object.entries(element));
} else {
throw new Error("Bad element passed to propertyTreeBytes");
}
return Buffer.concat([
uint8Byte(type),
Buffer.alloc(1), // anyTypeFlag
dataBytes,
]);
}
return Buffer.concat([
versionBytes(this.factorioVersion),
propertyTreeBytes(this.toJSON().settings),
]);
}
/**
* Fill in missing settings with their default values
*
* Uses the provided setting prototypes to add any missing mod settings
* in the mod pack with the default value from the prototype.
*
* @param {Object<string, object>} settingPrototypes -
* Setting prototypes exported from the game.
* @param {Logger} logger - Logger used to report warnings on.
*/
fillDefaultSettings(settingPrototypes, logger) {
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting"];
let prototypes = Object.entries(settingPrototypes)
.filter(([type, _]) => knownTypes.includes(type))
.flatMap(([_, settings]) => Object.values(settings))
;
for (let prototype of prototypes) {
if (!["startup", "runtime-global", "runtime-per-user"].includes(prototype.setting_type)) {
logger.warn(`Ignoring ${prototype.name} with unknown setting_type ${prototype.setting_type}`);
continue;
}
if (prototype.default_value === undefined) {
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
continue;
}
if (this.settings[prototype.setting_type].get(prototype.name)) {
continue;
}
this.settings[prototype.setting_type].set(prototype.name, { value: prototype.default_value });
}
}
}
module.exports = ModPack;
+17
View File
@@ -0,0 +1,17 @@
/**
* Shared data types used in Clusterio
* @module lib/data
* @author Hornwitser
*/
"use strict";
const ExportManifest = require("./ExportManifest");
const ModInfo = require("./ModInfo");
const ModPack = require("./ModPack");
const version = require("./version");
module.exports = {
ExportManifest,
ModInfo,
ModPack,
...version,
};
+44
View File
@@ -0,0 +1,44 @@
// Helper functions for dealing with factorio version numbers
"use strict";
/**
* Integer representation of a mod version
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param {string} version - string representation of version.
* @returns {number} numeric representation.
* @alias module:lib/data.integerModVersion
*/
function integerModVersion(version) {
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits.
}
/**
* Integer representation of a factorio version
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param {string} version - string representation of version.
* @returns {number} numeric representation.
* @alias module:lib/data.integerFactorioVersion
*/
function integerFactorioVersion(version) {
const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits.
}
/**
* Matches valid mod versions.
* @type {RegExp}
* @alias module:lib/data.modVersionRegExp
*/
const modVersionRegExp = /^\d+\.\d+\.\d+$/;
module.exports = {
integerModVersion,
integerFactorioVersion,
modVersionRegExp,
};
+63 -6
View File
@@ -7,6 +7,7 @@ const JSZip = require("jszip");
const libIni = require("../ini");
const libBuildMod = require("../build_mod");
const libZipOps = require("../zip_ops");
/**
* Generate the export mod needed for exportData
@@ -56,7 +57,7 @@ async function loadZip(server, modVersions, mod) {
zipCache.set(zipPath, zip);
}
return zip.folder(`${mod}_${modVersion}`);
return zip.folder(libZipOps.findRoot(zip));
}
/**
@@ -207,6 +208,42 @@ async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
return icon;
}
const itemTypes = new Set([
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"copy-paste-tool",
"deconstruction-item",
"upgrade-item",
"module",
"rail-planner",
"spidertron-remote",
"tool",
"armor",
"mining-tool",
"repair-tool",
// XXX Bad hack to get icons for fluids. These should be treated as a
// separate namespace from items as they may be named the same as items.
"fluid",
// TODO consider adding virtual signals and recipes
]);
function filterItems(prototypes) {
return Object.entries(prototypes)
.filter(([type, _]) => itemTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes))
;
}
/**
* Export item icons and data
*
@@ -348,11 +385,29 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
async function exportData(server) {
await generateExportMod(server);
let items = [];
let settings = {};
let prototypes = {};
let modVersions = new Map();
let modOrder = [];
server.on("ipc-item_export", data => items.push(data));
function add(obj, prototype) {
if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) {
obj[prototype.type] = {};
}
obj[prototype.type][prototype.name] = prototype;
}
server.on("ipc-prototype_export", data => add(prototypes, data));
server.on("ipc-settings_export", data => add(settings, data));
server.on("ipc-mod_setting_mod", ({ name, mod }) => {
for (let type of Object.values(settings)) {
if (Object.prototype.hasOwnProperty.call(type, name)) {
type[name].mod = mod;
return;
}
}
server._logger.error(`Unable to find ${name} in settings prototypes`);
});
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
server.on("output", parsed => {
if (parsed.format === "seconds" && parsed.type === "generic") {
@@ -370,8 +425,8 @@ async function exportData(server) {
await fs.unlink(server.writePath("mods", "export_0.0.0.zip"));
}
if (!items.length) {
throw new Error("No items got exported");
if (!Object.keys(prototypes).length) {
throw new Error("No prototypes got exported");
}
// Some mod authors put leading zeros into the versions of their zip files.
@@ -396,13 +451,15 @@ async function exportData(server) {
}
}
let { iconSheet, itemData } = await exportItems(server, modVersions, items);
let { iconSheet, itemData } = await exportItems(server, modVersions, filterItems(prototypes));
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
zipCache.clear();
let zip = new JSZip();
zip.file("export/settings.json", JSON.stringify(settings));
zip.file("export/prototypes.json", JSON.stringify(prototypes));
zip.file("export/item-spritesheet.png", await iconSheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/item-metadata.json", JSON.stringify([...itemData.entries()]));
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
+1 -37
View File
@@ -8,6 +8,7 @@ const path = require("path");
const semver = require("semver");
const libHash = require("../hash");
const { findRoot } = require("../zip_ops");
const knownScenarios = {
@@ -21,42 +22,6 @@ const knownScenarios = {
},
};
/**
* Returns the root folder in the zip file
*
* Returns the name of the folder that all files in the zip file is
* contained in. Throws an error if there are multiple such folders.
*
* @param {JSZip} zip - Zip to search through.
* @returns {string} name of the root folder.
* @memberof module:lib/factorio
* @private
* @inner
*/
function findRoot(zip) {
let root = null;
zip.forEach((relativePath, file) => {
let index = relativePath.indexOf("/");
if (index === -1) {
throw new Error(`Zip contains file '${relativePath}' in root dir`);
}
let pathRoot = relativePath.slice(0, index);
if (root === null) {
root = pathRoot;
} else if (root !== pathRoot) {
throw new Error("Zip contains multiple root folders");
}
});
if (root === null) {
throw new Error("Empty zip file");
}
return root;
}
/**
* Generates control.lua code for loading the Clusterio modules
*
@@ -295,7 +260,6 @@ module.exports = {
patch,
// For testing only
_findRoot: findRoot,
_generateLoader: generateLoader,
_reorderDependencies: reorderDependencies,
};
+85
View File
@@ -108,9 +108,94 @@ async function safeOutputFile(file, data, options={}) {
await fs.rename(temporary, file);
}
// Reserved names by allmost all filesystems
const badNames = [".", ".."];
// Reserved namespaces in Windows
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const badWinNamespaces = [
"CON", "PRN", "AUX", "NUL",
...oneToNine.map(n => `COM${n}`),
...oneToNine.map(n => `LPT${n}`),
];
/**
* Check if a string is a valid file name
*
* @param {string} name - Name to check
* @throws Error if the name is unsuitable.
*/
function checkFilename(name) {
// All of these are bad in Windows only, except for /, . and ..
// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
if (typeof name !== "string") {
throw new Error("must be a string");
}
if (name === "") {
throw new Error("cannot be empty");
}
if (badChars.test(name)) {
throw new Error('cannot contain <>:"\\/|=* or control characters');
}
if (badNames.includes(name)) {
throw new Error(
`cannot be named ${name}`
);
}
if (badWinNamespaces.includes(name.toUpperCase().split(".")[0])) {
throw new Error(
"cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9"
);
}
if (badEnd.test(name)) {
throw new Error("cannot end with . or space");
}
}
/**
* Clean up string to be suitable for use as filename
*
* @param {string} name - Arbitrary name string
* @returns {string} Filename suitable to use in the filesystem
*/
function cleanFilename(name) {
// copied from checkFilename due to RegExp with global flag containing state.
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
if (typeof name !== "string") {
throw new Error("name must be a string");
}
if (name === "" || badNames.includes(name.toUpperCase())) {
name += "_";
}
if (badWinNamespaces.includes(name.toUpperCase().split(".")[0])) {
name = [`${name.split(".")[0]}_`, ...name.split(".").slice(1)].join(".");
}
name = name.replace(badChars, "_");
name = name.replace(badEnd, "_");
return name;
}
module.exports = {
getNewestFile,
findUnusedName,
getTempFile,
safeOutputFile,
checkFilename,
cleanFilename,
};
+1 -1
View File
@@ -4,7 +4,7 @@
*/
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const fs = require("fs-extra");
/**
+190
View File
@@ -12,6 +12,7 @@
*
* @param {*} value - value to return the type of.
* @returns {string} basic type of the value passed.
* @static
*/
function basicType(value) {
if (value === null) { return "null"; }
@@ -24,6 +25,7 @@ function basicType(value) {
* Asynchronously wait for the given duration
*
* @param {number} duration - Time to wait for in milliseconds.
* @static
*/
async function wait(duration) {
await new Promise(resolve => { setTimeout(resolve, duration); });
@@ -36,6 +38,7 @@ async function wait(duration) {
* @param {Promise} promise - Promise to wait for.
* @param {number} time - Maximum time im milliseconds to wait for.
* @param {*=} timeoutResult - Value to return if the operation timed out.
* @static
*/
async function timeout(promise, time, timeoutResult) {
let timer;
@@ -61,6 +64,7 @@ async function timeout(promise, time, timeoutResult) {
*
* @param {Readable} stream - byte stream to read to the end.
* @returns {Buffer} content of the stream.
* @static
*/
async function readStream(stream) {
let chunks = [];
@@ -85,11 +89,194 @@ async function readStream(stream) {
*
* @param {string} text - Text to escape RegExp meta chars in.
* @returns {string} escaped text.
* @static
*/
function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
/**
* Format byte count for human readable display
*
* Shortens a large number of bytes using the kB/MB/GB/TB prefixes.
* @param {number} bytes - Count of bytes to format.
* @returns {string} formatted text.
* @static
*/
function formatBytes(bytes) {
if (bytes === 0) {
return "0\u{A0}Bytes"; // No-break space
}
let units = ["\u{A0}Bytes", "\u{A0}kB", "\u{A0}MB", "\u{A0}GB", "\u{A0}TB"];
let factor = 1000;
let power = Math.min(Math.floor(Math.log(bytes) / Math.log(factor)), units.length);
return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power];
}
function parseSearchIdentifier(pos, input) {
// character = ? all characters excluding space : " ?
// identifier = 1*character
let startPos = pos;
while (pos < input.length && ![" ", '"', ":"].includes(input.charAt(pos))) {
pos += 1;
}
return [pos, input.slice(startPos, pos)];
}
function parseSearchWord(pos, input) {
// non quote character = ? all characters excluding " ?
// word op = "-"
// word = [ word op ], ( identifier | '"', *non quote character, '"' )
let word = {
type: "word",
};
if (input.charAt(pos) === "-") {
word.exclude = true;
pos += 1;
}
if (input.charAt(pos) === '"') {
pos += 1;
let startPos = pos;
let endPos = pos;
while (pos < input.length && input.charAt(pos) !== '"') {
pos += 1;
endPos += 1;
}
if (input.charAt(pos) === '"') {
pos += 1;
}
word.value = input.slice(startPos, endPos);
} else {
([pos, word.value] = parseSearchIdentifier(pos, input));
}
return [pos, word];
}
function parseSearchTerm(pos, input, attributes, issues) {
// attribute = identifier, ':', word
// term = word | attribute
let term;
if (["-", '"'].includes(input.charAt(pos))) {
([pos, term] = parseSearchWord(pos, input));
} else {
let identifier;
([pos, identifier] = parseSearchIdentifier(pos, input));
if (input.charAt(pos) === ":") {
pos += 1;
identifier = identifier.toLowerCase();
if (!Object.prototype.hasOwnProperty.call(attributes, identifier)) {
issues.push(`Unregonized attribute "${identifier}", use quotes to escape colons`);
} else if (attributes[identifier] === "word") {
let value;
([pos, value] = parseSearchWord(pos, input));
term = { type: "attribute", name: identifier, value };
} else {
throw new Error(`Bad attribute format ${attributes[identifier]} for ${identifier}`);
}
} else {
term = { type: "word", value: identifier };
}
}
return [pos, term];
}
/**
* Match a parsed word term with the given texts.
*
* Returns true if the passed word term in the search matches at least one
* of the passed text snippets. If the word mode is exclude then returns
* true if none of the passed text snippets match.
*
* @param {lib/helpers.ParsedTerm} word - Word to match.
* @param {...string} texts - Text to match word in.
* @returns {boolean} true if the word matches.
*/
function wordMatches(word, ...texts) {
if (word.type !== "word") {
throw Error("wordMatches: parameter is not a word");
}
let matches = false;
for (let text of texts) {
if (text.includes(word.value)) {
matches = true;
break;
}
}
if (word.exclude) {
matches = !matches;
}
return matches;
}
/**
* @typedef {object} module:lib/helpers~ParsedTerm
* @property {string} type - Type of term, either attribute or word.
* @property {string=} name - attribute only: Name of attribute.
* @property {boolean=} exclude - word only: exclude results with this word.
* @property {string | module:lib/helpers.ParsedTerm} value -
* Parsed value of this term. Is a string if type is word, and a
* ParsedTerm if type is attribute.
*/
/**
* @typedef {object} module:lib/helpers~ParsedSearch
* @property {Array<module:lib/helpers~ParsedTerm>} terms -
* Parsed result of search terms.
* @property {Array<string>} issues -
* Issues detected while parsing the seach string.
*/
/**
* Parse a search string with optional attributes
*
* Parses the given input as a search expression consisting of space
* delimited words and attributes:word pairs. For aattributes to be
* recognized they need to be passed as name: "word" in the attributes
* parameter.
*
* Words in the search expression can optionally be prefixed by a - to
* search for results that does not contain that word. E.g. -author contains
* all results not matching author.
*
* If parsing fails an string describing the problem will be added to the
* issues array returned and parsing will resume at some arbitrary point.
* The issues should be shown to the end user so that they can correct their
* search.
*
* @param {string} input - Search expression to parse.
* @param {Object<string, string>} attributes -
* Recognized attributes and their format. Currently only word is
* supported.
* @returns {module:lib/helpers~ParsedSearch} parsed terms of the search.
* @static
*/
function parseSearchString(input, attributes = {}) {
// whitespace = 1*" "
// search = [ term, *( [ whitespace ], term ) ]
input = input.trim();
let parsed = {
terms: [],
issues: [],
};
let pos = 0;
while (pos < input.length) {
let term;
([pos, term] = parseSearchTerm(pos, input, attributes, parsed.issues));
if (term) {
parsed.terms.push(term);
}
while (pos < input.length && input.charAt(pos) === " ") {
pos += 1;
}
}
// istanbul ignore if (should not be possible)
if (pos !== input.length) {
throw new Error(`parse search ended at ${pos} which is not the end of the input (${input.length})`);
}
return parsed;
}
module.exports = {
basicType,
@@ -97,4 +284,7 @@ module.exports = {
timeout,
readStream,
escapeRegExp,
formatBytes,
wordMatches,
parseSearchString,
};
+4
View File
@@ -2,6 +2,7 @@
const libBuildMod = require("./build_mod");
const libCommand = require("./command");
const libConfig = require("./config");
const libData = require("./data");
const libDatabase = require("./database");
const libErrors = require("./errors");
const libFactorio = require("./factorio");
@@ -18,6 +19,7 @@ const libPrometheus = require("./prometheus");
const libSchema = require("./schema");
const libSharedCommands = require("./shared_commands");
const libUsers = require("./users");
const libZipOps = require("./zip_ops");
const ExponentialBackoff = require("./ExponentialBackoff");
const RateLimiter = require("./RateLimiter");
@@ -27,6 +29,7 @@ module.exports = {
libBuildMod,
libCommand,
libConfig,
libData,
libDatabase,
libErrors,
libFactorio,
@@ -43,6 +46,7 @@ module.exports = {
libSchema,
libSharedCommands,
libUsers,
libZipOps,
ExponentialBackoff,
RateLimiter,
+190 -1
View File
@@ -1,8 +1,9 @@
// Message definitions for links
"use strict";
const libSchema = require("../schema");
const libData = require("../data");
const libErrors = require("../errors");
const { logger } = require("../logging");
const libSchema = require("../schema");
const PlayerStats = require("../PlayerStats");
class MissingLinkHandlerError extends Error {
@@ -709,6 +710,178 @@ messages.sendRcon = new Request({
},
});
messages.getModPack = new Request({
type: "get_mod_pack",
links: ["control-master", "instance-slave", "slave-master"],
permission: "core.mod_pack.get",
forwardTo: "master",
requestProperties: {
"id": { type: "integer" },
},
responseProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.getDefaultModPack = new Request({
type: "get_default_mod_pack",
links: ["control-master", "instance-slave", "slave-master"],
permission: "core.mod_pack.get",
forwardTo: "master",
responseProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.listModPacks = new Request({
type: "list_mod_packs",
links: ["control-master"],
permission: "core.mod_pack.list",
responseProperties: {
"list": {
type: "array",
items: libData.ModPack.jsonSchema,
},
},
});
messages.createModPack = new Request({
type: "create_mod_pack",
links: ["control-master"],
permission: "core.mod_pack.create",
requestProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.updateModPack = new Request({
type: "update_mod_pack",
links: ["control-master"],
permission: "core.mod_pack.update",
requestProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.deleteModPack = new Request({
type: "delete_mod_pack",
links: ["control-master"],
permission: "core.mod_pack.delete",
requestProperties: {
"id": { type: "integer" },
},
});
messages.setModPackSubscriptions = new Request({
type: "set_mod_pack_subscriptions",
links: ["control-master"],
permission: "core.mod_pack.subscribe",
requestProperties: {
"all": { type: "boolean" },
"mod_pack_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.getMod = new Request({
type: "get_mod",
links: ["control-master"],
permission: "core.mod.get",
requestProperties: {
"name": { type: "string" },
"version": { type: "string" },
},
responseProperties: {
"mod": libData.ModInfo.jsonSchema,
},
});
messages.listMods = new Request({
type: "list_mods",
links: ["control-master"],
permission: "core.mod.list",
responseProperties: {
"list": {
type: "array",
items: libData.ModInfo.jsonSchema,
},
},
});
messages.searchMods = new Request({
type: "search_mods",
links: ["control-master"],
permission: "core.mod.search",
requestRequired: ["query", "factorio_version", "page"],
requestProperties: {
"query": { type: "string" },
"factorio_version": { type: "string" },
"page_size": { type: "integer" },
"page": { type: "integer" },
"sort": { type: "string" },
"sort_order": { type: "string" },
},
responseProperties: {
"query_issues": {
type: "array",
items: { type: "string" },
},
"page_count": { type: "integer" },
"result_count": { type: "integer" },
"results": {
type: "array",
items: {
type: "object",
properties: {
"name": { type: "string" },
"versions": {
type: "array",
items: libData.ModInfo.jsonSchema,
},
},
},
},
},
});
messages.setModSubscriptions = new Request({
type: "set_mod_subscriptions",
links: ["control-master"],
permission: "core.mod.subscribe",
requestProperties: {
"all": { type: "boolean" },
"mod_names": {
type: "array",
items: { type: "string" },
},
},
});
messages.downloadMod = new Request({
type: "download_mod",
links: ["control-master"],
permission: "core.mod.download",
requestProperties: {
"name": { type: "string" },
"version": { type: "string" },
},
responseProperties: {
"stream_id": { type: "string" },
},
});
messages.deleteMod = new Request({
type: "delete_mod",
links: ["control-master"],
permission: "core.mod.delete",
requestProperties: {
"name": { type: "string" },
"version": { type: "string" },
},
});
messages.listPermissions = new Request({
type: "list_permissions",
links: ["control-master"],
@@ -1263,6 +1436,22 @@ messages.saveListUpdate = new Event({
},
});
messages.modPackUpdate = new Event({
type: "mod_pack_update",
links: ["master-control"],
eventProperties: {
"mod_pack": libData.ModPack.jsonSchema,
},
});
messages.modUpdate = new Event({
type: "mod_update",
links: ["master-control"],
eventProperties: {
"mod": libData.ModInfo.jsonSchema,
},
});
messages.userUpdate = new Event({
type: "user_update",
links: ["master-control"],
+25 -1
View File
@@ -479,7 +479,6 @@ class BaseMasterPlugin {
*/
onControlConnectionEvent(connection, event) { }
/**
* Called when a slave is preparing to disconnect from the master
*
@@ -494,6 +493,31 @@ class BaseMasterPlugin {
*/
async onPrepareSlaveDisconnect(connection) { }
/**
* Called when a mod pack is updated
*
* Invoked when a mod pack has been added, updated or deleted in the
* cluster.
*
* If the mod pack has been deleted its `.isDeleted` property will be
* true.
*
* @param {module:lib/data.ModPack} modPack - Mod pack that updated.
*/
async onModPackUpdated(modPack) { }
/**
* Called when a mod stored on the master server is updated
*
* Invoked when a mod has been added, updated or deleted from the pool
* of shared mods stored on the cluster.
*
* If a mod has been deleted its `.isDeleted` property will be true.
*
* @param {module:lib/data.ModInfo} mod - Mod that updated.
*/
async onModUpdated(mod) { }
/**
* Called when a player joins or leaves an instance
*
+74
View File
@@ -511,6 +511,80 @@ definePermission({
description: "Send arbitrary RCON commands to instances.",
});
definePermission({
name: "core.mod_pack.get",
title: "Retrieve mod pack",
description: "Get the details of a mod pack in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.mod_pack.list",
title: "List mod packs",
description: "Get the full list of a mod packs in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.mod_pack.subscribe",
title: "Subscribe to mod pack updates",
description: "Subscribe to be notified on updates on the details of mod packs.",
grantByDefault: true,
});
definePermission({
name: "core.mod_pack.create",
title: "Create mod pack",
description: "Create new mod pack in the cluster.",
});
definePermission({
name: "core.mod_pack.update",
title: "Update mod pack",
description: "Edit existing mod pack definition in the cluster.",
});
definePermission({
name: "core.mod_pack.delete",
title: "Delete mod pack",
description: "Delete mod pack definition in the cluster.",
});
definePermission({
name: "core.mod.get",
title: "Get mods",
description: "Get the details of a mod stored in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.mod.list",
title: "List mods",
description: "Get the full list of mods stored on the master.",
grantByDefault: true,
});
definePermission({
name: "core.mod.search",
title: "Search mods",
description: "Search through the list of mods stored on the master.",
grantByDefault: true,
});
definePermission({
name: "core.mod.subscribe",
title: "Subscribe to mod updates",
description: "Subscribe to be notified on updates on the details of mods.",
grantByDefault: true,
});
definePermission({
name: "core.mod.upload",
title: "Upload mod",
description: "Upload mods to the master.",
});
definePermission({
name: "core.mod.download",
title: "Download mod",
description: "Download mods stored on the master.",
grantByDefault: true,
});
definePermission({
name: "core.mod.delete",
title: "Delete mod",
description: "Delete mods stored on the master.",
});
definePermission({
name: "core.permission.list",
title: "List permissions",
+40
View File
@@ -0,0 +1,40 @@
"use strict";
/**
* Returns the root folder in the zip file
*
* Returns the name of the folder that all files in the zip file is
* contained in. Throws an error if there are multiple such folders.
*
* @param {JSZip} zip - Zip to search through.
* @returns {string} name of the root folder.
* @memberof module:lib/factorio
* @private
* @inner
*/
function findRoot(zip) {
let root = null;
zip.forEach((relativePath, file) => {
let index = relativePath.indexOf("/");
if (index === -1) {
throw new Error(`Zip contains file '${relativePath}' in root dir`);
}
let pathRoot = relativePath.slice(0, index);
if (root === null) {
root = pathRoot;
} else if (root !== pathRoot) {
throw new Error("Zip contains multiple root folders");
}
});
if (root === null) {
throw new Error("Empty zip file");
}
return root;
}
module.exports = {
findRoot,
};
+21
View File
@@ -84,6 +84,27 @@ class BaseConnection extends libLink.Link {
get connected() {
return !this._disconnecting && this.connector.connected;
}
async getModPackRequestHandler(message) {
let { id } = message.data;
let modPack = this._master.modPacks.get(id);
if (!modPack) {
throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`);
}
return { mod_pack: modPack.toJSON() };
}
async getDefaultModPackRequestHandler(message) {
let id = this._master.config.get("master.default_mod_pack_id");
if (id === null) {
throw new libErrors.RequestError("Default mod pack not set on master");
}
let modPack = this._master.modPacks.get(id);
if (!modPack) {
throw new libErrors.RequestError(`Default mod pack configured (${id}) does not exist`);
}
return { mod_pack: modPack.toJSON() };
}
}
module.exports = BaseConnection;
+202 -1
View File
@@ -1,9 +1,13 @@
"use strict";
const jwt = require("jsonwebtoken");
const events = require("events");
const fs = require("fs-extra");
const jwt = require("jsonwebtoken");
const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libData = require("@clusterio/lib/data");
const libErrors = require("@clusterio/lib/errors");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
@@ -14,6 +18,8 @@ const libUsers = require("@clusterio/lib/users");
const BaseConnection = require("./BaseConnection");
const routes = require("./routes");
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const lastQueryLogTime = new libPrometheus.Gauge(
"clusterio_master_last_query_log_duration_seconds",
"Time in seconds the last log query took to execute."
@@ -53,6 +59,16 @@ class ControlConnection extends BaseConnection {
instance_ids: [],
};
this.modPackSubscriptions = {
all: false,
mod_pack_ids: [],
};
this.modSubscriptions = {
all: false,
mod_names: [],
};
this.userSubscriptions = {
all: false,
names: [],
@@ -458,11 +474,196 @@ class ControlConnection extends BaseConnection {
return { save: result.save };
}
async listModPacksRequestHandler(message) {
return { list: [...this._master.modPacks.values()].map(pack => pack.toJSON()) };
}
async setModPackSubscriptionsRequestHandler(message) {
this.modPackSubscriptions = message.data;
}
async createModPackRequestHandler(message) {
let modPack = new libData.ModPack(message.data.mod_pack);
if (this._master.modPacks.has(modPack.id)) {
throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} already exist`);
}
this._master.modPacks.set(modPack.id, modPack);
this._master.modPackUpdated(modPack);
}
async updateModPackRequestHandler(message) {
let modPack = new libData.ModPack(message.data.mod_pack);
if (!this._master.modPacks.has(modPack.id)) {
throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
}
this._master.modPacks.set(modPack.id, modPack);
this._master.modPackUpdated(modPack);
}
async deleteModPackRequestHandler(message) {
let { id } = message.data;
let modPack = this._master.modPacks.get(id);
if (!modPack) {
throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`);
}
modPack.isDeleted = true;
this._master.modPacks.delete(id);
this._master.modPackUpdated(modPack);
}
modPackUpdated(modPack) {
if (
this.modPackSubscriptions.all
|| this.modPackSubscriptions.mod_pack_ids.includes(modPack.id)
) {
libLink.messages.modPackUpdate.send(this, { mod_pack: modPack.toJSON() });
}
}
async getModRequestHandler(message) {
let { name, version } = message.data;
let filename = `${name}_${version}.zip`;
let mod = this._master.mods.get(filename);
if (!mod) {
throw new libErrors.RequestError(`Mod ${filename} does not exist`);
}
return {
mod: mod.toJSON(),
};
}
async listModsRequestHandler(message) {
return { list: [...this._master.mods.values()].map(mod => mod.toJSON()) };
}
static termsMatchesMod(terms, mod) {
for (let term of terms) {
if (term.type === "word") {
if (!libHelpers.wordMatches(term,
mod.name, mod.version, mod.title, mod.author, mod.contact,
mod.homepage, mod.description, mod.filename
)) {
return false;
}
} else if (term.type === "attribute") {
if (!libHelpers.wordMatches(term.value, mod[term.name])) {
return false;
}
}
}
return true;
}
async searchModsRequestHandler(message) {
let query = libHelpers.parseSearchString(message.data.query, {
name: "word",
// version
title: "word",
author: "word",
contact: "word",
homepage: "word",
description: "word",
// factorioVersion
// dependencies
filename: "word",
// size
sha1: "word",
});
let factorioVersion = message.data.factorio_version;
let results = new Map();
for (let mod of this._master.mods.values()) {
if (
mod.factorioVersion !== factorioVersion
|| !ControlConnection.termsMatchesMod(query.terms, mod)
) {
continue;
}
let result = results.get(mod.name);
if (!result) {
result = {
name: mod.name,
versions: [],
};
results.set(mod.name, result);
}
result.versions.push(mod);
}
for (let result of results.values()) {
result.versions.sort((a, b) => b.integerVersion - a.integerVersion);
result.versions.map(e => e.toJSON());
}
let resultList = [...results.values()];
const sort = message.data.sort;
if (sort) {
const sorters = {
name: (a, b) => strcmp(a.versions[0].name, b.versions[0].name),
title: (a, b) => strcmp(a.versions[0].title, b.versions[0].title),
author: (a, b) => strcmp(a.versions[0].author, b.versions[0].author),
};
if (!Object.prototype.hasOwnProperty.call(sorters, sort)) {
throw new libErrors.RequestError(`Invalid value for sort: ${sort}`);
}
resultList.sort(sorters[sort]);
let order = message.data.sort_order;
if (order === "desc") {
resultList.reverse();
}
}
const page = message.data.page;
const pageSize = message.data.page_size || 10;
resultList = resultList.slice((page - 1) * pageSize, page * pageSize);
return {
query_issues: query.issues,
page_count: Math.ceil(results.size / pageSize),
result_count: results.size,
results: resultList,
};
}
async setModSubscriptionsRequestHandler(message) {
this.modSubscriptions = message.data;
}
async downloadModRequestHandler(message) {
let { name, version } = message.data;
let filename = `${name}_${version}.zip`;
let mod = this._master.mods.get(filename);
if (!mod) {
throw new libErrors.RequestError(`Mod ${filename} does not exist`);
}
let modPath = path.join(this._master.config.get("master.mods_directory"), mod.filename);
let stream = await routes.createProxyStream(this._master.app);
stream.filename = mod.filename;
stream.source = fs.createReadStream(modPath);
stream.mime = "application/zip";
stream.size = mod.size;
return { stream_id: stream.id };
}
async deleteModRequestHandler(message) {
await this._master.deleteMod(message.data.name, message.data.version);
}
async setLogSubscriptionsRequestHandler(message) {
this.logSubscriptions = message.data;
this.updateLogSubscriptions();
}
modUpdated(mod) {
if (
this.modSubscriptions.all
|| this.modSubscriptions.mod_names.includes(mod.name)
) {
libLink.messages.modUpdate.send(this, { mod: mod.toJSON() });
}
}
updateLogSubscriptions() {
let { all, master, slave_ids, instance_ids } = this.logSubscriptions;
if (all || master || slave_ids.length || instance_ids.length) {
+108 -7
View File
@@ -9,6 +9,7 @@ const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libData = require("@clusterio/lib/data");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libLink = require("@clusterio/lib/link");
@@ -65,6 +66,18 @@ class Master {
*/
this.instances = null;
/**
* Mapping of mod pack id to mod pack
* @type {Map<number, module:lib/data.ModPack>}
*/
this.modPacks = null;
/**
* Mapping of mod names to mod infos
* @type {Map<string, module:lib/data.ModInfo>}
*/
this.mods = null;
/**
* User and roles manager for the cluster
* @type {module:master/src/UserManager}
@@ -161,9 +174,13 @@ class Master {
this.slaves = await Master.loadSlaves(path.join(databaseDirectory, "slaves.json"));
this.instances = await Master.loadInstances(path.join(databaseDirectory, "instances.json"));
this.modPacks = await Master.loadModPacks(path.join(databaseDirectory, "mod-packs.json"));
this.userManager = new UserManager(this.config);
await this.userManager.load(path.join(databaseDirectory, "users.json"));
this.exportManifest = await Master.loadJsonObject(path.join(databaseDirectory, "export_manifest.json"));
let modsDirectory = this.config.get("master.mods_directory");
await fs.ensureDir(modsDirectory);
this.mods = await Master.loadModInfos(modsDirectory);
this.config.on("fieldChanged", (group, field, prev) => {
libPlugin.invokeHook(this.plugins, "onMasterConfigFieldChanged", group, field, prev);
@@ -284,6 +301,10 @@ class Master {
await Master.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances);
}
if (this.modPacks) {
await Master.saveModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks);
}
if (this.userManager) {
await this.userManager.save(path.join(databaseDirectory, "users.json"));
}
@@ -360,6 +381,57 @@ class Master {
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
static async loadModPacks(filePath) {
let json;
try {
json = JSON.parse(await fs.readFile(filePath));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
}
return new Map(json.map(e => [e.id, new libData.ModPack(e)]));
}
static async saveModPacks(filePath, modPacks) {
await libFileOps.safeOutputFile(filePath, JSON.stringify([...modPacks.values()], null, 4));
}
static async loadModInfos(modsDirectory) {
let mods = new Map();
for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) {
if (entry.isDirectory()) {
logger.warn(`Ignoring ${entry.name}${path.sep} in mods directory.`);
continue;
}
if (entry.name.toLowerCase().endsWith("tmp.zip")) {
continue;
}
logger.info(`Loading info for Mod ${entry.name}`);
let modInfo;
try {
modInfo = await libData.ModInfo.fromModFile(path.join(modsDirectory, entry.name));
} catch (err) {
logger.error(`Error loading mod ${entry.name}: ${err.message}`);
continue;
}
if (modInfo.filename !== entry.name) {
logger.error(
`Mod ${entry.name}'s filename does not match the expected ` +
`name ${modInfo.filename}, it will be ignored.`
);
continue;
}
mods.set(modInfo.filename, modInfo);
}
return mods;
}
static async loadJsonObject(filePath, throwOnMissing = false) {
let manifest = {};
try {
@@ -372,12 +444,18 @@ class Master {
return manifest;
}
async updateExportManifest(newManifest) {
let databaseDirectory = this.config.get("master.database_directory");
await libFileOps.safeOutputFile(
path.join(databaseDirectory, "export_manifest.json"), JSON.stringify(newManifest, null, 4)
);
this.exportManifest = newManifest;
async deleteMod(name, version) {
let filename = `${name}_${version}.zip`;
let mod = this.mods.get(filename);
if (!mod) {
throw new Error(`Mod ${filename} does not exist`);
}
let modsDirectory = this.config.get("master.mods_directory");
await fs.unlink(path.join(modsDirectory, filename));
this.mods.delete(filename);
mod.isDeleted = true;
this.modUpdated(mod);
}
/**
@@ -503,6 +581,29 @@ class Master {
}
}
modPackUpdated(modPack) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.modPackUpdated(modPack);
}
libPlugin.invokeHook(this.plugins, "onModPackUpdated", modPack);
}
modUpdated(mod) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.modUpdated(mod);
}
libPlugin.invokeHook(this.plugins, "onModUpdated", mod);
}
userUpdated(user) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
+191 -10
View File
@@ -2,11 +2,14 @@
const Busboy = require("busboy");
const crypto = require("crypto");
const events = require("events");
const fs = require("fs-extra");
const JSZip = require("jszip");
const jwt = require("jsonwebtoken");
const path = require("path");
const nodeStream = require("stream");
const util = require("util");
const libData = require("@clusterio/lib/data");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libHash = require("@clusterio/lib/hash");
@@ -18,6 +21,8 @@ const { logger } = require("@clusterio/lib/logging");
const { endpointHitCounter } = require("./metrics");
const finished = util.promisify(nodeStream.finished);
// Merges samples from sourceResult to destinationResult
function mergeSamples(destinationResult, sourceResult) {
@@ -182,6 +187,21 @@ async function uploadExport(req, res) {
return;
}
if (typeof req.query.mod_pack_id !== "string") {
res.sendStatus(400);
return;
}
let modPackId = Number.parseInt(req.query.mod_pack_id, 10);
if (!Number.isInteger(modPackId)) {
res.sendStatus(400);
return;
}
let modPack = res.app.locals.master.modPacks.get(modPackId);
if (!modPack) {
res.sendStatus(400);
return;
}
let data = [];
for await (let chunk of req) {
data.push(chunk);
@@ -192,33 +212,38 @@ async function uploadExport(req, res) {
// This is hardcoded to prevent path expansion attacks
let exportFiles = [
"export/settings.json",
"export/prototypes.json",
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
];
let manifest = {};
let assets = {};
let settingPrototypes = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
continue;
}
if (filePath === "export/settings.json") {
settingPrototypes = JSON.parse(await file.async("text"));
}
let { name, ext } = path.posix.parse(filePath);
let hash = await libHash.hashStream(file.nodeStream());
manifest[name] = `static/${name}.${hash}${ext}`;
assets[name] = `${name}.${hash}${ext}`;
await libFileOps.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
await res.app.locals.master.updateExportManifest(manifest);
modPack.exportManifest = new libData.ExportManifest({ assets });
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.master.modPackUpdated(modPack);
res.sendStatus(200);
}
async function getExportManifest(req, res) {
endpointHitCounter.labels(req.route.path).inc();
res.json(res.app.locals.master.exportManifest);
}
async function createProxyStream(app) {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let id = (await asyncRandomBytes(8)).toString("hex");
@@ -306,6 +331,8 @@ const zipMimes = [
"application/x-zip-compressed",
];
const contentTypeRegExp = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/;
async function uploadSave(req, res) {
try {
res.locals.user.checkPermission("core.instance.save.upload");
@@ -313,9 +340,10 @@ async function uploadSave(req, res) {
res.status(403).json({ request_errors: [err.message] });
return;
}
endpointHitCounter.labels(req.route.path).inc();
let contentType = req.get("Content-Type");
let match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/.exec(contentType);
let match = contentTypeRegExp.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
@@ -436,6 +464,154 @@ async function uploadSave(req, res) {
res.json({ saves });
}
function checkModName(name) {
try {
libFileOps.checkFilename(name);
} catch (err) {
throw new libErrors.RequestError(`Mod name ${err.message}`);
}
}
async function uploadMod(req, res) {
try {
res.locals.user.checkPermission("core.mod.upload");
} catch (err) {
res.status(403).json({ request_errors: [err.message] });
return;
}
endpointHitCounter.labels(req.route.path).inc();
let contentType = req.get("Content-Type");
let match = contentTypeRegExp.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
let mimeType = match[1].toLowerCase();
let tasks = [];
let errors = [];
let requestErrors = [];
let mods = [];
async function handleFile(stream, filename) {
try {
checkModName(filename);
} catch (err) {
logger.error(`Error uploading mod: ${err.message}`);
errors.push(err.message);
stream.resume();
return;
}
const modsDirectory = req.app.locals.master.config.get("master.mods_directory");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
try {
let writeStream;
while (true) {
try {
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
tempFilename = await libFileOps.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.pipe(writeStream);
await finished(writeStream);
const modInfo = await libData.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
req.app.locals.master.mods.set(modInfo.filename, modInfo);
req.app.locals.master.modUpdated(modInfo);
mods.push(modInfo.toJSON());
} catch (err) {
logger.error(`Error uploading mod: ${err.message}`);
errors.push(err.message);
stream.resume();
// Attempt to clean up.
writeStream.destroy();
try {
await fs.unlink(path.join(modsDirectory, tempFilename));
} catch (unlinkErr) {
if (unlinkErr.code !== "ENOENT") {
logger.error(`Error removing ${tempFilename}: ${err.message}`);
}
}
}
}
if (mimeType === "multipart/form-data") {
await new Promise(resolve => {
let busboy = new Busboy({ headers: req.headers });
busboy.on("file", (fieldname, stream, filename, transferEncoding, fileMime) => {
if (!zipMimes.includes(fileMime)) {
requestErrors.push("invalid file Content-Type");
}
if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
stream.resume();
return;
}
tasks.push(handleFile(stream, filename));
});
busboy.on("finish", resolve);
busboy.on("error", (err) => {
logger.error(`Error parsing multipart request in upload-mod:\n${err.stack}`);
errors.push(err.message);
});
req.pipe(busboy);
});
} else if (zipMimes.includes(mimeType)) {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
} else if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
req.resume();
} else {
tasks.push(handleFile(req, filename));
}
} else {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
await Promise.all(tasks);
if (errors.length) {
res.status(500);
res.json({ errors, request_errors: requestErrors });
return;
}
if (requestErrors.length) {
res.status(400);
res.json({ request_errors: requestErrors });
return;
}
res.json({ mods });
}
function addRouteHandlers(app) {
app.get("/metrics", (req, res, next) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
@@ -443,13 +619,16 @@ function addRouteHandlers(app) {
validateSlaveToken,
(req, res, next) => uploadExport(req, res).catch(next)
);
app.get("/api/export-manifest", (req, res, next) => getExportManifest(req, res, next).catch(next));
app.put("/api/stream/:id", (req, res, next) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req, res, next) => uploadSave(req, res).catch(next)
);
app.post("/api/upload-mod",
validateUserToken,
(req, res, next) => uploadMod(req, res).catch(next)
);
}
// Routes used in the web interface and served by the master server
@@ -460,6 +639,8 @@ const webRoutes = [
"/slaves/:id/view",
"/instances",
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/users",
"/users/:id/view",
"/roles",
@@ -0,0 +1,73 @@
local function is_array(t)
local count = 0
for k in pairs(t) do
if type(k) ~= "number" then
return false
end
count = count + 1
end
for i = 1, count do
if not t[i] then
return false
end
end
return count ~= 0
end
-- no game.table_to_json here :(
local function table_to_json(data)
if type(data) == "number" then
if data == math.huge then return "1e500" end -- Inf is not representable in JSON, but 1e500 will overflow to it.
if data == -math.huge then return "-1e500" end
if data ~= data then return "0" end -- NaN is not representable in JSON, treat as 0 instead.
return string.format("%.17g", data)
elseif type(data) == "string" then
data = data:gsub('([\x00-\x1f\\"])', function(match)
return "\\u00" .. string.format("%02x", match:byte())
end)
return '"' .. data .. '"'
elseif data == nil then
return "null"
elseif data == true then
return "true"
elseif data == false then
return "false"
elseif type(data) == "table" then
local r
if is_array(data) then
r = {"["}
for i=1, #data do
r[#r+1] = table_to_json(data[i])
r[#r+1] = ","
end
-- overwrite last comma, empty table is an object
r[#r] = "]"
else
r = {"{"}
for k, v in pairs(data) do
r[#r+1] = table_to_json(tostring(k))
r[#r+1] = ":"
r[#r+1] = table_to_json(v)
r[#r+1] = ","
end
r[#r + (r[#r] == "," and 0 or 1)] = "}"
end
return table.concat(r)
end
end
local clusterio_api = {}
function clusterio_api.send_json(channel, data)
data = table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
return clusterio_api
+3
View File
@@ -5,4 +5,7 @@ end
script.on_init(function()
send_json("mod_list", game.active_mods)
for name, mod_setting in pairs(game.mod_setting_prototypes) do
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
end
end)
+5 -101
View File
@@ -1,104 +1,8 @@
local item_types = {
-- Item categories
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"deconstruction-item",
"copy-paste-tool",
"upgrade-item",
"module",
"rail-planner",
"tool",
"armor",
"mining-tool",
"repair-tool",
local clusterio_api = require("clusterio_api")
-- Fluids
"fluid",
}
local function is_array(t)
local count = 0
for k in pairs(t) do
if type(k) ~= "number" then
return false
end
count = count + 1
end
for i = 1, count do
if not t[i] then
return false
end
end
return count ~= 0
end
-- no game.table_to_json here :(
local function table_to_json(data)
if type(data) == "number" then
if data == math.huge then return "1e500" end -- Inf is not representable in JSON, but 1e500 will overflow to it.
if data == -math.huge then return "-1e500" end
if data ~= data then return "0" end -- NaN is not representable in JSON, treat as 0 instead.
return string.format("%.17g", data)
elseif type(data) == "string" then
data = data:gsub('([\x00-\x1f\\"])', function(match)
return "\\u00" .. string.format("%02x", match:byte())
end)
return '"' .. data .. '"'
elseif data == nil then
return "null"
elseif data == true then
return "true"
elseif data == false then
return "false"
elseif type(data) == "table" then
local r
if is_array(data) then
r = {"["}
for i=1, #data do
r[#r+1] = table_to_json(data[i])
r[#r+1] = ","
end
-- overwrite last comma, empty table is an object
r[#r] = "]"
else
r = {"{"}
for k, v in pairs(data) do
r[#r+1] = table_to_json(tostring(k))
r[#r+1] = ":"
r[#r+1] = table_to_json(v)
r[#r+1] = ","
end
r[#r + (r[#r] == "," and 0 or 1)] = "}"
end
return table.concat(r)
end
end
local function send_json(channel, data)
data = table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
for _, group in ipairs(item_types) do
for _, item in pairs(data.raw[group]) do
send_json("item_export", item)
log("Exporting prototypes")
for group, prototypes in pairs(data.raw) do
for name, prototype in pairs(prototypes) do
clusterio_api.send_json("prototype_export", prototype)
end
end
@@ -0,0 +1,8 @@
local clusterio_api = require("clusterio_api")
log("Exporting settings")
for group, prototypes in pairs(data.raw) do
for name, prototype in pairs(prototypes) do
clusterio_api.send_json("settings_export", prototype)
end
end
+1 -1
View File
@@ -177,7 +177,7 @@ async function startSlave() {
// If we get here the command was run
await fs.ensureDir(slaveConfig.get("slave.instances_directory"));
await fs.ensureDir("sharedMods");
await fs.ensureDir(slaveConfig.get("slave.mods_directory"));
await fs.ensureDir("modules");
// Set the process title, shows up as the title of the CMD window on windows
+82 -74
View File
@@ -6,6 +6,7 @@ const phin = require("phin");
const util = require("util");
// internal libraries
const libData = require("@clusterio/lib/data");
const libFileOps = require("@clusterio/lib/file_ops");
const libFactorio = require("@clusterio/lib/factorio");
const libLink = require("@clusterio/lib/link");
@@ -94,71 +95,6 @@ const serverSettingsActions = {
},
};
/**
* Create and update symlinks for shared mods in an instance
*
* Creates symlinks for .zip and .dat files that are not present in the
* instance mods directory but is present in the sharedMods directory,
* and removes any symlinks that don't point to a file in the instance
* mods directory. If the instance mods directory doesn't exist it will
* be created.
*
* Note that on Windows this creates hard links instead of symbolic
* links as the latter requires elevated privileges. This unfortunately
* means the removal of mods from the shared mods dir can't be detected.
*
* @param {Instance} instance - Instance to link mods for
* @param {string} sharedMods - Path to folder to link mods from.
* @private
*/
async function symlinkMods(instance, sharedMods) {
await fs.ensureDir(instance.path("mods"));
// Remove broken symlinks in instance mods.
for (let entry of await fs.readdir(instance.path("mods"), { withFileTypes: true })) {
if (entry.isSymbolicLink()) {
if (!await fs.pathExists(instance.path("mods", entry.name))) {
instance.logger.verbose(`Removing broken symlink ${entry.name}`);
await fs.unlink(instance.path("mods", entry.name));
}
}
}
// Link entries that are in sharedMods but not in instance mods.
let instanceModsEntries = new Set(await fs.readdir(instance.path("mods")));
for (let entry of await fs.readdir(sharedMods, { withFileTypes: true })) {
if (entry.isFile()) {
if ([".zip", ".dat", ".json"].includes(path.extname(entry.name))) {
if (!instanceModsEntries.has(entry.name)) {
instance.logger.verbose(`linking ${entry.name} from ${sharedMods}`);
let target = path.join(sharedMods, entry.name);
let link = instance.path("mods", entry.name);
/* eslint-disable max-depth */
if (process.platform !== "win32") {
await fs.symlink(path.relative(path.dirname(link), target), link);
// On Windows symlinks require elevated privileges, which is
// not something we want to have. For this reason the mods
// are hard linked instead. This has the drawback of not
// being able to identify when mods are removed from the
// sharedMods directory, or which mods are linked.
} else {
await fs.link(target, link);
}
/* eslint-enable max-depth */
}
} else {
instance.logger.warn(`Warning: ignoring file '${entry.name}' in sharedMods`);
}
} else {
instance.logger.warn(`Warning: ignoring non-file '${entry.name}' in sharedMods`);
}
}
}
/**
* Keeps track of the runtime parameters of an instance
@@ -255,6 +191,12 @@ class Instance extends libLink.Link {
}
});
/**
* Mod pack currently running on this instance
* @type {module:lib/data.ModPack>}
*/
this.activeModPack = undefined;
/**
* Per player statistics recorded by this instance.
* @type {Map<string, module:lib/PlayerStats>}
@@ -643,6 +585,77 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
await fs.ensureDir(path.join(instanceDir, "saves"));
}
/**
* Sync instance mods directory with configured mod pack
*
* Adds, deletes, and updates all files in the instance mods folder to
* match up with the mod pack that's configured. If no mod pack is
* configured then an empty mod pack is used, esentially turning it into
* a vanilla mods folder. If the instance mods directory does't exist
* then it will be created.
*
* On Linux this creates symlinks to mods in the slave's mods folder, on
* Windows hard links are used instead due to symlinks being privileged.
*/
async syncMods() {
const modPackId = this.config.get("factorio.mod_pack");
let response;
if (modPackId === null) {
response = await libLink.messages.getDefaultModPack.send(this);
} else {
response = await libLink.messages.getModPack.send(this, { id: modPackId });
}
this.activeModPack = new libData.ModPack(response.mod_pack);
// TODO validate factorioVersion
if (!this._slave.config.get("slave.mods_directory_is_shared")) {
throw new Error("Fetching mods is not implemented");
}
await fs.ensureDir(this.path("mods"));
// Remove all files
for (let entry of await fs.readdir(this.path("mods"), { withFileTypes: true })) {
if (entry.isDirectory()) {
this.logger.warn(
`Found unexpected directory ${entry.name} in mods folder, it may break Clusterio's mod syncing`
);
continue;
}
if (entry.isFile() || entry.isSymbolicLink()) {
await fs.unlink(this.path("mods", entry.name));
}
}
// Add mods from mod the pack
const modsDir = this._slave.config.get("slave.mods_directory");
for (let mod of this.activeModPack.mods.values()) {
const modFile = `${mod.name}_${mod.version}.zip`;
const target = path.join(modsDir, modFile);
const link = this.path("mods", modFile);
if (process.platform !== "win32") {
await fs.symlink(path.relative(path.dirname(link), target), link);
// On Windows symlinks require elevated privileges, which is
// not something we want to have. For this reason the mods
// are hard linked instead.
} else {
await fs.link(target, link);
}
}
// Write mod-list.json
await fs.outputFile(this.path("mods", "mod-list.json"), JSON.stringify({
mods: [{ name: "base" }, ...this.activeModPack.mods.values()].map(m => ({ name: m.name, enabled: true })),
}, null, 2));
// Write mod-settings.dat
await fs.outputFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
}
/**
* Prepare instance for starting
*
@@ -678,8 +691,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
);
}
// eslint-disable-next-line no-use-before-define
await symlinkMods(this, "sharedMods");
await this.syncMods();
}
/**
@@ -1030,8 +1042,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
await this.writeServerSettings();
this.logger.verbose("Creating save .....");
// eslint-disable-next-line no-use-before-define
await symlinkMods(this, "sharedMods");
await this.syncMods();
} catch (err) {
this.notifyExit();
@@ -1053,13 +1064,13 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
await this.writeServerSettings();
this.logger.info("Exporting data .....");
// eslint-disable-next-line no-use-before-define
await symlinkMods(this, "sharedMods");
await this.syncMods();
let zip = await libFactorio.exportData(this.server);
let content = await zip.generateAsync({ type: "nodebuffer" });
let url = new URL(this._slave.config.get("slave.master_url"));
url.pathname += "api/upload-export";
url.searchParams.set("mod_pack_id", this.activeModPack.id);
let response = await phin({
url, method: "PUT",
data: content,
@@ -1116,6 +1127,3 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
}
module.exports = Instance;
// For testing only
module.exports._symlinkMods = symlinkMods;
+3 -83
View File
@@ -25,86 +25,9 @@ const InstanceConnection = require("./InstanceConnection");
const finished = util.promisify(stream.finished);
function checkFilename(name) {
// All of these are bad in Windows only, except for /, . and ..
// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const badNames = [
// Relative path components
".", "..",
// Reserved filenames in Windows
"CON", "PRN", "AUX", "NUL",
...oneToNine.map(n => `COM${n}`),
...oneToNine.map(n => `LPT${n}`),
];
if (typeof name !== "string") {
throw new Error("must be a string");
}
if (name === "") {
throw new Error("cannot be empty");
}
if (badChars.test(name)) {
throw new Error('cannot contain <>:"\\/|=* or control characters');
}
if (badNames.includes(name.toUpperCase())) {
throw new Error(
"cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9"
);
}
if (badEnd.test(name)) {
throw new Error("cannot end with . or space");
}
}
/**
* Clean up string to be suitable for use as filename
*
* @param {string} name - Arbitrary name string
* @returns {string} Filename suitable to use in the filesystem
* @private
*/
function cleanFilename(name) {
// copied from checkFilename
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const badNames = [
// Relative path components
".", "..",
// Reserved filenames in Windows
"CON", "PRN", "AUX", "NUL",
...oneToNine.map(n => `COM${n}`),
...oneToNine.map(n => `LPT${n}`),
];
if (typeof name !== "string") {
throw new Error("name must be a string");
}
if (name === "" || badNames.includes(name.toUpperCase())) {
name += "_";
}
name = name.replace(badChars, "_");
name = name.replace(badEnd, "_");
return name;
}
function checkRequestSaveName(name) {
try {
checkFilename(name);
libFileOps.checkFilename(name);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
@@ -243,9 +166,9 @@ class Slave extends libLink.Link {
}
async _createNewInstanceDir(name) {
name = cleanFilename(name);
name = libFileOps.cleanFilename(name);
try {
checkFilename(name);
libFileOps.checkFilename(name);
} catch (err) {
throw new Error(`Instance folder was unepectedly invalid: name ${err.message}`);
}
@@ -837,8 +760,5 @@ ${err.stack}`
module.exports = Slave;
// For testing only
module.exports._checkFilename = checkFilename;
module.exports._cleanFilename = cleanFilename;
module.exports._discoverInstances = discoverInstances;
module.exports._Slave = Slave;
@@ -0,0 +1,53 @@
import React, { Fragment } from "react";
import { Descriptions, Grid, Typography } from "antd";
import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined";
import { libHelpers } from "@clusterio/lib";
const { useBreakpoint } = Grid;
export default function ModDetails(props) {
let screens = useBreakpoint();
const mod = props.mod;
return <Descriptions className="borderless" bordered size="small" column={{ xs: 1, sm: 1, md: 1, lg: 2 }}>
{mod.error && <Descriptions.Item label={<><ExclamationCircleOutlined/> Error</>} span={2}>
{mod.error === "missing" && "The given version of this mod is missing from the master storage."}
{mod.error === "bad_checksum"
&& "Checksum of the mod in the master storage does not match the checksum provided by this mod pack."
}
</Descriptions.Item>}
{props.actions && !screens.lg
&& <Descriptions.Item label="Action">{props.actions(mod)}</Descriptions.Item>
}
{mod.title && <Descriptions.Item label="Title">{mod.title}</Descriptions.Item>}
{mod.version && <Descriptions.Item label="Version" span={mod.title ? 1 : 2}>{mod.version}</Descriptions.Item>}
{mod.description && <Descriptions.Item label="Description" span={2}>{mod.description}</Descriptions.Item> }
{mod.author && <Descriptions.Item label="Author" span={2}>{mod.author}</Descriptions.Item>}
<Descriptions.Item label="Mod&nbsp;Portal" span={2}>
<Typography.Link href={`https://mods.factorio.com/mod/${mod.name}`}>
{`https://mods.factorio.com/mod/${mod.name}`}
</Typography.Link>
</Descriptions.Item>
{mod.contact && <Descriptions.Item label="Contact" span={2}>{mod.contact}</Descriptions.Item>}
{mod.homepage && <Descriptions.Item label="Homepage" span={2}>{mod.homepage}</Descriptions.Item>}
<Descriptions.Item label="Internal&nbsp;Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
{mod.factorioVersion
&& <Descriptions.Item label="Factorio&nbsp;Version">{mod.factorioVersion}</Descriptions.Item>
}
{mod.filename && <Descriptions.Item label="Filename">{mod.filename}</Descriptions.Item>}
{mod.size !== undefined
&& <Descriptions.Item label="Size">{libHelpers.formatBytes(mod.size)}</Descriptions.Item>
}
{mod.sha1 && <Descriptions.Item label="SHA1" span={2}>{mod.sha1}</Descriptions.Item>}
{mod.dependencies && mod.dependencies.length
&& <Descriptions.Item label="Dependencies" span={2}>
{mod.dependencies
.filter(e => !e.startsWith("(?)"))
.map((e, i) => <Fragment key={i}>{e}<br/></Fragment>)
}
</Descriptions.Item>
}
</Descriptions>;
}
@@ -0,0 +1,803 @@
import React, { Fragment, useEffect, useContext, useRef, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import {
Button, Card, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, PageHeader, Pagination,
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
} from "antd";
import InfoCircleOutlined from "@ant-design/icons/InfoCircleOutlined";
import ExportOutlined from "@ant-design/icons/ExportOutlined";
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
import CloseOutlined from "@ant-design/icons/CloseOutlined";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import UploadOutlined from "@ant-design/icons/UploadOutlined";
import { libData, libLink, libLogging } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { useAccount } from "../model/account";
import { useModPack } from "../model/mod_pack";
import { useModList } from "../model/mods";
import { useClipboard } from "../util/clipboard";
import notify, { notifyErrorHandler } from "../util/notify";
import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
const { logger } = libLogging;
const { Paragraph, Text } = Typography;
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function SearchModsTable(props) {
const control = useContext(ControlContext);
const [searchText, setSearchText] = useState("");
const [modResultSort, setModResultSort] = useState("title");
const [modResultSortOrder, setModResultSortOrder] = useState("asc");
const [modResults, setModResults] = useState([]);
const [modResultPage, setModResultPage] = useState(1);
const [modResultPageSize, setModResultPageSize] = useState(10);
const [modResultCount, setModResultCount] = useState(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState(new Map());
const factorioVersion = props.modPack.factorioVersion.split(".").slice(0, 2).join(".");
useEffect(() => {
let canceled = false;
libLink.messages.searchMods.send(control, {
query: searchText,
factorio_version: factorioVersion,
page_size: modResultPageSize,
page: modResultPage,
sort: modResultSort,
sort_order: modResultSortOrder,
}).then(response => {
if (canceled) {
return;
}
// In React < v18 this causes 3 renders and a partial state necessitating the use
// of || 0 on modResultSelectedVersion.get() calls. Remove when updating React.
setModResults(response.results.map(
({ name, versions }) => ({ name, versions: versions.map(mod => new libData.ModInfo(mod)) })
));
setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0])));
setModResultCount(response.result_count);
});
return () => {
canceled = true;
};
}, [searchText, factorioVersion, modResultPageSize, modResultPage, modResultSort, modResultSortOrder]);
function modResultPageChanged(page, pageSize) {
setModResultPage(page);
setModResultPageSize(pageSize);
}
function modResultsTableChanged(pagination, filters, sorters, extra) {
if (extra.action === "sort") {
if (sorters.order) {
const mapping = {
ascend: "asc",
descend: "desc",
};
setModResultSort(sorters.columnKey);
setModResultSortOrder(mapping[sorters.order]);
} else {
setModResultSort(undefined);
setModResultSortOrder(undefined);
}
}
}
function actions(mod) {
return <Space>
{(!props.modPack.mods.has(mod.name) || props.modPack.mods.get(mod.name).version !== mod.version)
&& <Typography.Link
onClick={() => {
props.onChange({
type: "mods.set",
name: mod.name,
value: { name: mod.name, enabled: true, version: mod.version, sha1: mod.sha1 },
});
}}
>{props.modPack.mods.has(mod.name) ? "change" : "add"}</Typography.Link>
}
{props.modPack.mods.has(mod.name)
&& <Typography.Link
type="danger"
onClick={() => {
props.onChange({
type: "mods.delete",
name: mod.name,
value: { name: mod.name, version: mod.version, sha1: mod.sha1 },
});
}}
>remove</Typography.Link>
}
</Space>;
}
return <>
<Input
placeholder="Search Mods"
style={{ marginBottom: 8 }}
onChange={(event) => {
let search = event.target.value.trim();
setSearchText(search);
setModResultPage(1);
}}
/>
<SectionHeader
title="Stored Mods"
extra={<Pagination
current={modResultPage}
onChange={modResultPageChanged}
total={modResultCount}
showTotal={total => `${total} results`}
showSizeChanger={true}
size="small"
/>}
/>
<ConfigProvider renderEmpty={() => "No results"}>
<Table
size="small"
columns={[
{
title: "Title",
key: "title",
render: results => results.versions[0].title,
defaultSortOrder: "ascend",
sorter: true,
},
{
title: "Author",
key: "author",
render: results => results.versions[0].author,
responsive: ["lg"],
sorter: true,
},
{
title: "Version",
key: "version",
align: "right",
render: result => <Select
size="small"
bordered={false}
value={modResultSelectedVersion.get(result.name) || 0}
onChange={index => {
const newVersions = new Map(modResultSelectedVersion);
newVersions.set(result.name, index);
setModResultSelectedVersion(newVersions);
}}
options={result.versions.map((mod, index) => ({ label: mod.version, value: index }))}
/>,
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: result => actions(result.versions[modResultSelectedVersion.get(result.name) || 0]),
},
]}
expandable={{
expandedRowRender: result => <ModDetails
mod={result.versions[modResultSelectedVersion.get(result.name) || 0]}
actions={actions}
/>,
expandedRowClassName: () => "no-expanded-padding",
}}
onChange={modResultsTableChanged}
dataSource={modResults}
pagination={false}
rowKey={result => result.name}
/>
</ConfigProvider>
</>;
}
function ModsTable(props) {
const [showAddMods, setShowAddMods] = useState(false);
const [modList] = useModList();
const deletedMods = new Map();
const changedMods = new Map();
for (let change of props.changes) {
if (props.modPack.mods.has(change.name)) {
if (change.type === "mods.set") {
changedMods.set(change.name, change.value);
}
} else if (change.type === "mods.delete") {
deletedMods.set(change.name, change.value);
}
}
const modListMap = new Map(modList.map(mod => [`${mod.name}_${mod.version}`, mod]));
const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map(
mod => {
const candidate = modListMap.get(`${mod.name}_${mod.version}`);
if (!candidate) {
return {
...mod,
error: "missing",
};
} else if (mod.sha1 && candidate.sha1 !== mod.sha1) {
return {
...mod,
error: "bad_checksum",
};
}
return candidate;
}
);
function actions(mod) {
return <Space>
{!deletedMods.has(mod.name) && <Typography.Link
type="danger"
onClick={() => {
props.onChange({
type: "mods.delete",
name: mod.name,
value: mod,
});
}}
>remove</Typography.Link>}
{(deletedMods.has(mod.name) || changedMods.has(mod.name)) && <Typography.Link
onClick={() => {
props.onRevert({
type: deletedMods.has(mod.name) ? "mods.delete" : "mods.set",
name: mod.name,
value: mod,
});
}}
>revert</Typography.Link>}
</Space>;
}
return <>
<SectionHeader
title="Mods"
extra={<Space>
<Button icon={<PlusOutlined />} onClick={() => setShowAddMods(true)}>Add</Button>
</Space>}
/>
{showAddMods && <Card
title="Add mods"
size="small"
extra={<Button type="text" icon={<CloseOutlined />} onClick={() => setShowAddMods(false)} />}
style={{ marginBottom: 8 }}
>
<SearchModsTable modPack={props.modPack} changes={props.changes} onChange={props.onChange} />
</Card>}
<Table
size="small"
rowSelection={{
type: "checkbox",
selectedRowKeys: [...props.modPack.mods.values()].filter(mod => mod.enabled).map(mod => mod.name),
getCheckboxProps(mod) {
return { disabled: deletedMods.has(mod.name) };
},
onChange(selectedRowKeys, selectedRows) {
function setEnabled(modNames, enabled) {
for (let name of modNames) {
props.onChange({
type: "mods.set",
name,
value: { ...props.modPack.mods.get(name), enabled },
});
}
}
const added = selectedRowKeys.filter(key => !props.modPack.mods.get(key).enabled);
const removed = [...props.modPack.mods.values()].filter(
mod => mod.enabled && selectedRowKeys.indexOf(mod.name) === -1
).map(mod => mod.name);
setEnabled(added, true);
setEnabled(removed, false);
},
}}
columns={[
{
title: "Name",
key: "name",
render: mod => <>
{mod.error === "missing" && <Tooltip title="Mod is missing from storage.">
<FileUnknownOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum missmatch.">
<FileExclamationOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.title || mod.name}
</>,
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
},
{
title: "Version",
dataIndex: "version",
sorter: (a, b) => strcmp(a.version, b.version),
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: mod => actions(mod),
},
]}
expandable={{
expandedRowRender: mod => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={mods}
pagination={false}
rowKey={mod => mod.name}
rowClassName={mod => {
if (deletedMods.has(mod.name)) {
return "deleted";
}
if (changedMods.has(mod.name)) {
return "changed";
}
return "";
}}
/>
</>;
}
// Returns true if a change record with the given fields exist
function hasChange(changes, field) {
return changes.some(change => Object.entries(field).every(([key, value]) => change[key] === value));
}
// picks out the value property from settings.
function pickValue(map) {
return [...map].map(([name, value]) => [name, value.value]);
}
function groupToMap(array, fn) {
const map = new Map();
for (let index = 0; index < array.length; index++) {
let key = fn(array[index], index, array);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(array[index]);
}
return map;
}
function SettingsTable(props) {
const [modList] = useModList();
const modsInPack = new Set([...props.modPack.mods.values()].map(mod => `${mod.name}_${mod.version}`));
const modTitles = new Map(modList
.filter(mod => modsInPack.has(`${mod.name}_${mod.version}`))
.map(mod => [`${mod.name}`, mod.title])
);
const types = ["bool-setting", "int-setting", "double-setting", "string-setting"];
let prototypes = Object.entries(props.prototypes)
.filter(([type, _]) => types.includes(type))
.flatMap(([_, settingPrototypes]) => Object.values(settingPrototypes))
;
function controls(scope, storedFields) {
let fields = new Map(prototypes
.filter(p => p.setting_type === scope)
.sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name))
.map(p => [p.name, p])
);
for (let [name, value] of storedFields) {
if (!fields.has(name)) {
let type;
if (typeof value === "boolean") {
type = "bool-setting";
} else if (typeof value === "number") {
type = "double-setting";
} else if (typeof value === "string") {
type = "string-setting";
} else {
throw Error(`Unhandled setting type ${typeof value}`);
}
fields.set(name, { mod: "Unknown Fields", name, setting_type: scope, type });
}
}
let fieldsByMod = groupToMap([...fields.values()], prototype => prototype.mod);
return [...fieldsByMod].map(([mod, fieldsForMod]) => <Fragment key={mod}>
<Typography.Title level={5}>
{props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod}
</Typography.Title>
{[...fieldsForMod].map(prototype => {
const name = prototype.name;
const isBoolean = prototype.type === "bool-setting";
const isNumber = ["int-setting", "double-setting"].includes(prototype.type);
const isSelect = Boolean(prototype.allowed_values);
let input;
if (isBoolean) {
input = <Checkbox />;
} else if (isSelect) {
const options = Object.values(prototype.allowed_values).map(value => ({
value,
label: props.locale.get(`string-mod-setting.${name}-${value}`) || value,
}));
input = <Select options={options} />;
} else {
input = <Input />;
}
return <Form.Item
name={[scope, name]}
key={`${scope} ${name}`}
label={props.locale.get(`mod-setting-name.${name}`) || name}
tooltip={props.locale.get(`mod-setting-description.${name}`)}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
valuePropName={isBoolean ? "checked" : "value"}
rules={[...isNumber ? [{
validator(rule, value) {
if (Number.isNaN(Number(value))) {
return Promise.reject(new Error("Must be a number"));
}
return Promise.resolve();
},
}] : []]}
className={
hasChange(props.changes, { type: "settings.set", scope, name }) && "changed"
}
>
{input}
</Form.Item>;
})}
</Fragment>);
}
return <>
<SectionHeader
title="Mod Settings"
/>
<Typography.Paragraph>Run export data on an instance to update available fields.</Typography.Paragraph>
<Typography.Title level={4}>Startup</Typography.Title>
{controls("startup", pickValue(props.modPack.settings["startup"]))}
<Typography.Title level={4}>Map</Typography.Title>
<Typography.Paragraph>These only apply for new saves.</Typography.Paragraph>
{controls("runtime-global", pickValue(props.modPack.settings["runtime-global"]))}
<Typography.Title level={4}>Per Player</Typography.Title>
<Typography.Paragraph>Default settings for new players, these only apply for new saves.</Typography.Paragraph>
{controls("runtime-per-user", pickValue(props.modPack.settings["runtime-per-user"]))}
</>;
}
function CopyButton(props) {
let clipboard = useClipboard();
let [copiedVisible, setCopiedVisible] = useState(false);
let copiedTimeout = useRef();
useEffect(() => () => {
clearTimeout(copiedTimeout.current);
setCopiedVisible(false);
}, []);
let denied = clipboard.readPermissionState === "denied";
return <Tooltip title={denied ? clipboard.deniedReason : "Copied!"} visible={denied ? undefined : copiedVisible}>
<Button disabled={denied} onClick={() => {
try {
clipboard.writeText(props.content);
clearTimeout(copiedTimeout.current);
setCopiedVisible(undefined);
copiedTimeout.current = setTimeout(() => {
setCopiedVisible(false);
}, 3000);
} catch (err) {
logger.error(`Writing to clipboard failed:\n${err.stack}`);
notify("Unable to copy to clipboard", "error", "Use ordinary select and copy instead.");
}
}}>Copy</Button>
</Tooltip>;
}
function ExportButton(props) {
let [visible, setVisible] = useState(false);
function close() {
setVisible(false);
}
let exportString = props.modPack.toModPackString();
return <>
<Button icon={<ExportOutlined />} onClick={() => { setVisible(true); }}>Export to string</Button>
<Modal
title="Mod Pack String"
visible={visible}
onOk={close}
onCancel={close}
destroyOnClose
footer={<Space>
<CopyButton content={exportString} />
<Button onClick={close}>Close</Button>
</Space>}
>
<Text code className="code">
{exportString}
</Text>
</Modal>
</>;
}
function useExportedAsset(modPack, asset) {
let [assetData, setAssetData] = useState(asset === "locale" ? new Map() : {});
let assetFilename;
if (modPack instanceof libData.ModPack && modPack.exportManifest && modPack.exportManifest.assets[asset]) {
assetFilename = modPack.exportManifest.assets[asset];
}
useEffect(() => {
async function load() {
if (!assetFilename) {
return;
}
let response = await fetch(`${staticRoot}static/${assetFilename}`);
if (response.ok) {
let data = await response.json();
setAssetData(asset === "locale" ? new Map(data) : data);
} else {
logger.error(`Error loading mod pack asset "${asset}" (${response.status}): ${await response.text()}`);
}
}
load().catch(notifyErrorHandler(`Loading mod pack asset "${asset}" failed`));
}, [assetFilename]);
return assetData;
}
export default function ModPackViewPage() {
let account = useAccount();
let history = useHistory();
let control = useContext(ControlContext);
let params = useParams();
let modPackId = Number(params.id);
const [form] = Form.useForm();
let [modPack] = useModPack(modPackId);
let prototypes = useExportedAsset(modPack, "settings");
let locale = useExportedAsset(modPack, "locale");
let [changes, setChanges] = useState([]);
let [modifiedModPack, setModifiedModPack] = useState(modPack);
let syncTimeout = useRef();
useEffect(() => () => { clearTimeout(syncTimeout.current); });
function pushChange(change) {
setChanges(oldChanges => {
const newChanges = [...oldChanges];
if (
newChanges.length
&& newChanges.slice(-1)[0].type === change.type
&& newChanges.slice(-1)[0].name === change.name
&& newChanges.slice(-1)[0].scope === change.scope
) {
newChanges.splice(-1, 1, change);
} else {
newChanges.push(change);
}
return newChanges;
});
}
function revertChange(change) {
const newChanges = [...changes];
const index = newChanges.findLastIndex(reference => (
change.type === reference.type && change.name === reference.name
));
if (index === -1) {
logger.error(`Unable to revert ${change.type} ${change.name}: change not found`);
return;
}
newChanges.splice(index, 1);
setChanges(newChanges);
}
function syncPack(fields) {
for (let field of fields) {
if (!field.touched || field.validating || field.errors.length) {
continue;
}
if (["name", "description", "factorioVersion"].includes(field.name[0])) {
if (modifiedModPack[field.name[0]] !== field.value) {
pushChange({ type: field.name[0], value: field.value });
}
} else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) {
let [scope, settingName] = field.name;
let value = field.value;
if (typeof modPack.settings[scope].get(settingName).value === "number") {
value = Number(value);
if (Number.isNaN(value)) {
continue;
}
}
if (modifiedModPack.settings[scope].get(settingName).value !== value) {
pushChange({ type: "settings.set", scope, name: settingName, value: { value }});
}
}
}
}
useEffect(() => {
// Loading or invalid id
if (!(modPack instanceof libData.ModPack)) {
setModifiedModPack(modPack);
return;
}
const modified = new libData.ModPack(modPack.toJSON());
for (let change of changes) {
if (change.type === "name") {
modified.name = change.value;
} else if (change.type === "description") {
modified.description = change.value;
} else if (change.type === "factorioVersion") {
modified.factorioVersion = change.value;
} else if (change.type === "mods.set") {
modified.mods.set(change.name, change.value);
} else if (change.type === "mods.delete") {
modified.mods.delete(change.name);
} else if (change.type === "settings.set") {
modified.settings[change.scope].set(change.name, change.value);
} else if (change.type === "settings.delete") {
modified.settings[change.scope].delete(change.name);
} else {
throw new Error(`Unknown change type ${change.type}`);
}
}
setModifiedModPack(modified);
}, [modPack, changes]);
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack.name || modPackId }];
if (modifiedModPack.loading) {
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={modPackId}
/>
<Spin size="large" />
</PageLayout>;
}
if (modifiedModPack.missing) {
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title="Mod Pack Not Found"
/>
<p>Mod pack with id {modPackId} was not found on the master server.</p>
</PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={modPack.name}
extra={<Space>
<ExportButton modPack={modifiedModPack}/>
{account.hasPermission("core.mod-pack.delete") && <Popconfirm
title="Delete mod pack and all of its settings?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteModPack.send(
control, { id: modPack.id }
).then(() => {
history.push("/mods");
}).catch(notifyErrorHandler("Error deleting mod pack"));
}}
>
<Button danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>}
</Space>}
/>
<Form
form={form}
onFieldsChange={(changedFields, allFields) => {
clearTimeout(syncTimeout.current);
if (typeof changedFields[0].value === "boolean") {
syncPack(allFields);
} else {
// Defer sync if this is not a checkbox to make the UI responsive.
syncTimeout.current = setTimeout(syncPack.bind(undefined, allFields), 500);
}
}}
initialValues={{
name: modPack.name,
description: modPack.description,
factorioVersion: modPack.factorioVersion,
"startup": Object.fromEntries(pickValue(modPack.settings["startup"])),
"runtime-global": Object.fromEntries(pickValue(modPack.settings["runtime-global"])),
"runtime-per-user": Object.fromEntries(pickValue(modPack.settings["runtime-per-user"])),
}}
>
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, lg: 3 }}>
<Descriptions.Item span={3} label="Name">
<Form.Item noStyle name="name">
<Input
className={hasChange(changes, { type: "name" }) && "changed"}
/>
</Form.Item>
</Descriptions.Item>
<Descriptions.Item span={3} label="Description">
<Form.Item noStyle name="description">
<Input.TextArea
autoSize={{ minRows: 2 }}
className={hasChange(changes, { type: "description" }) && "changed"}
/>
</Form.Item>
</Descriptions.Item>
<Descriptions.Item label="Mods">
{modifiedModPack.mods.size}
</Descriptions.Item>
<Descriptions.Item label="Factorio Version">
<Form.Item
name="factorioVersion"
style={{ marginBottom: 0 }}
rules={[{
pattern: /^\d+\.\d+\.\d+?$/,
message: "Must be an a.b.c version number.",
}]}
>
<Input
className={hasChange(changes, { type: "factorioVersion" }) && "changed"}
/>
</Form.Item>
</Descriptions.Item>
</Descriptions>
<ModsTable modPack={modifiedModPack} changes={changes} onChange={pushChange} onRevert={revertChange} />
<SettingsTable
modPack={modifiedModPack}
changes={changes}
onChange={pushChange}
onRevert={revertChange}
prototypes={prototypes}
locale={locale}
/>
</Form>
<div
className="ant-notification ant-notification-bottom"
style={{
visibility: changes.length ? "visible" : "hidden",
inset: "auto auto 24px auto",
position: "sticky",
marginTop: 16,
}}
>
<div className="ant-notification-notice" style={{ width: "auto", maxWidth: 384, marginBottom: 0 }}>
<Row style={{ alignItems: "center", rowGap: 12 }}>
<Col flex="auto">You have unsaved changes</Col>
<Col flex="0 0 auto" style={{ marginLeft: "auto" }}>
<Space>
<Button
onClick={() => {
form.resetFields();
setChanges([]);
}}
>Revert</Button>
<Button
type="primary"
onClick={() => {
libLink.messages.updateModPack.send(
control, { mod_pack: modifiedModPack.toJSON() }
).then(() => {
form.resetFields();
setChanges([]);
}).catch(notifyErrorHandler("Error deleting mod pack"));
}}
>Apply</Button>
</Space>
</Col>
</Row>
</div>
</div>
</PageLayout>;
}
+277
View File
@@ -0,0 +1,277 @@
import React, { Fragment, useState, useContext } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Popconfirm, Space, Table, Typography, Upload } from "antd";
import ImportOutlined from "@ant-design/icons/ImportOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import UploadOutlined from "@ant-design/icons/UploadOutlined";
import { libData, libHelpers, libLink, libLogging } from "@clusterio/lib";
import { useAccount } from "../model/account";
import { useModList } from "../model/mods";
import { useModPackList } from "../model/mod_pack";
import { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const { logger } = libLogging;
function ImportModPackButton() {
let control = useContext(ControlContext);
let history = useHistory();
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
function close() {
setVisible(false);
}
return <>
<Button icon={<ImportOutlined />} onClick={() => { setVisible(true); }}>Import string</Button>
<Modal
title="Import Mod Pack String"
visible={visible}
okText="Import"
cancelText="Cancel"
onCancel={() => { setVisible(false); }}
onOk={() => {
(async () => {
let values;
try {
values = await form.validateFields();
} catch {
return; // Validation failed
}
const modPack = libData.ModPack.fromModPackString(values.string);
await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() });
history.push(`/mods/mod-packs/${modPack.id}/view`);
})().catch(notifyErrorHandler("Error creating mod pack"));
}}
>
<Form form={form} layout="vertical">
<Form.Item
name="string"
rules={[
{ required: true },
{ async validator(rule, value) {
if (value) {
libData.ModPack.fromModPackString(value);
}
}},
]}
>
<Input.TextArea autoSize={{ minRows: 6, maxRows: 6 }} />
</Form.Item>
</Form>
</Modal>
</>;
}
function CreateModPackButton() {
let control = useContext(ControlContext);
let history = useHistory();
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
function close() {
setVisible(false);
}
return <>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setVisible(true); }}>Create</Button>
<Modal
title="Create Mod Pack"
visible={visible}
okText="Create"
cancelText="Cancel"
onCancel={() => { setVisible(false); }}
onOk={() => {
(async () => {
let values;
try {
values = await form.validateFields();
} catch {
return; // Validation failed
}
const modPack = new libData.ModPack();
modPack.name = values.name;
modPack.factorioVersion = values.factorioVersion;
if (values.description) { modPack.description = values.description; }
await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() });
history.push(`/mods/mod-packs/${modPack.id}/view`);
})().catch(notifyErrorHandler("Error creating mod pack"));
}}
>
<Form form={form} layout="vertical" requiredMark="optional">
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item>
<Form.Item
name="factorioVersion"
label="Factorio Version"
rules={[{
required: true,
pattern: /^\d+\.\d+(\.\d+)?$/,
message: "Must be an a.b or a.b.c version number.",
}]}
>
<Input />
</Form.Item>
</Form>
</Modal>
</>;
}
export default function ModsPage() {
let account = useAccount();
let control = useContext(ControlContext);
let history = useHistory();
let [modList] = useModList();
let [modPackList] = useModPackList();
function actions(mod) {
return <Space>
{account.hasPermission("core.mod.download")
&& <Typography.Link
onClick={() => {
libLink.messages.downloadMod.send(
control, { name: mod.name, version: mod.version }
).then(response => {
let url = new URL(webRoot, document.location);
url.pathname += `api/stream/${response.stream_id}`;
document.location = url;
}).catch(
notifyErrorHandler("Error downloading save")
);
}}
>download</Typography.Link>
}
{account.hasPermission("core.mod.delete")
&& <Popconfirm
title="Are you sure you want to delete this mod?"
onConfirm={event => {
libLink.messages.deleteMod.send(
control, { name: mod.name, version: mod.version }
).catch(notifyErrorHandler("Error deleting mod"));
}}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Typography.Link type="danger">delete</Typography.Link>
</Popconfirm>
}
</Space>;
}
let uploadButton;
if (account.hasPermission("core.mod.upload")) {
uploadButton = <Upload
name="file"
accept=".zip"
multiple
headers={{
"X-Access-Token": control.connector.token,
}}
action={`${webRoot}api/upload-mod`}
>
<Button icon={<UploadOutlined/>}>Upload</Button>
</Upload>;
}
return <PageLayout nav={[{ name: "Mods" }]}>
<PageHeader
className="site-page-header"
title="Mods"
/>
<SectionHeader
title="Mod Packs"
extra={<Space>
{account.hasPermission("core.mod-pack.create") && <ImportModPackButton />}
{account.hasPermission("core.mod-pack.create") && <CreateModPackButton />}
</Space>}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
},
{
title: "Factorio Version",
dataIndex: "factorioVersion",
sorter: (a, b) => a.integerFactorioVersion - b.integerFactorioVersion,
},
{
title: "Mods",
key: "roles",
render: modPack => modPack.mods.size,
},
]}
dataSource={modPackList}
pagination={false}
rowKey={modPack => modPack.id}
onRow={(modPack, rowIndex) => ({
onClick: event => {
history.push(`/mods/mod-packs/${modPack.id}/view`);
},
})}
/>
<SectionHeader title="Stored Mods" extra={uploadButton} />
<Table
columns={[
{
title: "Name",
dataIndex: "title",
defaultSortOrder: "ascend",
sorter: (a, b) => (
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
},
{
title: "Version",
key: "version",
align: "right",
render: mod => <>
{`${mod.version} `}
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
</>,
},
{
title: "Filename",
dataIndex: "filename",
responsive: ["xl"],
sorter: (a, b) => strcmp(a.filename, b.filename),
},
{
title: "Size",
key: "size",
responsive: ["lg"],
render: mod => libHelpers.formatBytes(mod.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: mod => actions(mod),
},
]}
expandable={{
expandedRowRender: mod => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={modList}
pagination={false}
rowKey={mod => `${mod.name}_${mod.version}`}
/>
<PluginExtra component="ModsPage" />
</PageLayout>;
}
+2 -13
View File
@@ -6,7 +6,7 @@ import {
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
import LeftOutlined from "@ant-design/icons/LeftOutlined";
import { libLink } from "@clusterio/lib";
import { libLink, libHelpers } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
@@ -179,17 +179,6 @@ function TransferModal(props) {
</>;
}
function formatBytes(bytes) {
if (bytes === 0) {
return "0 Bytes";
}
let units = [" Bytes", " kB", " MB", " GB", " TB"];
let factor = 1000;
let power = Math.min(Math.floor(Math.log(bytes) / Math.log(factor)), units.length);
return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power];
}
export default function SavesList(props) {
let account = useAccount();
let control = useContext(ControlContext);
@@ -214,7 +203,7 @@ export default function SavesList(props) {
title: "Size",
key: "size",
responsive: ["sm"],
render: save => formatBytes(save.size),
render: save => libHelpers.formatBytes(save.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
@@ -49,7 +49,13 @@ export default function SiteLayout(props) {
let menuItems = [];
let menuGroups = new Map();
for (let { sidebarName, sidebarGroup, permission, path } of combinedPages) {
if (!sidebarName || permission && !account.hasPermission(permission)) {
if (
!sidebarName || permission && (
typeof permission === "function"
? !permission(account)
: !account.hasPermission(permission)
)
) {
continue;
}
if (sidebarGroup) {
+41 -1
View File
@@ -27,7 +27,7 @@ body {
}
.site-layout {
overflow-x: hidden;
overflow-x: clip;
}
.site-layout-content-container {
@@ -73,6 +73,7 @@ body {
.section-header {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 16px;
margin-bottom: 8px;
@@ -295,6 +296,10 @@ body {
width: 32px;
}
.code > code {
display: block;
}
/*
* Render codeblock with options for syntax highlighting
* https://user-images.githubusercontent.com/6792749/152066701-21420461-710b-4fae-a80a-89ef90a0b18f.png
@@ -314,3 +319,38 @@ body {
top: 8px;
right: 8px;
}
.ant-descriptions-bordered.borderless > .ant-descriptions-view {
border: none;
}
tr.no-expanded-padding > td,
.ant-table.ant-table-small .ant-table-tbody > tr.no-expanded-padding > td {
padding: 0;
}
.ant-form-item.changed,
input.ant-input.changed,
textarea.ant-input.changed,
tr.ant-table-row.changed > td {
background: #2a1912;
}
.ant-table-tbody > tr.ant-table-row.changed:hover > td,
.ant-table-tbody > tr.changed > td.ant-table-cell-row-hover
tr.ant-table-row.deleted > td {
background: #3a2922;
}
tr.ant-table-row.deleted {
color: rgba(255, 255, 255, 0.45);
background: #340c08;
text-decoration: line-through;
}
.ant-table-tbody > tr.ant-table-row.deleted:hover > td,
.ant-table-tbody > tr.deleted > td.ant-table-cell-row-hover
tr.ant-table-row.deleted {
background: #441c18;
}
@@ -1,15 +1,20 @@
import { useEffect, useState } from "react";
import { useEffect, useContext, useState } from "react";
import { libData, libLink } from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
let exportManifestCache = null;
let emptyCache = {};
export function useExportManifest() {
const control = useContext(ControlContext);
let [exportManifest, setExportManifest] = useState(exportManifestCache || emptyCache);
useEffect(() => {
async function load() {
let response = await fetch(`${webRoot}api/export-manifest`);
if (response.ok) {
exportManifestCache = await response.json();
let result = await libLink.messages.getDefaultModPack.send(control);
let modPack = new libData.ModPack(result.mod_pack);
if (modPack.exportManifest) {
exportManifestCache = modPack.exportManifest;
setExportManifest(exportManifestCache);
}
}
+7 -3
View File
@@ -9,10 +9,14 @@ export function useItemMetadata() {
let [itemMetadata, setItemMetadata] = useState(itemMetadataCache || new Map());
useEffect(() => {
async function load() {
if (!exportManifest["item-metadata"] || !exportManifest["item-spritesheet"]) {
if (
!exportManifest
|| !exportManifest.assets["item-metadata"]
|| !exportManifest.assets["item-spritesheet"]
) {
return;
}
let response = await fetch(`${staticRoot}${exportManifest["item-metadata"]}`);
let response = await fetch(`${staticRoot}static/${exportManifest.assets["item-metadata"]}`);
if (response.ok) {
let data = await response.json();
itemMetadataCache = new Map(data);
@@ -22,7 +26,7 @@ export function useItemMetadata() {
for (let [name, meta] of itemMetadataCache) {
style.sheet.insertRule(
`.item-${CSS.escape(name)} {
background-image: url("${staticRoot}${exportManifest["item-spritesheet"]}");
background-image: url("${staticRoot}static/${exportManifest.assets["item-spritesheet"]}");
background-repeat: no-repeat;
background-position: -${meta.x}px -${meta.y}px;
height: ${meta.size}px;
+2 -2
View File
@@ -9,10 +9,10 @@ export function useLocale() {
let [locale, setLocale] = useState(localeCache || new Map());
useEffect(() => {
async function load() {
if (!exportManifest["locale"]) {
if (!exportManifest || !exportManifest.assets["locale"]) {
return;
}
let response = await fetch(`${staticRoot}${exportManifest["locale"]}`);
let response = await fetch(`${staticRoot}static/${exportManifest.assets["locale"]}`);
if (response.ok) {
let data = await response.json();
localeCache = new Map(data);
+78
View File
@@ -0,0 +1,78 @@
import { useContext, useEffect, useState } from "react";
import { libData, libLink, libLogging } from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
const { logger } = libLogging;
export function useModPack(id) {
let control = useContext(ControlContext);
let [modPack, setModPack] = useState({ loading: true });
function updateModPack() {
libLink.messages.getModPack.send(control, { id }).then(result => {
setModPack(new libData.ModPack(result.mod_pack));
}).catch(err => {
logger.error(`Failed to get mod pack: ${err}`);
setModPack({ missing: true });
});
}
useEffect(() => {
if (typeof id !== "number") {
setModPack({ missing: true });
return undefined;
}
updateModPack();
control.onModPackUpdate(id, setModPack);
return () => {
control.offModPackUpdate(id, setModPack);
};
}, [id]);
return [modPack];
}
export function useModPackList() {
let control = useContext(ControlContext);
let [modPackList, setModPackList] = useState([]);
function updateModPackList() {
libLink.messages.listModPacks.send(control).then(result => {
setModPackList(result.list.map(pack => new libData.ModPack(pack)));
}).catch(err => {
logger.error(`Failed to list mod packs:\n${err}`);
});
}
useEffect(() => {
updateModPackList();
function updateHandler(newModPack) {
setModPackList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(u => u.id === newModPack.id);
if (!newModPack.isDeleted) {
if (index !== -1) {
newList[index] = newModPack;
} else {
newList.push(newModPack);
}
} else if (index !== -1) {
newList.splice(index, 1);
}
return newList;
});
}
control.onModPackUpdate(null, updateHandler);
return () => {
control.offModPackUpdate(null, updateHandler);
};
}, []);
return [modPackList];
}
+50
View File
@@ -0,0 +1,50 @@
import { useContext, useEffect, useState } from "react";
import { libData, libLink, libLogging } from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
const { logger } = libLogging;
export function useModList() {
let control = useContext(ControlContext);
let [modList, setModList] = useState([]);
function updateModList() {
libLink.messages.listMods.send(control).then(result => {
setModList(result.list.map(mod => new libData.ModInfo(mod)));
}).catch(err => {
logger.error(`Failed to list mods:\n${err}`);
});
}
useEffect(() => {
updateModList();
function updateHandler(newMod) {
setModList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(mod => mod.name === newMod.name && mod.version === newMod.version);
if (!newMod.isDeleted) {
if (index !== -1) {
newList[index] = newMod;
} else {
newList.push(newMod);
}
} else if (index !== -1) {
newList.splice(index, 1);
}
return newList;
});
}
control.onModUpdate(null, updateHandler);
return () => {
control.offModUpdate(null, updateHandler);
};
}, []);
return [modList];
}
+13
View File
@@ -5,6 +5,8 @@ import SlavesPage from "./components/SlavesPage";
import SlaveViewPage from "./components/SlaveViewPage";
import InstancesPage from "./components/InstancesPage";
import InstanceViewPage from "./components/InstanceViewPage";
import ModPackViewPage from "./components/ModPackViewPage";
import ModsPage from "./components/ModsPage";
import UsersPage from "./components/UsersPage";
import UserViewPage from "./components/UserViewPage";
import RolesPage from "./components/RolesPage";
@@ -41,6 +43,17 @@ export const pages = [
sidebarPath: "/instances",
content: <InstanceViewPage />,
},
{
path: "/mods",
sidebarName: "Mods",
permission: account => account.hasAnyPermission("core.mod.list", "core.mod-pack.list"),
content: <ModsPage />,
},
{
path: "/mods/mod-packs/:id/view",
sidebarPath: "/mods",
content: <ModPackViewPage />,
},
{
path: "/users",
sidebarName: "Users",
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { EventEmitter } from "events";
class Permission extends EventEmitter {
status;
constructor(name) {
super();
navigator.permissions.query({ name }).then(
status => {
this.status = status;
this.emit("change", this.status.state);
if (status.addEventListener) {
status.addEventListener("change", () => {
this.emit("change", this.status.state);
});
}
},
// Pretend the clipboard will work if this query fails.
() => {
this.status = { state: "prompt" };
this.emit("change", "prompt");
}
);
}
get state() {
return this.status.state || "prompt";
}
}
let readPermission = new Permission("clipboard-read");
let writePermission = new Permission("clipboard-write");
// Wrapper for navigator.clipboard suitable for use in React
export function useClipboard() {
let [readPermissionState, setReadPermissionState] = useState(readPermission.state);
let [writePermissionState, setWritePermissionState] = useState(writePermission.state);
useEffect(() => {
readPermission.on("change", setReadPermissionState);
writePermission.on("change", setWritePermissionState);
return () => {
readPermission.off("change", setReadPermissionState);
writePermission.off("change", setWritePermissionState);
};
}, []);
return {
readPermissionState: readPermissionState,
read: async () => await navigator.clipboard.read(),
readText: async () => await navigator.clipboard.readText(),
writePermissionState: writePermissionState,
write: async (data) => await navigator.clipboard.write(data),
writeText: async (text) => await navigator.clipboard.writeText(text),
// Give a possible reason for why the clipboard is not available
deniedReason: () => {
if (window.isSecureContext === false) {
if (window.location.protocol !== "https:") {
return "Clipboard interaction is blocked due this page not being served over https.";
}
return "Clipboard interaction is blocked due this page not being a secure context.";
}
return "Clipboard interaction is blocked by the browser.";
},
};
}
+125 -1
View File
@@ -1,4 +1,4 @@
import { libErrors, libLink, libLogging, libPlugin } from "@clusterio/lib";
import { libData, libErrors, libLink, libLogging, libPlugin } from "@clusterio/lib";
const { logger } = libLogging;
import packageJson from "../../package.json";
@@ -86,6 +86,8 @@ export class Control extends libLink.Link {
this.slaveUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.modPackUpdateHandlers = new Map();
this.modUpdateHandlers = new Map();
this.userUpdateHandlers = new Map();
this.logHandlers = new Map();
@@ -102,6 +104,12 @@ export class Control extends libLink.Link {
this.updateSaveListSubscriptions().catch(err => logger.error(
`Unexpected error updating save list subscriptions:\n${err.stack}`
));
this.updateModPackSubscriptions().catch(err => logger.error(
`Unexpected error updating mod pack subscriptions:\n${err.stack}`
));
this.updateModSubscriptions().catch(err => logger.error(
`Unexpected error updating mod subscriptions:\n${err.stack}`
));
this.updateUserSubscriptions().catch(err => logger.error(
`Unexpected error updating user subscriptions:\n${err.stack}`
));
@@ -319,6 +327,122 @@ export class Control extends libLink.Link {
});
}
async modPackUpdateEventHandler(message) {
let modPack = new libData.ModPack(message.data.mod_pack);
let handlers = [].concat(
this.modPackUpdateHandlers.get(null) || [],
this.modPackUpdateHandlers.get(modPack.id) || [],
);
for (let handler of handlers) {
handler(modPack);
}
}
async onModPackUpdate(id, handler) {
if (id !== null && typeof id !== "number") {
throw new Error("Invalid mod pack id");
}
let handlers = this.modPackUpdateHandlers.get(id);
if (!handlers) {
handlers = [];
this.modPackUpdateHandlers.set(id, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateModPackSubscriptions();
}
}
async offModPackUpdate(id, handler) {
let handlers = this.modPackUpdateHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for mod pack ${id} exist`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for mod pack ${id}`);
}
handlers.splice(index, 1);
if (!handlers.length) {
this.modPackUpdateHandlers.delete(id);
await this.updateModPackSubscriptions();
}
}
async updateModPackSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setModPackSubscriptions.send(this, {
all: this.modPackUpdateHandlers.has(null),
mod_pack_ids: [...this.modPackUpdateHandlers.keys()].filter(k => k !== null),
});
}
async modUpdateEventHandler(message) {
let mod = new libData.ModInfo(message.data.mod);
let handlers = [].concat(
this.modUpdateHandlers.get(null) || [],
this.modUpdateHandlers.get(mod.name) || []
);
for (let handler of handlers) {
handler(mod);
}
}
async onModUpdate(name, handler) {
if (name !== null && typeof name !== "string") {
throw new Error("Invalid mod name");
}
let handlers = this.modUpdateHandlers.get(name);
if (!handlers) {
handlers = [];
this.modUpdateHandlers.set(name, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateModSubscriptions();
}
}
async offModUpdate(name, handler) {
let handlers = this.modUpdateHandlers.get(name);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for mod ${name} exist`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for mod ${name}`);
}
handlers.splice(index, 1);
if (!handlers.length) {
this.modUpdateHandlers.delete(name);
await this.updateModSubscriptions();
}
}
async updateModSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setModSubscriptions.send(this, {
all: this.modUpdateHandlers.has(null),
mod_names: [...this.modUpdateHandlers.keys()].filter(k => k !== null),
});
}
async userUpdateEventHandler(message) {
let handlers = [].concat(
this.userUpdateHandlers.get(null) || [],
+2
View File
@@ -95,6 +95,8 @@ module.exports = (env = {}) => ({
resolve: {
extensions: [".js", ".json", ".jsx"],
fallback: {
"crypto": false,
// Required for winston
"util": require.resolve("util/"),
"os": require.resolve("os-browserify/browser"),
+9
View File
@@ -0,0 +1,9 @@
{
"name": "empty_mod",
"version": "1.0.0",
"title": "An Empty Mod",
"author": "Me",
"description": "An empty mod for testing",
"factorio_version": "1.1",
"dependencies": []
}
+190 -7
View File
@@ -5,6 +5,9 @@ const path = require("path");
const events = require("events");
const phin = require("phin");
const libBuildMod = require("@clusterio/lib/build_mod");
const libData = require("@clusterio/lib/data");
const libHash = require("@clusterio/lib/hash");
const libLink = require("@clusterio/lib/link");
const libUsers = require("@clusterio/lib/users");
const { wait } = require("@clusterio/lib/helpers");
@@ -303,13 +306,19 @@ describe("Integration of Clusterio", function() {
let exportPath = path.join("temp", "test", "static");
await fs.remove(exportPath);
await execCtl("instance export-data test");
let response = await get("/api/export-manifest");
let manifest = JSON.parse(response.body);
assert(Object.keys(manifest).length > 1, "Export manifest is empty");
assert(
await fs.exists(path.join(exportPath, "..", manifest["item-metadata"])),
"Export was not written to filesystem"
);
let result = await libLink.messages.getDefaultModPack.send(getControl());
let modPack = new libData.ModPack(result.mod_pack);
let assets = modPack.exportManifest.assets;
assert(Object.keys(assets).length > 1, "Export assets is empty");
for (let key of ["settings", "prototypes", "item-metadata", "item-spritesheet", "locale"]) {
assert(assets[key], `Missing ${key} from assets`);
assert(
await fs.exists(path.join(exportPath, assets[key])),
`Manifest entry for ${key} was not written to filesystem`
);
}
let prototypes = JSON.parse(await fs.readFile(path.join(exportPath, assets["prototypes"])));
assert(Object.keys(prototypes).length > 50, "Expected there to be more than 50 prototype types");
await checkInstanceStatus(44, "stopped");
});
});
@@ -818,6 +827,180 @@ describe("Integration of Clusterio", function() {
});
});
describe("mod-pack create", function() {
it("should create a mod-pack", async function() {
await execCtl("mod-pack create empty-pack 1.1.0");
let response = await libLink.messages.listModPacks.send(getControl());
assert(response.list.some(modPack => modPack.name === "empty-pack"), "created pack is not in the list");
});
it("should allow setting all fields", async function() {
await execCtl(
"mod-pack create full-pack 0.17.59 " +
"--description Description " +
"--mods empty_mod:1.0.0 " +
"--bool-setting startup MyBool true " +
"--int-setting runtime-global MyInt 1235 " +
"--double-setting runtime-global MyDouble 12.25 " +
"--string-setting runtime-per-user MyString a-string"
);
let response = await libLink.messages.listModPacks.send(getControl());
let modPack = response.list.find(entry => entry.name === "full-pack");
assert(modPack, "created mod pack not found");
let reference = new libData.ModPack();
reference.id = modPack.id;
reference.name = "full-pack";
reference.description = "Description";
reference.factorioVersion = "0.17.59";
reference.mods.set("empty_mod", { name: "empty_mod", enabled: true, version: "1.0.0" });
reference.settings["startup"].set("MyBool", { value: true });
reference.settings["runtime-global"].set("MyInt", { value: 1235 });
reference.settings["runtime-global"].set("MyDouble", { value: 12.25 });
reference.settings["runtime-per-user"].set("MyString", { value: "a-string" });
assert.deepEqual(new libData.ModPack(modPack), reference);
});
});
describe("mod-pack list", function() {
it("runs", async function() {
await execCtl("mod-pack list");
});
});
describe("mod-pack show", function() {
it("runs", async function() {
await execCtl("mod-pack show empty-pack");
});
});
describe("mod-pack import/export", function() {
it("should should roundtrip a mod-pack", async function() {
let reference = new libData.ModPack();
reference.name = "imported-pack";
reference.description = "Description";
reference.factorioVersion = "0.17.59";
reference.mods.set("empty_mod", { name: "empty_mod", enabled: true, version: "1.0.0" });
reference.settings["startup"].set("MyBool", { value: true });
reference.settings["runtime-global"].set("MyInt", { value: 1235 });
reference.settings["runtime-global"].set("MyDouble", { value: 12.25 });
reference.settings["runtime-per-user"].set("MyString", { value: "a-string" });
await execCtl(`mod-pack import ${reference.toModPackString()}`);
const result = await execCtl("mod-pack export imported-pack");
const roundtrip = libData.ModPack.fromModPackString(result.stdout.trim());
roundtrip.id = reference.id;
assert.deepEqual(roundtrip, reference);
});
});
describe("mod-pack edit", function() {
it("runs", async function() {
await execCtl("mod-pack edit full-pack --factorio-version 1.2.0");
let response = await libLink.messages.listModPacks.send(getControl());
let modPack = response.list.find(entry => entry.name === "full-pack");
assert(modPack, "created mod pack not found");
assert.equal(modPack.factorio_version, "1.2.0");
});
});
describe("mod-pack delete", function() {
it("deletes the pack", async function() {
await execCtl("mod-pack delete full-pack");
let response = await libLink.messages.listModPacks.send(getControl());
let modPack = response.list.find(entry => entry.name === "full-pack");
assert(!modPack, "mod pack not deleted");
});
});
describe("mod upload", function() {
it("uploads a mod", async function() {
await libBuildMod.build({
build: true,
pack: true,
sourceDir: path.join("test", "file", "empty_mod"),
outputDir: path.join("temp", "test"),
});
await execCtl("mod upload empty_mod_1.0.0.zip");
assert(
await fs.pathExists(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip")),
"mod not present in mods directory"
);
});
});
describe("mod show", function() {
it("gives details of a mod", async function() {
let result = await execCtl("mod show empty_mod 1.0.0");
let hash = await libHash.hashFile(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip"));
let stat = await fs.stat(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip"));
assert.equal(
result.stdout,
"name: empty_mod\n" +
"version: 1.0.0\n" +
"title: An Empty Mod\n" +
"author: Me\n" +
"description: An empty mod for testing\n" +
"factorio_version: 1.1\n" +
"dependencies:\n" +
`size: ${stat.size}\n` +
`sha1: ${hash}\n`,
);
});
});
describe("mod list", function() {
it("shows the list of mods", async function() {
let result = await execCtl("mod list");
assert(result.stdout.indexOf("empty_mod") !== -1, "empty_mod is not in the list");
});
});
describe("mod search", function() {
it("searches the list of mods", async function() {
let result = await execCtl("mod search 1.1 name:empty_mod");
assert(result.stdout.indexOf("empty_mod") !== -1, "empty_mod is not in the result");
});
});
describe("mod download", function() {
it("downloads a mod", async function() {
await fs.unlink(path.join("temp", "test", "empty_mod_1.0.0.zip"));
await execCtl("mod download empty_mod 1.0.0");
assert(
await fs.pathExists(path.join("temp", "test", "empty_mod_1.0.0.zip")),
"mod not downloaded to cwd"
);
});
});
describe("mod delete", function() {
it("deletes a mod", async function() {
await execCtl("mod delete empty_mod 1.0.0");
assert(
!await fs.pathExists(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip")),
"mod still present in mods dir"
);
});
});
describe("modUpdateEventHandler()", function() {
it("should have triggered for the previous mod updates", function() {
let eventsToCheck = new Set(["updated", "deleted"]);
let eventsNotSeen = new Set(eventsToCheck);
for (let modUpdate of getControl().modUpdates) {
if (modUpdate.name !== "empty_mod" || modUpdate.version !== "1.0.0") {
continue;
}
if (modUpdate.is_deleted) {
eventsNotSeen.delete("deleted");
} else {
eventsNotSeen.delete("updated");
}
}
assert(eventsNotSeen.size === 0, `Did not see the events ${[...eventsNotSeen]}`);
});
});
describe("permission list", function() {
it("runs", async function() {
await execCtl("permission list");
+23
View File
@@ -8,6 +8,7 @@ const phin = require("phin");
const util = require("util");
const events = require("events");
const libData = require("@clusterio/lib/data");
const libLink = require("@clusterio/lib/link");
const { LineSplitter } = require("@clusterio/lib/stream");
const { ConsoleTransport, logger } = require("@clusterio/lib/logging");
@@ -28,6 +29,8 @@ class TestControl extends libLink.Link {
this.slaveUpdates = [];
this.instanceUpdates = [];
this.saveListUpdates = [];
this.modUpdates = [];
this.modPackUpdates = [];
this.userUpdates = [];
this.connector.on("connect", () => {
@@ -40,6 +43,9 @@ class TestControl extends libLink.Link {
libLink.messages.setSaveListSubscriptions.send(
this, { all: true, instance_ids: [] }
).catch(err => logger.error(`Error setting save list subscriptions:\n${err.stack}`));
libLink.messages.setModSubscriptions.send(
this, { all: true, mod_names: [] }
).catch(err => logger.error(`Error setting mod subscriptions:\n${err.stack}`));
libLink.messages.setUserSubscriptions.send(
this, { all: true, names: [] }
).catch(err => logger.error(`Error setting user subscriptions:\n${err.stack}`));
@@ -67,6 +73,14 @@ class TestControl extends libLink.Link {
this.saveListUpdates.push(message.data);
}
async modUpdateEventHandler(message) {
this.modUpdates.push(message.data.mod);
}
async modPackUpdateEventHandler(message) {
this.modPackUpdates.push(message.data.mod_pack);
}
async userUpdateEventHandler(message) {
this.userUpdates.push(message.data);
}
@@ -207,6 +221,15 @@ before(async function() {
controlConnector.token = controlToken;
control = new TestControl(controlConnector);
await controlConnector.connect();
const testPack = new libData.ModPack();
testPack.id = 12;
testPack.name = "subspace_storage-pack";
testPack.factorioVersion = "1.1.0";
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: "0.1.2" });
testPack.mods.set("subspace_storage", { name: "subspace_storage", enabled: true, version: "1.99.8" });
await libLink.messages.createModPack.send(control, { mod_pack: testPack.toJSON() });
await libLink.messages.setMasterConfigField.send(control, { field: "master.default_mod_pack_id", value: "12" });
});
after(async function() {
+101
View File
@@ -0,0 +1,101 @@
"use strict";
const assert = require("assert").strict;
const fs = require("fs-extra");
const path = require("path");
const libSchema = require("@clusterio/lib/schema");
const libHash = require("@clusterio/lib/hash");
const libBuildMod = require("@clusterio/lib/build_mod");
const ModInfo = require("@clusterio/lib/data/ModInfo");
describe("lib/data/ModInfo", function() {
describe("class ModInfo", function() {
it("should round trip serialize", function() {
const validate = libSchema.compile(ModInfo.jsonSchema);
function check(pack) {
const json = JSON.parse(JSON.stringify(pack));
if (!validate(json)) {
throw validate.errors;
}
assert.deepEqual(new ModInfo(json), pack);
}
check(new ModInfo());
check(new ModInfo({ name: "MyMod" }));
check(new ModInfo({ version: "1.0.0" }));
check(new ModInfo({ title: "My Extravagent Mod" }));
check(new ModInfo({ author: "Me" }));
check(new ModInfo({ contact: "Use my email" }));
check(new ModInfo({ homepage: "It's over there" }));
check(new ModInfo({ description: "My Description" }));
check(new ModInfo({ factorio_version: "2.0" }));
check(new ModInfo({ dependencies: [] }));
check(new ModInfo({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }));
check(new ModInfo({ filename: "MyMod_1.0.0.zip" }));
check(new ModInfo({ size: 1024 }));
check(new ModInfo({ sha1: "verified-as-MyMod" }));
check(new ModInfo({ is_deleted: true }));
// All at once
check(new ModInfo({
name: "MyMod",
version: "1.0.0",
title: "My Extravagent Mod",
author: "Me",
contact: "Use my email",
homepage: "It's over there",
description: "My Description",
factorio_version: "2.0",
dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"],
filename: "MyMod_1.0.0.zip",
size: 1024,
sha1: "verified-as-MyMod",
is_deleted: true,
}));
});
it("should sort integer mod versions lexicographically", function() {
let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"];
let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"];
let mods = unsortedVersions.map(v => new ModInfo({ version: v }));
mods.sort((a, b) => a.integerVersion - b.integerVersion);
assert.deepEqual(mods.map(mod => mod.version), sortedVersions);
});
it("should sort integer factorio versions lexicographically", function() {
let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"];
let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"];
let factorioMods = unsortedVersions.map(v => new ModInfo({ factorio_version: v }));
factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion);
assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions);
});
it("should load from a mod zip file", async function() {
await libBuildMod.build({
build: true,
pack: true,
sourceDir: path.join("test", "file", "empty_mod"),
outputDir: path.join("temp", "test"),
});
const filePath = path.join("temp", "test", "empty_mod_1.0.0.zip");
const hash = await libHash.hashFile(filePath);
const stat = await fs.stat(filePath);
const mod = await ModInfo.fromModFile(filePath);
assert.deepEqual(
mod,
new ModInfo({
name: "empty_mod",
version: "1.0.0",
title: "An Empty Mod",
author: "Me",
description: "An empty mod for testing",
factorio_version: "1.1",
dependencies: [],
filename: "empty_mod_1.0.0.zip",
size: stat.size,
sha1: hash,
})
);
});
});
});
+267
View File
@@ -0,0 +1,267 @@
"use strict";
const assert = require("assert").strict;
const zlib = require("zlib");
const libSchema = require("@clusterio/lib/schema");
const ExportManifest = require("@clusterio/lib/data/ExportManifest");
const ModPack = require("@clusterio/lib/data/ModPack");
describe("lib/data/ModPack", function() {
describe("class ModPack", function() {
it("should round trip serialize", function() {
const validate = libSchema.compile(ModPack.jsonSchema);
function check(pack) {
const json = JSON.parse(JSON.stringify(pack));
if (!validate(json)) {
throw validate.errors;
}
assert.deepEqual(new ModPack(json), pack);
const packStringed = ModPack.fromModPackString(pack.toModPackString());
packStringed.id = pack.id;
packStringed.exportManifest = pack.exportManifest;
assert.deepEqual(packStringed, pack);
}
check(new ModPack());
check(new ModPack({ name: "MyPack" }));
check(new ModPack({ description: "My Description" }));
check(new ModPack({ factorio_version: "2.0" }));
check(new ModPack({ mods: [
{ name: "subspace_storage", enabled: true, version: "1.99.8" },
{ name: "clusterio_lib", enabled: true, version: "0.1.2", sha1: "012345abcd" },
]}));
check(new ModPack({ settings: {
"startup": {
"bool-setting": { "value": true },
},
"runtime-global": {
"number-setting": { "value": 123 },
},
"runtime-per-user": {
"string-setting": { "value": "a string" },
},
}}));
check(new ModPack({ export_manifest: { assets: { setting: "settings.json" }}}));
check(new ModPack({ deleted: true }));
check(new ModPack({
name: "Super pack",
description: "Every option at once.",
factorio_version: "2.0",
mods: [
{ name: "subspace_storage", enabled: true, version: "1.99.8" },
{ name: "clusterio_lib", enabled: true, version: "0.1.2", sha1: "012345abcd" },
],
settings: {
"startup": {
"bool-setting": { "value": true },
},
"runtime-global": {
"number-setting": { "value": 123 },
},
"runtime-per-user": {
"string-setting": { "value": "a string" },
},
},
export_manifest: { assets: { setting: "settings.json" }},
deleted: true,
}));
});
it("should sort integer factorio versions lexicographically", function() {
let unsortedVersions = ["1.0", "1.1.0", "0.1", "3.0.0", "1.2", "0.3.1", "0.3.3", "2.1.1", "0.0.1"];
let sortedVersions = ["0.0.1", "0.1", "0.3.1", "0.3.3", "1.0", "1.1.0", "1.2", "2.1.1", "3.0.0"];
let factorioMods = unsortedVersions.map(v => new ModPack({ factorio_version: v }));
factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion);
assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions);
});
describe(".fillDefaultSettings()", function() {
const prototypes = {
"bool-setting": {
"bool": {
name: "bool",
type: "bool-setting",
setting_type: "startup",
default_value: true,
},
},
"double-setting": {
"number": {
name: "number",
type: "double-setting",
setting_type: "runtime-global",
default_value: 123,
},
},
"string-setting": {
"string": {
name: "string",
type: "string-setting",
setting_type: "runtime-per-user",
default_value: "a string",
},
},
};
const mockLogger = { warn: () => {} };
it("should fill in defaults for settings", function() {
const pack = new ModPack();
pack.fillDefaultSettings(prototypes, mockLogger);
assert.deepEqual(pack.toJSON().settings, {
"startup": {
"bool": { "value": true },
},
"runtime-global": {
"number": { "value": 123 },
},
"runtime-per-user": {
"string": { "value": "a string" },
},
});
});
it("should not overwrite existing values", function() {
const pack = new ModPack({ settings: {
"startup": {
"bool": { "value": false },
},
"runtime-global": {
"number": { "value": 2 },
},
"runtime-per-user": {
"string": { "value": "spam" },
},
}});
pack.fillDefaultSettings(prototypes, mockLogger);
assert.deepEqual(pack.toJSON().settings, {
"startup": {
"bool": { "value": false },
},
"runtime-global": {
"number": { "value": 2 },
},
"runtime-per-user": {
"string": { "value": "spam" },
},
});
});
it("should ignore unknown setting_type and missing default_value", function() {
const pack = new ModPack();
pack.fillDefaultSettings({
"string-setting": {
"foo": {
name: "foo",
type: "string-setting",
setting_type: "magic-that-does-not-exist",
default_value: "a string",
},
"bar": {
name: "bar",
type: "string-setting",
setting_type: "startup",
},
},
}, mockLogger);
assert.deepEqual(pack.toJSON().settings, {
"startup": {},
"runtime-global": {},
"runtime-per-user": {},
});
});
});
describe(".fromModPackString()", function() {
it("should handle malformed strings", function() {
assert.throws(
() => ModPack.fromModPackString("AMalformedString"),
new Error("Malformed mod pack string: zlib inflate failed")
);
let badJsonMsg;
try {
JSON.parse("Not Json");
} catch (err) {
badJsonMsg = err.message;
}
assert.throws(
// eslint-disable-next-line node/no-sync
() => ModPack.fromModPackString(Buffer.from(zlib.deflateSync("Not Json")).toString("base64")),
new Error(`Malformed mod pack string: ${badJsonMsg}`)
);
assert.throws(
// eslint-disable-next-line node/no-sync
() => ModPack.fromModPackString(Buffer.from(zlib.deflateSync('{"i":1}')).toString("base64")),
new Error("Malformed mod pack string: Schema validation failed")
);
});
});
describe("toModSettingsDat()", function() {
it("should properly serialise settings", function() {
const pack = new ModPack({ settings: {
"startup": {
"bool-setting": { "value": true },
},
"runtime-global": {
"number-setting": { "value": 123 },
},
"runtime-per-user": {
"string-setting": { "value": "a string" },
},
}});
function istr(str) {
return [
Uint8Array.from([0, str.length]),
Buffer.from(str),
];
}
/* eslint-disable indent */
assert.deepEqual(
pack.toModSettingsDat(),
Buffer.concat([
new Uint8Array(Uint16Array.from([1, 1, 0, 0]).buffer), // version
Uint8Array.from([0]), // reserved
Uint8Array.from([5, 0]), // dictionary
new Uint8Array(Uint32Array.from([3]).buffer), // items
...istr("startup"), // name
Uint8Array.from([5, 0]), // dictionary
new Uint8Array(Uint32Array.from([1]).buffer), // items
...istr("bool-setting"), // name
Uint8Array.from([5, 0]), // dictinary
new Uint8Array(Uint32Array.from([1]).buffer), // items
...istr("value"), // name
Uint8Array.from([1, 0, 1]), // boolean
...istr("runtime-global"), // name
Uint8Array.from([5, 0]), // dictionary
new Uint8Array(Uint32Array.from([1]).buffer), // items
...istr("number-setting"), // name
Uint8Array.from([5, 0]), // dictinary
new Uint8Array(Uint32Array.from([1]).buffer), // items
...istr("value"), // name
Uint8Array.from([2, 0]), // number
new Uint8Array(Float64Array.from([123]).buffer),
...istr("runtime-per-user"), // name
Uint8Array.from([5, 0]), // dictionary
new Uint8Array(Uint32Array.from([1]).buffer), // items
...istr("string-setting"), // name
Uint8Array.from([5, 0]), // dictinary
new Uint8Array(Uint32Array.from([1]).buffer), // items
...istr("value"), // name
Uint8Array.from([3, 0, 0, "a string".length]), // string
Buffer.from("a string"),
])
);
/* eslint-enable indent */
});
});
});
});
-41
View File
@@ -8,47 +8,6 @@ const libFactorio = require("@clusterio/lib/factorio");
describe("lib/factorio", function() {
describe("findRoot()", function() {
it("should find the root of a zip", function() {
let zip = new JSZip();
zip.file("root/foo.txt", "");
zip.file("root/bar.txt", "");
assert.equal(libFactorio._findRoot(zip), "root");
});
it("should throw if files are at the root of the zip", function() {
let zip = new JSZip();
zip.file("root/foo.txt", "");
zip.file("bar.txt", "");
assert.throws(
() => libFactorio._findRoot(zip),
new Error("Zip contains file 'bar.txt' in root dir")
);
});
it("should throw if there are multiple root dirs", function() {
let zip = new JSZip();
zip.file("root-1/foo.txt", "");
zip.file("root-2/bar.txt", "");
assert.throws(
() => libFactorio._findRoot(zip),
new Error("Zip contains multiple root folders")
);
});
it("should throw if given an empty zip file", function() {
let zip = new JSZip();
assert.throws(
() => libFactorio._findRoot(zip),
new Error("Empty zip file")
);
});
});
describe("generateLoader()", function() {
let reference = [
"-- Auto generated scenario module loader created by Clusterio",
+105
View File
@@ -84,4 +84,109 @@ describe("lib/file_ops", function() {
assert.equal(await fs.readFile(target, "utf8"), "current");
});
});
describe("checkFilename()", function() {
it("should allow a basic name", function() {
libFileOps.checkFilename("file");
});
function check(item, msg) {
assert.throws(() => libFileOps.checkFilename(item), new Error(msg));
}
it("should throw on non-string", function() {
check(undefined, "must be a string");
check(null, "must be a string");
check({}, "must be a string");
check([], "must be a string");
check(0, "must be a string");
check(false, "must be a string");
});
it("should throw on empty name", function() {
check("", "cannot be empty");
});
it("should throw on <>:\"\\/|?* \\x00\\r\\n\\t", function() {
for (let char of '<>:"\\/|?*\x00\r\n\t') {
check(char, 'cannot contain <>:"\\/|=* or control characters');
}
});
it("should throw on CON, PRN, AUX, NUL, COM1, LPT1", function() {
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
check(bad, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}.zip`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}.anything.txt`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}....a`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
check(`${bad}.`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
}
});
it("should throw on . and ..", function() {
for (let bad of [".", ".."]) {
check(bad, `cannot be named ${bad}`);
}
});
it("should throw on names ending with . or space", function() {
check("a ", "cannot end with . or space");
check("a.", "cannot end with . or space");
});
});
describe("cleanFilename()", function() {
function clean(item, expected) {
assert.equal(libFileOps.cleanFilename(item), expected);
}
it("should allow a basic name", function() {
clean("file", "file");
});
function check(item, msg) {
assert.throws(() => libFileOps.cleanFilename(item), new Error(msg));
}
it("should throw on non-string", function() {
check(undefined, "name must be a string");
check(null, "name must be a string");
check({}, "name must be a string");
check([], "name must be a string");
check(0, "name must be a string");
check(false, "name must be a string");
});
it("should clean empty name", function() {
clean("", "_");
});
it("should clean <>:\"\\/|?* \\x00\\r\\n\\t", function() {
for (let char of '<>:"\\/|?*\x00\r\n\t') {
clean(char, "_");
}
});
it("should clean CON, PRN, AUX, NUL, COM1, LPT1", function() {
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
clean(bad, `${bad}_`);
clean(`${bad}.zip`, `${bad}_.zip`);
clean(`${bad}.anything.txt`, `${bad}_.anything.txt`);
clean(`${bad}....a`, `${bad}_....a`);
clean(`${bad}.`, `${bad}__`);
}
});
it("should clean . and ..", function() {
for (let bad of [".", ".."]) {
clean(bad, `${bad}_`);
}
});
it("should clean names ending with . or space", function() {
clean("a ", "a_");
clean("a.", "a_");
});
});
});
+109
View File
@@ -43,4 +43,113 @@ describe("lib/helpers", function() {
), "timeout");
});
});
function parse(input, attributes) {
const result = libHelpers.parseSearchString(input, attributes);
if (!result.issues.length) {
return result.terms;
}
return result;
}
describe("parseSearchString()", function() {
function word(value, opts = {}) {
return { type: "word", value, ...opts };
}
function attr(name, value) {
return { type: "attribute", name, value: typeof value === "string" ? word(value) : value };
}
it("should split words", function() {
assert.deepEqual(parse(""), [].map(word));
assert.deepEqual(parse("word"), ["word"].map(word));
assert.deepEqual(parse("some words here"), ["some", "words", "here"].map(word));
});
it("should trim whitespace", function() {
assert.deepEqual(parse(" "), [].map(word));
assert.deepEqual(parse("word "), ["word"].map(word));
assert.deepEqual(parse(" some words here"), ["some", "words", "here"].map(word));
});
it("should parse negated words", function() {
assert.deepEqual(parse("this -that"), [word("this"), word("that", { exclude: true })]);
assert.deepEqual(parse("not-negated"), [word("not-negated")]);
});
it("should not split quoted words", function() {
assert.deepEqual(parse('"some quoted words" here'), ["some quoted words", "here"].map(word));
assert.deepEqual(parse('"a quoted string"'), ["a quoted string"].map(word));
assert.deepEqual(parse('words and "a quoted string"'), ["words", "and", "a quoted string"].map(word));
});
it("should split on quotation mark", function() {
assert.deepEqual(parse('"quoted"unquoted'), ["quoted", "unquoted"].map(word));
assert.deepEqual(parse('"quoted""double"'), ["quoted", "double"].map(word));
assert.deepEqual(parse('word"with"quote'), ["word", "with", "quote"].map(word));
assert.deepEqual(
parse('word-"with"-negation'),
[word("word-"), word("with"), word("negation", { exclude: true })]
);
});
it("should allow colons in quotes", function() {
assert.deepEqual(parse('"word:value"'), ["word:value"].map(word));
});
it("should parse attribute:value", function() {
assert.deepEqual(parse("author:Me", { author: "word" }), [attr("author", "Me")]);
assert.deepEqual(parse("word author:Me", { author: "word" }), [word("word"), attr("author", "Me")]);
assert.deepEqual(
parse("author:Me and author:You", { author: "word" }),
[attr("author", "Me"), word("and"), attr("author", "You")]
);
assert.deepEqual(
parse("author:Me"),
{
terms: [word("Me")],
issues: ['Unregonized attribute "author", use quotes to escape colons'],
}
);
assert.deepEqual(
parse("constructor:value"),
{
terms: [word("value")],
issues: ['Unregonized attribute "constructor", use quotes to escape colons'],
}
);
});
it("should throw on bad attribute format", function() {
assert.throws(
() => parse("author:Me", { author: "bad" }),
new Error("Bad attribute format bad for author")
);
});
it("should ignore attribute case", function() {
assert.deepEqual(parse("Author:Me", { author: "word" }), [attr("author", "Me")]);
assert.deepEqual(parse("AUTHOR:Me", { author: "word" }), [attr("author", "Me")]);
assert.deepEqual(parse("auThOR:Me", { author: "word" }), [attr("author", "Me")]);
});
it("should parse partial inputs", function() {
assert.deepEqual(parse("-"), [word("", { exclude: true })]);
assert.deepEqual(parse('"'), [""].map(word));
assert.deepEqual(parse('"word'), ["word"].map(word));
assert.deepEqual(parse("author:", { author: "word" }), [attr("author", "")]);
assert.deepEqual(parse("author:-", { author: "word" }), [attr("author", word("", { exclude: true }))]);
});
});
describe("wordMatches()", function() {
const match = libHelpers.wordMatches;
it("should match any inputs", function() {
assert(match(parse("word")[0], "a word in a string"), "one text");
assert(!match(parse("word")[0], "a string without"), "one text no match");
assert(match(parse("word")[0], "no match", "no things", "a word", "bad text", ""), "five text");
assert(!match(parse("unword")[0], "no match", "no things", "a word", "bad text", ""), "five text no match");
});
it("should invert match if using exclude", function() {
assert(!match(parse("-word")[0], "a word in a string"), "one text");
assert(match(parse("-word")[0], "a string without"), "one text no match");
assert(!match(parse("-word")[0], "no match", "no things", "a word", "bad text", ""), "five text");
assert(match(parse("-unword")[0], "no match", "no things", "a word", "bad text", ""), "five text no match");
});
it("should throw on bad input", function() {
assert.throws(
() => match(parse("attr:value", { attr: "word" })[0], "a string"),
new Error("wordMatches: parameter is not a word"),
);
});
});
});
+8 -1
View File
@@ -45,9 +45,16 @@ describe("lib/plugin_loader", function() {
);
});
it("should reject on broken plugin", async function() {
let brokenMessage;
try {
// eslint-disable-next-line node/global-require
require(path.resolve(brokenPlugin, "info.js"));
} catch (err) {
brokenMessage = err.message;
}
await assert.rejects(
libPluginLoader.loadPluginInfos(new Map([["broken", path.resolve(brokenPlugin)]])),
{ message: "PluginError: Unexpected identifier" }
{ message: `PluginError: ${brokenMessage}` }
);
});
it("should reject on invalid plugin", async function() {
+49
View File
@@ -0,0 +1,49 @@
"use strict";
const assert = require("assert").strict;
const JSZip = require("jszip");
const { findRoot } = require("@clusterio/lib/zip_ops");
describe("lib/factorio", function() {
describe("findRoot()", function() {
it("should find the root of a zip", function() {
let zip = new JSZip();
zip.file("root/foo.txt", "");
zip.file("root/bar.txt", "");
assert.equal(findRoot(zip), "root");
});
it("should throw if files are at the root of the zip", function() {
let zip = new JSZip();
zip.file("root/foo.txt", "");
zip.file("bar.txt", "");
assert.throws(
() => findRoot(zip),
new Error("Zip contains file 'bar.txt' in root dir")
);
});
it("should throw if there are multiple root dirs", function() {
let zip = new JSZip();
zip.file("root-1/foo.txt", "");
zip.file("root-2/bar.txt", "");
assert.throws(
() => findRoot(zip),
new Error("Zip contains multiple root folders")
);
});
it("should throw if given an empty zip file", function() {
let zip = new JSZip();
assert.throws(
() => findRoot(zip),
new Error("Empty zip file")
);
});
});
});
-163
View File
@@ -1,11 +1,8 @@
"use strict";
const assert = require("assert").strict;
const path = require("path");
const fs = require("fs-extra");
const libLink = require("@clusterio/lib/link");
const libConfig = require("@clusterio/lib/config");
const Instance = require("@clusterio/slave/src/Instance");
const Slave = require("@clusterio/slave/src/Slave");
describe("Slave testing", function() {
@@ -13,166 +10,6 @@ describe("Slave testing", function() {
libConfig.InstanceConfig.finalize();
});
describe("checkFilename()", function() {
it("should allow a basic name", function() {
Slave._checkFilename("file");
});
function check(item, msg) {
assert.throws(() => Slave._checkFilename(item), new Error(msg));
}
it("should throw on non-string", function() {
check(undefined, "must be a string");
check(null, "must be a string");
check({}, "must be a string");
check([], "must be a string");
check(0, "must be a string");
check(false, "must be a string");
});
it("should throw on empty name", function() {
check("", "cannot be empty");
});
it("should throw on <>:\"\\/|?* \\x00\\r\\n\\t", function() {
for (let char of '<>:"\\/|?*\x00\r\n\t') {
check(char, 'cannot contain <>:"\\/|=* or control characters');
}
});
it("should throw on CON, PRN, AUX, NUL, COM1, LPT1", function() {
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
check(bad, "cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9");
}
});
it("should throw on . and ..", function() {
for (let bad of [".", ".."]) {
check(bad, "cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9");
}
});
it("should throw on names ending with . or space", function() {
check("a ", "cannot end with . or space");
check("a.", "cannot end with . or space");
});
});
describe("cleanFilename()", function() {
function clean(item, expected) {
assert.equal(Slave._cleanFilename(item), expected);
}
it("should allow a basic name", function() {
clean("file", "file");
});
function check(item, msg) {
assert.throws(() => Slave._cleanFilename(item), new Error(msg));
}
it("should throw on non-string", function() {
check(undefined, "name must be a string");
check(null, "name must be a string");
check({}, "name must be a string");
check([], "name must be a string");
check(0, "name must be a string");
check(false, "name must be a string");
});
it("should clean empty name", function() {
clean("", "_");
});
it("should clean <>:\"\\/|?* \\x00\\r\\n\\t", function() {
for (let char of '<>:"\\/|?*\x00\r\n\t') {
clean(char, "_");
}
});
it("should clean CON, PRN, AUX, NUL, COM1, LPT1", function() {
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
clean(bad, `${bad}_`);
}
});
it("should clean . and ..", function() {
for (let bad of [".", ".."]) {
clean(bad, `${bad}_`);
}
});
it("should clean names ending with . or space", function() {
clean("a ", "a_");
clean("a.", "a_");
});
});
describe("symlinkMods()", function() {
let testDir = path.join("temp", "test", "symlink");
let instance;
before(async function() {
// Remove previous test output
await fs.remove(testDir);
// Add some test mods
await fs.outputFile(path.join(testDir, "shared", "mod_a.zip"), "a");
await fs.outputFile(path.join(testDir, "shared", "mod_b.zip"), "b");
await fs.outputFile(path.join(testDir, "shared", "mod.dat"), "c");
await fs.outputFile(path.join(testDir, "shared", "mod-list.json"), "d");
let instanceConfig = new libConfig.InstanceConfig("slave");
await instanceConfig.init();
instanceConfig.set("instance.name", "test");
instance = new Instance(
{}, new libLink.VirtualConnector(), path.join(testDir, "instance"), "factorioDir", instanceConfig
);
await fs.outputFile(instance.path("mods", "mod_i.zip"), "i");
});
it("should link mods and data files", async function() {
await Instance._symlinkMods(instance, path.join(testDir, "shared"));
assert.equal(await fs.readFile(instance.path("mods", "mod_a.zip"), "utf-8"), "a");
assert.equal(await fs.readFile(instance.path("mods", "mod_b.zip"), "utf-8"), "b");
assert.equal(await fs.readFile(instance.path("mods", "mod.dat"), "utf-8"), "c");
assert.equal(await fs.readFile(instance.path("mods", "mod-list.json"), "utf-8"), "d");
assert.equal(await fs.readFile(instance.path("mods", "mod_i.zip"), "utf-8"), "i");
});
it("should ignore directories", async function() {
await fs.ensureDir(path.join(testDir, "shared", "dir"));
await Instance._symlinkMods(instance, path.join(testDir, "shared"));
assert(!await fs.exists(instance.path("mods", "dir"), "utf-8"), "dir was unxpectedly linked");
});
it("should ignore files", async function() {
await fs.outputFile(path.join(testDir, "shared", "file"), "f");
await Instance._symlinkMods(instance, path.join(testDir, "shared"));
assert(!await fs.exists(instance.path("mods", "file"), "utf-8"), "dir was unxpectedly linked");
});
it("should unlink removed mods", async function() {
// This does not work on Windows
if (process.platform === "win32") {
this.skip();
}
await fs.unlink(path.join(testDir, "shared", "mod_a.zip"));
await Instance._symlinkMods(instance, path.join(testDir, "shared"));
await assert.rejects(fs.lstat(instance.path("mods", "mod_a.zip")), { code: "ENOENT" });
assert.equal(await fs.readFile(instance.path("mods", "mod_b.zip"), "utf-8"), "b");
assert.equal(await fs.readFile(instance.path("mods", "mod.dat"), "utf-8"), "c");
assert.equal(await fs.readFile(instance.path("mods", "mod-list.json"), "utf-8"), "d");
assert.equal(await fs.readFile(instance.path("mods", "mod_i.zip"), "utf-8"), "i");
});
});
describe("discoverInstances()", function() {
it("should discover test instance", async function() {
let instancePath = path.join("test", "file", "instances");