Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 227 additions & 4 deletions moddb/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
38 changes: 38 additions & 0 deletions moddb/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions scripts/data/enums_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down