Merge pull request #456 from Hornwitser/save-transfer

Save transfer
This commit is contained in:
Hornwitser authored and GitHub committed 2022-03-30 22:51:04 +02:00
commit ca6464baeb
10 files changed
+712 -104

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@
- Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins.
- Added centralized logging for the cluster.
All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried.
- Added remote save management with the ability to list, create, upload, download, delete and load saves for instances.
- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances.
### Features
+74 -10
View File
@@ -533,8 +533,11 @@ async function loadMapSettings(args) {
};
}
instanceCommands.add(new libCommand.Command({
definition: ["list-saves <instance>", "list saves on an instance", (yargs) => {
const instanceSaveCommands = new libCommand.CommandTree({
name: "save", alias: ["s"], description: "Instance save management",
});
instanceSaveCommands.add(new libCommand.Command({
definition: ["list <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args, control) {
@@ -548,8 +551,8 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["create-save <instance> [name]", "Create a new save on an instance", (yargs) => {
instanceSaveCommands.add(new libCommand.Command({
definition: ["create <instance> [name]", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
yargs.options({
@@ -573,8 +576,40 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["upload-save <instance> <filepath>", "Upload a save to an instance", (yargs) => {
instanceSaveCommands.add(new libCommand.Command({
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.renameSave.send(control, {
instance_id: instanceId,
old_name: args.oldName,
new_name: args.newName,
});
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
yargs.positional("source", { describe: "Save to copy.", type: "string" });
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.copySave.send(control, {
instance_id: instanceId,
source: args.source,
destination: args.destination,
});
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["upload <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
@@ -624,8 +659,36 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["download-save <instance> <save>", "Download a save from an instance", (yargs) => {
instanceSaveCommands.add(new libCommand.Command({
definition: [
"transfer <source-instance> <source-save> <target-instance> [target-save]",
"Transfer a save between instances",
(yargs) => {
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
yargs.options({
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
});
},
],
handler: async function(args, control) {
let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance);
let result = await libLink.messages.transferSave.send(control, {
instance_id: sourceInstanceId,
source_save: args.sourceSave,
target_instance_id: targetInstanceId,
target_save: args.targetSave || args.sourceSave,
copy: args.copy,
});
print(`Transferred as ${result.save} to ${args.targetInstance}.`);
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["download <instance> <save>", "Download a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
@@ -669,8 +732,8 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["delete-save <instance> <save>", "Delete a save from an instance", (yargs) => {
instanceSaveCommands.add(new libCommand.Command({
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
@@ -682,6 +745,7 @@ instanceCommands.add(new libCommand.Command({
});
},
}));
instanceCommands.add(instanceSaveCommands);
instanceCommands.add(new libCommand.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
+38
View File
@@ -550,6 +550,28 @@ messages.createSave = new Request({
},
});
messages.renameSave = new Request({
type: "rename_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.rename",
forwardTo: "instance",
requestProperties: {
"old_name": { type: "string" },
"new_name": { type: "string" },
},
});
messages.copySave = new Request({
type: "copy_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.copy",
forwardTo: "instance",
requestProperties: {
"source": { type: "string" },
"destination": { type: "string" },
},
});
messages.deleteSave = new Request({
type: "delete_save",
links: ["control-master", "master-slave"],
@@ -573,6 +595,22 @@ messages.downloadSave = new Request({
},
});
messages.transferSave = new Request({
type: "transfer_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.transfer",
requestProperties: {
"instance_id": { type: "integer" },
"source_save": { type: "string" },
"target_instance_id": { type: "integer" },
"target_save": { type: "string" },
"copy": { type: "boolean" },
},
responseProperties: {
"save": { type: "string" },
},
});
messages.pullSave = new Request({
type: "pull_save",
links: ["master-slave"],
+20 -5
View File
@@ -379,7 +379,7 @@ definePermission({
});
definePermission({
name: "core.instance.save.list",
title: "List saves on instance",
title: "List saves",
description: "List the saves currently on the instance.",
});
definePermission({
@@ -389,22 +389,37 @@ definePermission({
});
definePermission({
name: "core.instance.save.create",
title: "Create new instance save",
title: "Create new save",
description: "Create new savegames on instances.",
});
definePermission({
name: "core.instance.save.rename",
title: "Rename save",
description: "Rename savegames on instances.",
});
definePermission({
name: "core.instance.save.copy",
title: "Copy save",
description: "Create copies of savegames on instances.",
});
definePermission({
name: "core.instance.save.delete",
title: "Delete instance save",
title: "Delete save",
description: "Delete savegames on instances.",
});
definePermission({
name: "core.instance.save.upload",
title: "Upload save to instance",
title: "Upload save",
description: "Upload savegames to instances.",
});
definePermission({
name: "core.instance.save.transfer",
title: "Transfer save",
description: "Transfer savegames between instances.",
});
definePermission({
name: "core.instance.save.download",
title: "Download save from instance",
title: "Download save",
description: "Download savegames from instances.",
});
definePermission({
+86 -28
View File
@@ -1,5 +1,6 @@
"use strict";
const jwt = require("jsonwebtoken");
const events = require("events");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
@@ -159,10 +160,7 @@ class ControlConnection extends BaseConnection {
async getInstanceRequestHandler(message) {
let id = message.data.id;
let instance = this._master.instances.get(id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${id} does not exist`);
}
let instance = this._master.getRequestInstance(id);
return {
id,
@@ -249,11 +247,7 @@ class ControlConnection extends BaseConnection {
}
async deleteInstanceRequestHandler(message, request) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
if (instance.config.get("instance.assigned_slave") !== null) {
await this.forwardRequestToInstance(message, request);
}
@@ -266,11 +260,7 @@ class ControlConnection extends BaseConnection {
}
async getInstanceConfigRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
return {
serialized_config: instance.config.serialize("control"),
};
@@ -290,11 +280,7 @@ class ControlConnection extends BaseConnection {
}
async setInstanceConfigFieldRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
if (message.data.field === "instance.assigned_slave") {
throw new libErrors.RequestError("instance.assigned_slave must be set through the assign-slave interface");
}
@@ -309,11 +295,7 @@ class ControlConnection extends BaseConnection {
}
async setInstanceConfigPropRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
let { field, prop, value } = message.data;
instance.config.setProp(field, prop, value, "control");
await this.updateInstanceConfig(instance);
@@ -321,10 +303,7 @@ class ControlConnection extends BaseConnection {
async assignInstanceCommandRequestHandler(message, request) {
let { slave_id, instance_id } = message.data;
let instance = this._master.instances.get(instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(instance_id);
// Check if target slave is connected
let newSlaveConnection;
@@ -395,6 +374,85 @@ class ControlConnection extends BaseConnection {
return { stream_id: stream.id };
}
async transferSaveRequestHandler(message, request) {
let { source_save, target_save, copy, instance_id, target_instance_id } = message.data;
if (copy) {
this.user.checkPermission("core.instance.save.copy");
} else if (source_save !== target_save) {
this.user.checkPermission("core.instance.save.rename");
}
if (instance_id === target_instance_id) {
throw new libErrors.RequestError("Source and target instance may not be the same");
}
let sourceInstance = this._master.getRequestInstance(instance_id);
let targetInstance = this._master.getRequestInstance(target_instance_id);
let sourceSlaveId = sourceInstance.config.get("instance.assigned_slave");
let targetSlaveId = targetInstance.config.get("instance.assigned_slave");
if (sourceSlaveId === null) {
throw new libErrors.RequestError("Source instance is not assigned a slave");
}
if (targetSlaveId === null) {
throw new libErrors.RequestError("Target instance is not assigned a slave");
}
// Let slave handle request if source and target is on the same slave.
if (sourceSlaveId === targetSlaveId) {
return await this.forwardRequestToInstance(message, request);
}
// Check connectivity
let sourceSlaveConnection = this._master.wsServer.slaveConnections.get(sourceSlaveId);
if (!sourceSlaveConnection || sourceSlaveConnection.closing) {
throw new libErrors.RequestError("Source slave is not connected to the master server");
}
let targetSlaveConnection = this._master.wsServer.slaveConnections.get(targetSlaveId);
if (!targetSlaveConnection || targetSlaveConnection.closing) {
throw new libErrors.RequestError("Target slave is not connected to the master server");
}
// Create stream to proxy from target to source
let stream = await routes.createProxyStream(this._master.app);
stream.events.on("timeout", () => {
if (stream.source) {
stream.source.destroy();
}
stream.events.emit("error", new libErrors.RequestError("Timed out establishing transfer stream"));
});
// Ignore errors if not listening for them to avoid crash.
stream.events.on("error", () => { /* ignore */ });
// Establish push from source slave to stream, this is done first to
// ensure the file size is known prior to the target slave pull.
await Promise.all([
this._master.forwardRequestToInstance(libLink.messages.pushSave, {
instance_id,
stream_id: stream.id,
save: source_save,
}),
events.once(stream.events, "source"),
]);
// Establish pull from target slave to stream and wait for completion.
let result = await this._master.forwardRequestToInstance(libLink.messages.pullSave, {
instance_id: target_instance_id,
stream_id: stream.id,
filename: target_save,
});
// Delete source save if this is not a copy
if (!copy) {
await this._master.forwardRequestToInstance(libLink.messages.deleteSave, {
instance_id,
save: source_save,
});
}
return { save: result.save };
}
async setLogSubscriptionsRequestHandler(message) {
this.logSubscriptions = message.data;
this.updateLogSubscriptions();
+16 -5
View File
@@ -424,6 +424,21 @@ class Master {
}
}
/**
* Get instance by ID for a request
*
* @param {number} instanceId - ID of instance to get.
* @returns {object} instance
* @throws {module:lib/errors.RequestError} if the instance does not exist.
*/
getRequestInstance(instanceId) {
let instance = this.instances.get(instanceId);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
addInstanceHooks(instance) {
instance.config.on("fieldChanged", (group, field, prev) => {
if (group.name === "instance" && field === "name") {
@@ -608,11 +623,7 @@ class Master {
* @returns {object} data returned from the request.
*/
async forwardRequestToInstance(request, data) {
let instance = this.instances.get(data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${data.instance_id} does not exist`);
}
let instance = this.getRequestInstance(data.instance_id);
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) {
throw new libErrors.RequestError("Instance is not assigned to a slave");
+104 -37
View File
@@ -1077,6 +1077,15 @@ function checkFilename(name) {
}
}
function checkRequestSaveName(name) {
try {
checkFilename(name);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
}
/**
* Handles running the slave
*
@@ -1351,6 +1360,14 @@ class Slave extends libLink.Link {
}
}
getRequestInstanceInfo(instanceId) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instanceInfo;
}
/**
* Initialize and connect an unloaded instance
*
@@ -1358,11 +1375,7 @@ class Slave extends libLink.Link {
* @returns {module:slave/slave~InstanceConnection} connection to instance.
*/
async _connectInstance(instanceId) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
let instanceInfo = this.getRequestInstanceInfo(instanceId);
if (this.instanceConnections.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`);
}
@@ -1424,11 +1437,7 @@ class Slave extends libLink.Link {
return await request.send(instanceConnection, message.data);
}
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
let instanceInfo = this.getRequestInstanceInfo(instanceId);
return {
list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null),
};
@@ -1440,6 +1449,85 @@ class Slave extends libLink.Link {
await request.send(instanceConnection, message.data);
}
async renameSaveRequestHandler(message) {
let { instance_id, old_name, new_name } = message.data;
checkRequestSaveName(old_name);
checkRequestSaveName(new_name);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.move(
path.join(instanceInfo.path, "saves", old_name),
path.join(instanceInfo.path, "saves", new_name),
{ overwrite: false },
);
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${old_name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async copySaveRequestHandler(message) {
let { instance_id, source, destination } = message.data;
checkRequestSaveName(source);
checkRequestSaveName(destination);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.copy(
path.join(instanceInfo.path, "saves", source),
path.join(instanceInfo.path, "saves", destination),
{ overwrite: false, errorOnExist: true },
);
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${old_name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async transferSaveRequestHandler(message) {
let { source_save, target_save, copy, instance_id, target_instance_id } = message.data;
checkRequestSaveName(source_save);
checkRequestSaveName(target_save);
let sourceInstanceInfo = this.getRequestInstanceInfo(instance_id);
let targetInstanceInfo = this.getRequestInstanceInfo(target_instance_id);
// For consistency with remote transfer initiated through pullSave the
// target is renamed if it already exists.
target_save = await libFileOps.findUnusedName(
path.join(targetInstanceInfo.path, "saves"), target_save, ".zip"
);
try {
if (copy) {
await fs.copy(
path.join(sourceInstanceInfo.path, "saves", source_save),
path.join(targetInstanceInfo.path, "saves", target_save),
{ overwrite: true },
);
} else {
await fs.move(
path.join(sourceInstanceInfo.path, "saves", source_save),
path.join(targetInstanceInfo.path, "saves", target_save),
{ overwrite: true },
);
}
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${source_save} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(sourceInstanceInfo.path, "saves"));
await this.sendSaveListUpdate(target_instance_id, path.join(targetInstanceInfo.path, "saves"));
return { save: target_save };
}
async sendSaveListUpdate(instance_id, savesDir) {
let instanceConnection = this.instanceConnections.get(instance_id);
let saveList;
@@ -1454,15 +1542,8 @@ class Slave extends libLink.Link {
async deleteSaveRequestHandler(message) {
let { instance_id, save } = message.data;
try {
checkFilename(save);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
checkRequestSaveName(save);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.unlink(path.join(instanceInfo.path, "saves", save));
@@ -1477,15 +1558,8 @@ class Slave extends libLink.Link {
async pullSaveRequestHandler(message) {
let { instance_id, stream_id, filename } = message.data;
try {
checkFilename(filename);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
checkRequestSaveName(filename);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
let url = new URL(this.config.get("slave.master_url"));
url.pathname += `api/stream/${stream_id}`;
@@ -1528,15 +1602,8 @@ class Slave extends libLink.Link {
async pushSaveRequestHandler(message) {
let { instance_id, stream_id, save } = message.data;
try {
checkFilename(save);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
checkRequestSaveName(save);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
let content;
try {
+176 -1
View File
@@ -1,5 +1,8 @@
import React, { useContext, useState } from "react";
import { Button, List, Popconfirm, Progress, Space, Table, Tooltip, Upload } from "antd";
import {
message, Button, Checkbox, Form, Input, List, Modal,
Popconfirm, Progress, Select, Space, Table, Tooltip, Upload,
} from "antd";
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
import LeftOutlined from "@ant-design/icons/LeftOutlined";
@@ -9,10 +12,173 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
import { useInstanceList } from "../model/instance";
import { useSaves } from "../model/saves";
import { notifyErrorHandler } from "../util/notify";
function RenameModal(props) {
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
return <>
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Rename</Button>
<Modal
title="Rename save"
okText="Rename"
visible={visible}
onOk={() => form.submit()}
onCancel={() => setVisible(false)}
>
<Form
form={form}
layout="vertical"
initialValues={{ newName: props.save.name }}
onFinish={values => {
libLink.messages.renameSave.send(control,
{ instance_id: props.instanceId, old_name: props.save.name, new_name: values.newName }
).then(() => {
setVisible(false);
form.resetFields();
}).catch(notifyErrorHandler("Error renaming save"));
}}
>
<Form.Item
name="newName"
label="New name"
rules={[{ required: true, message: "New name is required" }]}
>
<Input autoFocus />
</Form.Item>
</Form>
</Modal>
</>;
}
function CopyModal(props) {
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
return <>
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Copy</Button>
<Modal
title="Copy save"
okText="Copy"
visible={visible}
onOk={() => form.submit()}
onCancel={() => setVisible(false)}
>
<Form
form={form}
layout="vertical"
initialValues={{ newName: props.save.name }}
onFinish={values => {
libLink.messages.copySave.send(control,
{ instance_id: props.instanceId, source: props.save.name, destination: values.newName }
).then(() => {
setVisible(false);
form.resetFields();
}).catch(notifyErrorHandler("Error copying save"));
}}
>
<Form.Item
name="newName"
label="New name"
rules={[{ required: true, message: "New name is required" }]}
>
<Input autoFocus />
</Form.Item>
</Form>
</Modal>
</>;
}
function TransferModal(props) {
let account = useAccount();
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
let [instanceList] = useInstanceList();
return <>
<Button disabled={props.disabled} onClick={() => setVisible(true)}>Transfer</Button>
<Modal
title="Transfer save"
okText="Transfer"
visible={visible}
onOk={() => form.submit()}
onCancel={() => setVisible(false)}
destroyOnClose
>
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
initialValues={{ transferredName: props.save.name, copy: false }}
onFinish={values => {
setVisible(false);
let hide = message.loading("Transferring save...", 0);
libLink.messages.transferSave.send(control, {
instance_id: props.instanceId,
source_save: props.save.name,
target_instance_id: values.targetInstanceId,
target_save: values.transferredName || props.save.name,
copy: values.copy,
}).then(() => {
message.success("Transfer complete");
form.resetFields();
}).catch(
notifyErrorHandler("Error transfering save")
).finally(() => hide());
}}
>
<Form.Item
name="targetInstanceId"
label="Target Instance"
rules={[{ required: true, message: "Target Instance is required" }]}
>
<Select
autoFocus
showSearch
filterOption={(input, option) => option.title.toLowerCase().indexOf(input.toLowerCase()) >= 0}
>
{instanceList.filter(
instance => instance["id"] !== props.instanceId
).map((instance) => <Select.Option
key={instance["id"]}
value={instance["id"]}
title={instance["name"]}
disabled={["unassigned", "unknown"].includes(instance["status"])}
>
{instance["name"]}
{instance["status"] === "unassigned" && " (unassigned)"}
{instance["status"] === "unknown" && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>
<Form.Item
name="transferredName"
label="Transferred name"
disabled={!account.hasAnyPermission("core.instance.save.rename", "core.instance.save.copy")}
>
<Input />
</Form.Item>
<Form.Item
name="copy"
valuePropName="checked"
label="Copy"
tooltip="Copy instead of moving the save to the new instance."
disabled={!account.hasPermission("core.instance.save.copy")}
>
<Checkbox />
</Form.Item>
</Form>
</Modal>
</>;
}
function formatBytes(bytes) {
if (bytes === 0) {
return "0 Bytes";
@@ -81,6 +247,12 @@ export default function SavesList(props) {
);
}}
>Load save</Button>}
{account.hasPermission("core.instance.save.rename") && <RenameModal
disabled={slaveOffline} instanceId={props.instance.id} save={save}
/>}
{account.hasPermission("core.instance.save.copy") && <CopyModal
disabled={slaveOffline} instanceId={props.instance.id} save={save}
/>}
{account.hasPermission("core.instance.save.download") && <Button
disabled={slaveOffline}
onClick={() => {
@@ -95,6 +267,9 @@ export default function SavesList(props) {
);
}}
>Download</Button>}
{account.hasPermission("core.instance.save.transfer") && <TransferModal
disabled={slaveOffline} instanceId={props.instance.id} save={save}
/>}
{account.hasPermission("core.instance.save.delete") && <Popconfirm
title="Permanently delete save?"
okText="Delete"
+195 -16
View File
@@ -3,6 +3,7 @@ const assert = require("assert").strict;
const fs = require("fs-extra");
const path = require("path");
const events = require("events");
const phin = require("phin");
const libLink = require("@clusterio/lib/link");
const libUsers = require("@clusterio/lib/users");
@@ -25,6 +26,24 @@ async function checkInstanceStatus(id, status) {
assert.equal(instances.get(44).status, status, "incorrect instance status");
}
async function uploadSave(instanceId, name, content) {
return await phin({
url: `https://localhost:4443/api/upload-save?instance_id=${instanceId}&filename=${name}`,
method: "POST",
core: { rejectUnauthorized: false },
headers: {
"X-Access-Token": controlToken,
"Content-Type": "application/zip",
"Content-Length": String(content.length),
},
data: content,
});
}
async function deleteSave(instanceId, save) {
await libLink.messages.deleteSave.send(getControl(), { instance_id: instanceId, save });
}
describe("Integration of Clusterio", function() {
describe("clusteriomaster", function() {
describe("bootstrap generate-user-token", function() {
@@ -115,10 +134,14 @@ describe("Integration of Clusterio", function() {
let config = "alt-slave-config.json";
let configPath = path.join("temp", "test", config);
await fs.remove(configPath);
await fs.remove(path.join("temp", "test", "alt-instances"));
await execCtl(`slave create-config --id 5 --name alt-slave --generate-token --output ${config}`);
await exec(
`node ../../packages/slave --config ${config} config set slave.tls_ca ../../test/file/tls/cert.pem`
);
await exec(
`node ../../packages/slave --config ${config} config set slave.instances_directory alt-instances`
);
let slaveProcess;
try {
@@ -250,11 +273,11 @@ describe("Integration of Clusterio", function() {
});
});
describe("instance create-save", function() {
describe("instance save create", function() {
it("creates a save", async function() {
slowTest(this);
getControl().saveListUpdates = [];
await execCtl("instance create-save test");
await execCtl("instance save create test");
await checkInstanceStatus(44, "stopped");
});
});
@@ -266,10 +289,10 @@ describe("Integration of Clusterio", function() {
});
});
describe("instance list-saves", function() {
describe("instance save list", function() {
it("lists the created save", async function() {
slowTest(this);
let result = await execCtl("instance list-saves test");
let result = await execCtl("instance save list test");
assert(/world\.zip/.test(result.stdout), "world.zip not present in list save output");
});
});
@@ -530,10 +553,10 @@ describe("Integration of Clusterio", function() {
});
});
describe("instance upload-save", function() {
describe("instance save upload", function() {
it("should upload a zip file", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await execCtl("instance upload-save 44 upload.zip");
await execCtl("instance save upload 44 upload.zip");
assert(
await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
"file not uploaded to saves directory"
@@ -541,42 +564,198 @@ describe("Integration of Clusterio", function() {
});
it("should reject non-zip files", async function() {
await fs.outputFile(path.join("temp", "test", "invalid"), "a test");
await assert.rejects(execCtl("instance upload-save 44 invalid"));
await assert.rejects(execCtl("instance save upload 44 invalid"));
});
it("should reject path traversal attacks", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await assert.rejects(execCtl("instance upload-save 44 upload.zip --name ../traversal.zip"));
await assert.rejects(execCtl("instance save upload 44 upload.zip --name ../traversal.zip"));
});
});
describe("instance download-save", function() {
describe("instance save download", function() {
it("should download a save", async function() {
await fs.remove(path.join("temp", "test", "upload.zip"));
await execCtl("instance download-save 44 upload.zip");
await execCtl("instance save download 44 upload.zip");
assert(await fs.pathExists(path.join("temp", "test", "upload.zip")));
});
it("should error if save does not exist", async function() {
await assert.rejects(execCtl("instance download-save 44 invalid"));
await assert.rejects(execCtl("instance save download 44 invalid"));
});
it("should error on path traversal attacks", async function() {
await assert.rejects(execCtl("instance download-save 44 ../factorio-current.log"));
await assert.rejects(execCtl("instance save download 44 ../factorio-current.log"));
});
});
describe("instance delete-save", function() {
describe("instance save copy", function() {
it("copy a save file", async function() {
await execCtl("instance save copy 44 upload.zip copy.zip");
assert(await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "copy.zip")));
});
it("should error if destination exist", async function() {
await assert.rejects(execCtl("instance save copy 44 upload.zip copy.zip"));
});
it("should error if source does not exist", async function() {
await assert.rejects(execCtl("instance save copy 44 not-here.zip invalid.zip"));
});
it("should reject path traversal attacks", async function() {
await assert.rejects(execCtl("instance save copy 44 upload.zip ../traversal.zip"));
await assert.rejects(execCtl("instance save copy 44 ../saves/upload.zip traversal.zip "));
});
});
describe("instance save rename", function() {
it("rename a save file", async function() {
await execCtl("instance save rename 44 copy.zip rename.zip");
assert(!await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "copy.zip")));
assert(await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "rename.zip")));
});
it("should error if new name exist", async function() {
await assert.rejects(execCtl("instance save rename 44 upload.zip rename.zip"));
});
it("should error if old name does not exist", async function() {
await assert.rejects(execCtl("instance save rename 44 not-here.zip invalid.zip"));
});
it("should reject path traversal attacks", async function() {
await assert.rejects(execCtl("instance save rename 44 upload.zip ../traversal.zip"));
await assert.rejects(execCtl("instance save rename 44 ../saves/upload.zip traversal.zip "));
});
});
describe("instance save transfer", function() {
before(async function() {
slowTest(this);
await execCtl("instance create spam --id 66");
await execCtl("instance assign spam 4");
await execCtl("instance create unassign --id 77");
});
after(async function() {
await execCtl("instance delete spam");
});
for (let remote of [false, true]) {
let pri = 44;
let sec = remote ? 99 : 66;
let priSaves = path.join("temp", "test", "instances", "test", "saves");
let secSaves = remote
? path.join("temp", "test", "alt-instances", "alt-test", "saves")
: path.join("temp", "test", "instances", "spam", "saves")
;
describe(remote ? "remote" : "local", function() {
if (remote) {
let slaveProcess;
before(async function() {
// On windows there's currently no way to automate graceful shutdown of the slave
// process as CTRL+C is some weird terminal thing and SIGINT isn't a thing.
if (process.platform === "win32") {
this.skip();
}
slowTest(this);
// Reuse from the clusterioslave test
let config = "alt-slave-config.json";
slaveProcess = await spawn(
"alt-slave:", `node ../../packages/slave run --config ${config}`, /Started slave/
);
});
after(async function() {
if (slaveProcess) {
slaveProcess.kill("SIGINT");
await events.once(slaveProcess, "exit");
}
});
}
it("should transfers a save", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await execCtl(`instance save transfer ${pri} transfer.zip ${sec}`);
assert(!await fs.pathExists(path.join(priSaves, "transfer.zip")), "save still at pri");
assert(await fs.pathExists(path.join(secSaves, "transfer.zip")), "save not at sec");
await deleteSave(sec, "transfer.zip");
});
it("should support rename", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await execCtl(`instance save transfer ${pri} transfer.zip ${sec} rename.zip`);
assert(!await fs.pathExists(path.join(priSaves, "transfer.zip")), "save still at pri");
assert(await fs.pathExists(path.join(secSaves, "rename.zip")), "save not at sec");
await deleteSave(sec, "rename.zip");
});
it("should auto-rename if target exists", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await uploadSave(sec, "transfer.zip", "transfer.zip");
await execCtl(`instance save transfer ${pri} transfer.zip ${sec}`);
assert(!await fs.pathExists(path.join(priSaves, "transfer.zip")), "save still at pri");
assert(await fs.pathExists(path.join(secSaves, "transfer-2.zip")), "save not at sec");
await deleteSave(sec, "transfer.zip");
await deleteSave(sec, "transfer-2.zip");
});
it("should copy when using --copy", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await execCtl(`instance save transfer ${pri} transfer.zip ${sec} --copy`);
assert(await fs.pathExists(path.join(priSaves, "transfer.zip")), "save not at pri");
assert(await fs.pathExists(path.join(secSaves, "transfer.zip")), "save not at sec");
await deleteSave(pri, "transfer.zip");
await deleteSave(sec, "transfer.zip");
});
it("should fail if save does not exist", async function() {
await assert.rejects(execCtl(`instance save transfer ${pri} not-here.zip ${sec}`));
});
it("should fail if source save name is invalid", async function() {
await assert.rejects(execCtl(`instance save transfer ${pri} nul 111`));
});
it("should fail if target save name is invalid", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 111 nul`));
await deleteSave(pri, "transfer.zip");
});
it("should reject path traversal of source save", async function() {
await assert.rejects(execCtl(`instance save transfer ${pri} ../saves/transfer.zip 111`));
});
it("should reject path traversal of target save", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await assert.rejects(
execCtl(`instance save transfer ${pri} transfer.zip 111 ../saves/transfer.zip`)
);
await deleteSave(pri, "transfer.zip");
});
if (!remote) {
it("should fail if source and target instance is the same instance", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip ${pri}`));
await deleteSave(pri, "transfer.zip");
});
it("should fail if source instance does not exist", async function() {
await assert.rejects(execCtl(`instance save transfer 111 not-here.zip ${sec}`));
});
it("should fail if target instance does not exist", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 111`));
await deleteSave(pri, "transfer.zip");
});
it("should fail if source instance is not assigned", async function() {
await assert.rejects(execCtl(`instance save transfer 77 transfer.zip ${pri}`));
});
it("should fail if target instance is not assigned", async function() {
await uploadSave(pri, "transfer.zip", "transfer.zip");
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 77`));
await deleteSave(pri, "transfer.zip");
});
}
});
}
});
describe("instance save delete", function() {
it("should delete a save", async function() {
await execCtl("instance delete-save 44 upload.zip");
await execCtl("instance save delete 44 upload.zip");
assert(
!await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
"file not deleted"
);
});
it("should error if save does not exist", async function() {
await assert.rejects(execCtl("instance delete-save 44 upload.zip"));
await assert.rejects(execCtl("instance save delete 44 upload.zip"));
});
it("should error on path traversal attacks", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await assert.rejects(execCtl("instance delete-save 44 ../../upload.zip"));
await assert.rejects(execCtl("instance save delete 44 ../../upload.zip"));
});
});
+2 -1
View File
@@ -109,7 +109,8 @@ let slaveConfigPath = path.join("temp", "test", "config-slave.json");
let controlConfigPath = path.join("temp", "test", "config-control.json");
async function exec(command, options = {}) {
console.log(command);
// Uncomment to show commands run in tests
// console.log(command);
options = { cwd: path.join("temp", "test"), ...options };
return await util.promisify(child_process.exec)(command, options);
}