From de8a7ef88424d7654747f95a6f57ee08a279616c Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Fri, 11 Apr 2025 22:19:11 +0200 Subject: [PATCH] Support mod portal downloads --- packages/controller/src/ControlConnection.ts | 62 +++++++++++++++++- packages/lib/src/ModStore.ts | 67 +++++++++----------- packages/lib/src/data/messages_mod.ts | 36 +++++++++++ packages/lib/src/link/messages.ts | 1 + packages/lib/src/permissions.ts | 8 ++- packages/web_ui/src/components/ModsPage.tsx | 65 ++++++++++++++----- 6 files changed, 184 insertions(+), 55 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index a1c661cc..86653a76 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -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 { + 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); } } diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 6698a431..e606b3b3 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -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 { constructor( @@ -266,8 +266,6 @@ export default class ModStore extends TypedEventEmitter release.version === version); if (latestRelease === undefined) { @@ -280,7 +278,7 @@ export default class ModStore extends TypedEventEmitter 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) { + return new this(json.name, json.version, json.factorioVersion); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 2a275125..88b9c24d 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -102,6 +102,7 @@ export const dataClasses: (RequestClass | EventClass) mod.ModDeleteRequest, mod.ModPackUpdatesEvent, mod.ModUpdatesEvent, + mod.ModPortalDownloadRequest, user.PermissionListRequest, user.RoleListRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 4e64684d..9d888a54 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -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", diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 722853da..868d8251 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -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) => ( - - - Factorio Mod Portal - - + + + + Factorio Mod Portal + + + {account.hasPermission("core.mod.download") && record.latest_release && ( + +