Support mod portal downloads

This commit is contained in:
Danielv123 committed 2025-04-11 22:19:11 +02:00
1 parent 2936973a75
commit de8a7ef884
6 files changed
+184 -55

No files matched your search

+60 -2
View File
@@ -118,6 +118,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this));
this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -537,9 +538,66 @@ export default class ControlConnection extends BaseConnection {
// The Response class is defined inline within the Request class
return new lib.ModPortalGetAllRequest.Response(mods);
} catch (error: any) {
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error.message}`);
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
// Propagate a user-friendly error back to the client
throw new lib.RequestError(`Portal mod fetch failed: ${error.message}`);
throw new lib.RequestError(`Portal mod fetch failed: ${error}`);
}
}
/**
* Handle request to download a mod from the Factorio Mod Portal to the controller.
* @param request - Request object with mod name, version, and factorioVersion.
*/
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise<lib.ModInfo> {
const { name, version, factorioVersion } = request;
const filename = lib.ModInfo.filename(name, version);
// Check if mod already exists
if (this._controller.modStore.files.has(filename)) {
logger.verbose(`Mod ${filename} already exists, skipping download.`);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this._controller.modStore.files.get(filename)!;
}
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
if (!username || !token) {
// Attempt download without credentials - might fail for some mods
logger.warn(`Factorio username or token not configured. Attempting anonymous download for ${filename}.`);
// Note: The Factorio Mod Portal API might reject downloads without credentials.
// Consider throwing an error here if credentials are required by your setup.
// throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
try {
logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`);
const modMap = new Map([[name, version]]);
await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion);
// Verify download and return ModInfo
const downloadedMod = this._controller.modStore.files.get(filename);
if (!downloadedMod) {
throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`);
}
logger.info(`Successfully downloaded mod ${filename}.`);
return downloadedMod;
} catch (error: any) {
logger.error(`Error downloading mod ${filename} from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = `Mod portal download failed for ${name} v${version}`;
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
} else {
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
}
+30 -37
View File
@@ -65,7 +65,7 @@ interface ModPortalResponse {
}
const MOD_PORTAL_API_URL = "https://mods.factorio.com/api/mods";
const MOD_PORTAL_MAX_PAGE_SIZE = 10000; // Or largest allowed by API
const MOD_PORTAL_MAX_PAGE_SIZE = 1000; // Or largest allowed by API
export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, ModStoreEvents> {
constructor(
@@ -266,8 +266,6 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
) {
if (releases.length === 0) { }
// releases.find() // work on this
const latestRelease = releases.find(release => release.version === version);
if (latestRelease === undefined) {
@@ -280,7 +278,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
const fileName = `${this.modsDirectory}/${latestRelease.file_name}.tmp`;
await ModStore.downloadFile(url, fileName);
await fs.rename(fileName, `${this.modsDirectory}/${latestRelease.file_name}`);
this.addMod(await this.loadFile(`${this.modsDirectory}/${latestRelease.file_name}`));
this.addMod(await this.loadFile(latestRelease.file_name));
}
private async downloadModsChunk(
@@ -473,43 +471,38 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
let allMods: ModDetails[] = [];
let hasMorePages = true;
try {
while (hasMorePages) {
const url = new URL(MOD_PORTAL_API_URL);
url.searchParams.set("page_size", String(MOD_PORTAL_MAX_PAGE_SIZE));
url.searchParams.set("version", factorioVersion);
url.searchParams.set("page", String(currentPage));
url.searchParams.set("hide_deprecated", String(hide_deprecated));
while (hasMorePages) {
const url = new URL(MOD_PORTAL_API_URL);
url.searchParams.set("page_size", String(MOD_PORTAL_MAX_PAGE_SIZE));
url.searchParams.set("version", factorioVersion);
url.searchParams.set("page", String(currentPage));
url.searchParams.set("hide_deprecated", String(hide_deprecated));
logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`);
const response = await fetch(url.toString());
logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`);
const response = await fetch(url.toString());
if (!response.ok) {
let errorDetail = "";
try {
errorDetail = await response.text();
} catch (bodyError) {
logger.warn("Failed to read response body for failed portal request");
}
let errorMessage = `Mod portal fetch page ${currentPage} failed: `;
errorMessage += `${response.status} ${response.statusText}`;
if (errorDetail) {
errorMessage += ` - ${errorDetail}`;
}
throw new Error(errorMessage);
if (!response.ok) {
let errorDetail = "";
try {
errorDetail = await response.text();
} catch (bodyError) {
logger.warn("Failed to read response body for failed portal request");
}
const data = await response.json() as ModPortalResponse;
allMods = allMods.concat(data.results);
hasMorePages = currentPage < data.pagination.page_count;
currentPage += 1;
let errorMessage = `Mod portal fetch page ${currentPage} failed: `;
errorMessage += `${response.status} ${response.statusText}`;
if (errorDetail) {
errorMessage += ` - ${errorDetail}`;
}
throw new Error(errorMessage);
}
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
} catch (err: any) {
logger.error(err);
throw new Error(err);
const data = await response.json() as ModPortalResponse;
allMods = allMods.concat(data.results);
hasMorePages = currentPage < data.pagination.page_count;
currentPage += 1;
}
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
}
}
+36
View File
@@ -355,3 +355,39 @@ export class ModUpdatesEvent {
return new this(json.updates.map(update => ModInfo.fromJSON(update)));
}
}
/**
* Request mods to be downloaded from the Factorio mod portal to the controller.
*
* The controller handles the actual download process asynchronously.
* Requires Factorio credentials to be configured on the controller if the portal
* requires authentication for downloads.
*
* @param name - Name of the mod to download.
* @param version - Version of the mod to download.
* @param factorioVersion - Factorio version context for the download.
*/
export class ModPortalDownloadRequest {
declare ["constructor"]: typeof ModPortalDownloadRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.download_from_portal" as const;
static Response = ModInfo;
constructor(
public name: string,
public version: string,
public factorioVersion: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"factorioVersion": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.version, json.factorioVersion);
}
}
+1
View File
@@ -102,6 +102,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModDeleteRequest,
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
user.PermissionListRequest,
user.RoleListRequest,
+7 -1
View File
@@ -387,7 +387,7 @@ definePermission({
});
definePermission({
name: "core.mod.upload",
title: "Upload mod",
title: "Upload mods",
description: "Upload mods to the controller.",
});
definePermission({
@@ -401,6 +401,12 @@ definePermission({
title: "Delete mod",
description: "Delete mods stored on the controller.",
});
definePermission({
name: "core.mod.download_from_portal",
title: "Download mods from portal",
description: "Allow downloading mods directly from the Factorio mod portal to the controller.",
grantByDefault: true,
});
definePermission({
name: "core.permission.list",
+50 -15
View File
@@ -1,7 +1,11 @@
import React, { Fragment, useState, useContext, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip } from "antd";
import { ImportOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
import {
Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification,
} from "antd";
import {
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
} from "@ant-design/icons";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
@@ -471,19 +475,50 @@ function SearchModsButton() {
key: "actions",
align: "center",
render: (_, record) => (
<Tooltip title="Open in Factorio Mod Portal">
<Typography.Link
href={`https://mods.factorio.com/mod/${record.name}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://mods.factorio.com/static/favicon.ico"
alt="Factorio Mod Portal"
style={{ height: "1em", verticalAlign: "middle" }}
/>
</Typography.Link>
</Tooltip>
<Space>
<Tooltip title="Open in Factorio Mod Portal">
<Typography.Link
href={`https://mods.factorio.com/mod/${record.name}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://mods.factorio.com/static/favicon.ico"
alt="Factorio Mod Portal"
style={{ height: "1em", verticalAlign: "middle" }}
/>
</Typography.Link>
</Tooltip>
{account.hasPermission("core.mod.download") && record.latest_release && (
<Tooltip title="Download to Controller">
<Button
type="text"
icon={<DownloadOutlined />}
disabled={!record.latest_release?.version}
onClick={() => {
if (record.latest_release?.version) {
control.send(
new lib.ModPortalDownloadRequest(
record.name,
record.latest_release.version,
factorioVersion
)
).then(() => {
notification.success({
message: "Download started",
description: `${record.title || record.name} v${
record.latest_release?.version
} is being downloaded to the controller.`,
});
}).catch(
notifyErrorHandler("Error starting mod download")
);
}
}}
/>
</Tooltip>
)}
</Space>
),
},
]}