diff --git a/moddb/client.py b/moddb/client.py index f29efb4..2aec5b0 100644 --- a/moddb/client.py +++ b/moddb/client.py @@ -1,9 +1,10 @@ from __future__ import annotations +import os import random import re import sys -from typing import TYPE_CHECKING, Any, List, Tuple, Union +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union from curl_adapter import CurlCffiAdapter import requests @@ -12,7 +13,7 @@ from .base import parse_page from .boxes import ResultList, Thumbnail, _parse_results -from .enums import Status, ThumbnailType +from .enums import AddonCategory, Licence, PlatformCategory, Status, ThumbnailType from .errors import ModdbException from .pages import Member from .utils import ( @@ -41,7 +42,7 @@ if TYPE_CHECKING: from .boxes import Comment, Tag from .enums import WatchType - from .pages import Engine, Game, Group, Mod, Review, Team + from .pages import Addon, Engine, Game, Group, Mod, Review, Team class Message: @@ -329,7 +330,7 @@ def _request(self, method, url, **kwargs): } req = requests.Request( - method, url, headers=headers, cookies=cookies, data=kwargs.pop("data", {}) + method, url, headers=headers, cookies=cookies, data=kwargs.pop("data", {}), files=kwargs.pop("files", {}) ) prepped = self._session.prepare_request(req) LOGGER.info("Request: %s", prepped.url) @@ -1178,6 +1179,228 @@ def downvote_tag(self, tag: Tag) -> bool: Whether the downvote was successful """ return self._vote_tag(tag, 1) + + def _validate_file(self, path: str, max_mbytes: float, accepted_extensions: List[str]): + # Check if is file + if not os.path.isfile(path): + raise ModdbException("Please select a valid file before uploading") + + # Check file size + file_size_mb = os.path.getsize(path) / (1024 * 1024) # b -> mb + if file_size_mb <= 0: + raise ModdbException("Your file cannot be empty") + elif file_size_mb > max_mbytes: + raise ModdbException(f"Your file must be less then {max_mbytes}mb") + + # Check file extension + file_ext = os.path.splitext(path)[1] + if file_ext not in accepted_extensions: + raise ModdbException(f"You cannot select a {file_ext} file only ({', '.join(accepted_extensions)})") + + def _validate_summary(self, text: str): + if not (50 <= len(text) <= 1000): + raise ModdbException("The summary must contain at least 50 and at most 1000 characters") + + def _validate_description(self, text: str): + if not (100 <= len(text) <= 100000): + raise ModdbException("The description must contain at least 100 and at most 100000 characters") + + def _normalize_description(self, description: str): + # TODO: implement tinymce checks + return description + + def _validate_platforms(self, platforms: List[PlatformCategory]): + if not platforms: + raise ModdbException("Select the platforms the linked mods relate to") + + def _validate_post_response(self, html_str: str): + soup_obj = soup(html_str) + if soup_obj.find("a", id="downloadmirrorstoggle"): + return # Upload successful + + # We are still on the upload form + error_tooltip = soup_obj.find("div", class_="tooltip errortooltip clear") + if error_tooltip: + if error_tooltip.ul: + error_list = error_tooltip.ul.find_all("li", recursive=False) + errors = "\n".join([f"- {error.text}" for error in error_list]) + else: + # p-tag contains a space at the beginning and a new line at the end + errors = f"- {error_tooltip.p.text.strip()}" + raise ModdbException(f"Please correct the following: \n{errors}") + + def _upload_file(self, path: str, url: str): + with open(path, 'rb') as f: + resp = self._request("POST", url, files={"filedata": f}) + if resp.json()["error"]: + raise ModdbException("An error occurred while trying to upload the add-on") + + def _prepare_file(self, file_path: str, accepted_exts: List[str], max_size_mb: float): + abs_path = os.path.abspath(file_path) + self._validate_file(abs_path, max_size_mb, accepted_exts) + return abs_path + + def get_addon_edit_data(self, addon: Addon): + html = soup(self._request("GET", f"{addon.url}/edit").text) + if not html.find("input", { "name": "formhash" }): + raise ModdbException("You do not have permission to edit the requested downloads content") + + category = AddonCategory(int(html.find("select", id="downloadscategory") + .find_all("option", { "selected": "selected" })[0]["value"])) + name = html.find("input", id="downloadsname")["value"] + summary = html.find("textarea", id="downloadssummary").text + description = html.find("textarea", id="downloadsdescription").text + platforms = list(map(lambda c: PlatformCategory(c["value"]), html.find("select", id="downloadsplatforms") + .find_all("option", { "selected": "selected" }))) + licence = Licence(int(html.find("select", id="downloadslicence") + .find_all("option", { "selected": "selected" })[0]["value"])) + credits = html.find("input", id="downloadscredit")["value"] + url = html.find("input", id="downloadsnameid")["value"] + tags = html.find("input", id="downloadstags")["value"].split(",") + + return self.EditAddonInfo(category, name, summary, description, platforms, licence, credits, url, tags) + + def upload_addon(self, mod: Mod, addon_path: str, thumbnail_path: str, category: AddonCategory, + name: str, summary: str, description: str, platforms: List[PlatformCategory], + licence: Licence = Licence.proprietary, credits: str = "", tags: List[str] = []): + html = soup(self._request("GET", f"{mod.url}/addons/add").text) + + # Retrieve data for payloads + formhash = html.find("input", { "name": "formhash" })["value"] + mod_id = html.find("select", class_="right select").find_all("option")[1]["value"] + + # Validations + self._validate_summary(summary) + self._validate_description(description) + self._validate_platforms(platforms) + description = self._normalize_description(description) + + # Files + addon_exts = html.find("input", id="downloadsfiledata")["accept"].split(",") + thumbnail_exts = html.find("input", id="downloadslogo")["accept"].split(",") + abs_addon_path = self._prepare_file(addon_path, addon_exts, 52428800 / 1024) + abs_thumbnail_path = self._prepare_file(thumbnail_path, thumbnail_exts, 8192 / 1024) + + # Upload addon file + self._upload_file(abs_addon_path, f"https://upload.moddb.com/downloads/ajax/upload/{formhash}") + + logo_file = { + "logo": open(abs_thumbnail_path, 'rb') + } + data = { + "formhash": formhash, + "legacy": 0, + "platformstemp": 1, + "filedataUp": os.path.basename(abs_addon_path), + "category": category.value, + "licence": licence.value, + "credit": credits, + "tags": ",".join(tags), + "name": name, + "summary": summary, + "description": description, + "downloads": "Please wait uploading file", + "links[]": [*map(lambda p: p.value, platforms), mod_id] + } + + resp = self._request("POST", f"{mod.url}/addons/add", data=data, files=logo_file) + self._validate_post_response(resp.text) + if logo_file["logo"]: + logo_file["logo"].close() + + def update_addon(self, addon: Addon, addon_path: Optional[str] = None, thumbnail_path: Optional[str] = None, + category: Optional[AddonCategory] = None, name: Optional[str] = None, summary: Optional[str] = None, + description: Optional[str] = None, platforms: Optional[List[PlatformCategory]] = None, + licence: Optional[Licence] = None, credits: Optional[str] = None, tags: Optional[List[str]] = None, + url: Optional[str] = None): + html = soup(self._request("GET", f"{addon.url}/edit").text) + if not html.find("input", { "name": "formhash" }): + raise ModdbException("You do not have permission to edit the requested downloads content") + + # Retrieve data for payloads + formhash = html.find("input", { "name": "formhash" })["value"] + mod_id = html.find("select", class_="right select").find_all("option")[1]["value"] + addon_data = self.get_addon_edit_data(addon) + + # Use existing values if not overridden + summary = summary or addon_data.summary + description = self._normalize_description(description or addon_data.description) + name = name or addon_data.name + url = url or addon_data.url + credits = credits or addon_data.credits + tags = tags or addon_data.tags + platforms = platforms or addon_data.platforms + category = category or addon_data.category + licence = licence or addon_data.licence + + self._validate_summary(summary) + self._validate_description(description) + self._validate_platforms(platforms) + + abs_addon_path = None + if addon_path: + addon_exts = html.find("input", id="downloadsfiledata")["accept"].split(",") + abs_addon_path = self._prepare_file(addon_path, addon_exts, 52428800 / 1024) + self._upload_file(abs_addon_path, f"https://upload.moddb.com/downloads/ajax/upload/{formhash}") + + abs_thumbnail_path = None + if thumbnail_path: + thumbnail_exts = html.find("input", id="downloadslogo")["accept"].split(",") + abs_thumbnail_path = self._prepare_file(thumbnail_path, thumbnail_exts, 8192 / 1024) + + logo_file = { + "logo": open(abs_thumbnail_path, 'rb') if abs_addon_path else None + } + data = { + "formhash": formhash, + "legacy": 0, + "platformstemp": 1, + "filedataUp": os.path.basename(abs_addon_path) if abs_addon_path else None, + "category": category.value, + "licence": licence.value, + "credit": credits, + "tags": ",".join(tags), + "nameid": url, + "name": name, + "summary": summary, + "description": description, + "downloads": "Please wait uploading file", + "links[]": [*map(lambda p: p.value, platforms), mod_id] + } + + resp = self._request("POST", f"{addon.url}/edit", data=data, files=logo_file) + self._validate_post_response(resp.text) + if logo_file["logo"]: + logo_file["logo"].close() + + class EditAddonInfo: + def __init__(self, category: AddonCategory, name: str, summary: str, description: str, + platforms: List[PlatformCategory], licence: Licence, credits: str, + url: str, tags: List[str]): + self.category = category + self.name = name + self.summary = summary + self.description = description + self.platforms = platforms + self.licence = licence + self.credits = credits + self.url = url + self.tags = tags + + def __str__(self): + return ( + f"EditAddonInfo(\n" + f" Category: {self.category},\n" + f" Name: {self.name},\n" + f" Summary: {self.summary},\n" + f" Description: {self.description},\n" + f" Platforms: {', '.join(map(str, self.platforms))},\n" + f" Licence: {self.licence},\n" + f" Credits: {self.credits},\n" + f" URL: {self.url},\n" + f" Tags: {', '.join(self.tags)}\n" + f")" + ) class TwoFactorAuthClient(Client): diff --git a/moddb/enums.py b/moddb/enums.py index 4f59f44..da3863a 100644 --- a/moddb/enums.py +++ b/moddb/enums.py @@ -170,6 +170,44 @@ class Month(enum.Enum): december = "12" +class PlatformCategory(enum.Enum): + """The category of the platform""" + + windows = "Windows|platforms1" + mac = "Mac|platforms8" + linux = "Linux|platforms7" + vr = "VR|platforms35" + ar = "AR|platforms36" + web = "Web|platforms24" + rtx = "RTX|platforms40" + flash = "Flash|platforms23" + dos = "DOS|platforms19" + steamdeck = "SteamDeck|platforms41" + ios = "iOS|platforms20" + android = "Android|platforms22" + metro = "Metro|platforms25" + xsx = "XSX|platforms39" + xone = "XONE|platforms34" + x360 = "X360|platforms2" + xbox = "XBOX|platforms18" + ps5 = "PS5|platforms38" + ps4 = "PS4|platforms32" + ps3 = "PS3|platforms4" + ps2 = "PS2|platforms17" + ps1 = "PS1|platforms16" + vita = "VITA|platforms28" + psp = "PSP|platforms5" + switch = "Switch|platforms37" + wiiu = "WiiU|platforms31" + wii = "Wii|platforms3" + gcn = "GCN|platforms15" + n64 = "N64|platforms14" + snes = "SNES|platforms13" + nes = "NES|platforms12" + ds = "DS|platforms6" + gba = "GBA|platforms11" + + # BELOW THIS LINE ENUMS ARE GENERATED AUTOMATICALLY # PR changes to scripts/generate_enums.py if you want to # change something diff --git a/scripts/data/enums_base.py b/scripts/data/enums_base.py index 6976cd3..24aecba 100644 --- a/scripts/data/enums_base.py +++ b/scripts/data/enums_base.py @@ -169,6 +169,44 @@ class Month(enum.Enum): december = "12" +class PlatformCategory(enum.Enum): + """The category of the platform""" + + windows = "Windows|platforms1" + mac = "Mac|platforms8" + linux = "Linux|platforms7" + vr = "VR|platforms35" + ar = "AR|platforms36" + web = "Web|platforms24" + rtx = "RTX|platforms40" + flash = "Flash|platforms23" + dos = "DOS|platforms19" + steamdeck = "SteamDeck|platforms41" + ios = "iOS|platforms20" + android = "Android|platforms22" + metro = "Metro|platforms25" + xsx = "XSX|platforms39" + xone = "XONE|platforms34" + x360 = "X360|platforms2" + xbox = "XBOX|platforms18" + ps5 = "PS5|platforms38" + ps4 = "PS4|platforms32" + ps3 = "PS3|platforms4" + ps2 = "PS2|platforms17" + ps1 = "PS1|platforms16" + vita = "VITA|platforms28" + psp = "PSP|platforms5" + switch = "Switch|platforms37" + wiiu = "WiiU|platforms31" + wii = "Wii|platforms3" + gcn = "GCN|platforms15" + n64 = "N64|platforms14" + snes = "SNES|platforms13" + nes = "NES|platforms12" + ds = "DS|platforms6" + gba = "GBA|platforms11" + + # BELOW THIS LINE ENUMS ARE GENERATED AUTOMATICALLY # PR changes to scripts/generate_enums.py if you want to # change something