Skip to content

Commit

Permalink
ci: Automatically publish releases to GameBanana
Browse files Browse the repository at this point in the history
  • Loading branch information
psyGamer committed Oct 27, 2024
1 parent 51ac33a commit 5cfe393
Show file tree
Hide file tree
Showing 3 changed files with 497 additions and 4 deletions.
51 changes: 47 additions & 4 deletions .github/workflows/Release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@ jobs:
uses: actions/checkout@v4
with:
submodules: 'recursive'

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0'
- name: Setup Python
uses: actions/[email protected]
with:
python-version: 3.12.6
- name: Setup Firefox WebDriver
uses: browser-actions/[email protected]

- name: Download Studio builds
uses: actions/download-artifact@v4
Expand Down Expand Up @@ -54,11 +61,47 @@ jobs:
run: |
dotnet build CelesteTAS-EverestInterop -c Release -p:DefineConstants=INSTALL_STUDIO -p:UseSymlinks=false
- name: Create release
uses: marvinpinto/action-automatic-releases@latest
- name: Setup Python environment
run: |
python -m venv .venv
source .venv/bin/activate
pip install requests selenium
- name: Generate release-changelog
run: |
source .venv/bin/activate
python Scripts/generate_release.py "$(git log -1 --pretty=%B)" version_info.txt gamebanana_changelog.json github_changelog.md
env:
GITHUB_REPO: ${{ github.repository }}
GITHUB_TOKEN: ${{ github.token }}

- name: Prepare releases
run: |
# Version GameBanana .zip without v prefix
RELEASE_FILE="CelesteTAS_$(head -n 1 version_info.txt | cut -c 2-).zip"
cp CelesteTAS.zip $RELEASE_FILE
echo "RELEASE_FILE=$RELEASE_FILE" >> $GITHUB_ENV
# Setup GitHub release title
RELEASE_TITLE="$(sed -n "1p" version_info.txt) (Studio $(sed -n "2p" version_info.txt))"
echo "RELEASE_TITLE=$RELEASE_TITLE" >> $GITHUB_ENV
- name: Upload GameBanana release
run: |
source .venv/bin/activate
python Scripts/gamebanana_upload.py ${{ env.RELEASE_FILE }} gamebanana_changelog.json version_info.txt
env:
GAMEBANANA_USERNAME: AutomaticRelease
GAMEBANANA_PASSWORD: ${{ secrets.GAMEBANANA_PASSWORD }}
GAMEBANANA_2FA_URI: ${{ secrets.GAMEBANANA_2FA_URI }}
GAMEBANANA_MODID: 546692
GAMEBANANA_ISTOOL: 0

- name: Upload GitHub release
uses: softprops/action-gh-release@v1
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
prerelease: false
name: ${{ env.RELEASE_TITLE }}
body_path: github_changelog.md
files: |
CelesteTAS.zip
CelesteStudio-windows-x64.zip
Expand Down
233 changes: 233 additions & 0 deletions Scripts/gamebanana_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import os
import sys
import requests
import json
import time
import hmac
import hashlib
import base64
import struct
import urllib.parse
from dataclasses import dataclass
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.firefox.options import Options

# Common User-Agent to pretent to be a real user
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36"

def main():
file_path = sys.argv[1]
update_json_path = sys.argv[2]
version_info_path = sys.argv[3]

update_json = None
with open(update_json_path, "r") as f:
update_json = json.loads(f.read())

celestetas_version = None
studio_version = None
with open(version_info_path, "r") as f:
lines = f.readlines()
celestetas_version = lines[0].strip()
studio_version = lines[1].strip()

# Setup browser
options = Options()
options.add_argument("--headless")

profile = webdriver.FirefoxProfile()
profile.set_preference("general.useragent.override", user_agent)

driver = webdriver.Firefox(options=options)

# Login
driver.get("https://gamebanana.com/members/account/login")
driver.implicitly_wait(5)
time.sleep(5)

# Remove cookie banner
print("Removing cookie banner...", end=" ", flush=True)
driver.execute_script("$('.fc-consent-root').remove()")
print("Done", flush=True)
driver.implicitly_wait(1)
time.sleep(1)

print("Performing username + password login...", end=" ", flush=True)
driver.find_element(By.ID, "_sUsername").click()
driver.find_element(By.ID, "_sUsername").send_keys(os.getenv("GAMEBANANA_USERNAME"))
driver.find_element(By.ID, "_sPassword").click()
driver.find_element(By.ID, "_sPassword").send_keys(os.getenv("GAMEBANANA_PASSWORD"))
driver.execute_script("$('#UsernameLoginForm button').click()")
print("Done", flush=True)

driver.implicitly_wait(5)
time.sleep(5)

# Enter 2FA code if needed
if driver.current_url == "https://gamebanana.com/members/account/login":
print("Entering 2FA code...", end=" ", flush=True)
driver.find_element(By.ID, "_nTotp").send_keys(compute_twofac_code(os.getenv("GAMEBANANA_2FA_URI")))
print("Done", flush=True)

driver.implicitly_wait(5)
time.sleep(5)
else:
print(f"2FA not needed")

is_tool = os.getenv('GAMEBANANA_ISTOOL') == "1"

driver.get(f"https://gamebanana.com/{"tools" if is_tool else "mods"}/edit/{os.getenv('GAMEBANANA_MODID')}")
driver.implicitly_wait(5)
time.sleep(5)

# Check exiting file count
beforeFileCount = driver.execute_script("return $(\"fieldset[id='Files'] ul[id$='_UploadedFiles'] li\").length")

if beforeFileCount >= 20:
print("Deleting oldest file...", end=" ", flush=True)
# Need to delete oldest file to have enough space
driver.execute_script("$(\"fieldset[id='Files'] ul[id$='_UploadedFiles'] li:last button\").click()")

wait = WebDriverWait(driver, timeout=2)
alert = wait.until(lambda d : d.switch_to.alert)
alert.accept()

print("Done.", flush=True)
driver.implicitly_wait(1)
time.sleep(1)

# Upload file
print("Uploading new file...", end=" ", flush=True)
driver.find_element(By.CSS_SELECTOR, "fieldset#Files input[id$='_FileInput']").send_keys(os.path.join(os.getcwd(), file_path))
wait = WebDriverWait(driver, timeout=15, poll_frequency=.2)
wait.until(lambda d : beforeFileCount != driver.execute_script("$(\"return fieldset[id='Files'] ul[id$='_UploadedFiles'] li\").length"))
print("Done.", flush=True)
driver.implicitly_wait(5)
time.sleep(5)

# Reorder to be the topmost
print("Reordering new file to the top...", end=" ", flush=True)
driver.execute_script("$(\"fieldset[id='Files'] ul[id$='_UploadedFiles'] li:last\").prependTo(\"fieldset[id='Files'] ul[id$='_UploadedFiles']\")")
print("Done.", flush=True)
driver.implicitly_wait(1)
time.sleep(1)

# Add description
print("Adding description to file...", end=" ", flush=True)
desc = f"CelesteTAS {celestetas_version}, Studio {studio_version}"
driver.execute_script(f"$(\"fieldset[id='Files'] ul[id$='_UploadedFiles'] li:first .DescriptionInput\")[0].value = '{desc}'")
print("Done.", flush=True)
driver.implicitly_wait(1)
time.sleep(1)

# Store file ID
file_id = driver.execute_script(f"return $(\"fieldset[id='Files'] ul[id$='_UploadedFiles'] li:first input[name='_idFileRow']\")[0].value")

# Submit edit
print("Submitting edit...", end=" ", flush=True)
driver.execute_script("$('.Submit > button').click()")
driver.implicitly_wait(15)
time.sleep(15)
print("Done.", flush=True)

# Add update
print("Adding update...", end=" ", flush=True)

driver.execute_script(f"""
fetch("https://gamebanana.com/apiv11/{"Tool" if is_tool else "Mod"}/{os.getenv('GAMEBANANA_MODID')}/Update", {{
"credentials": "include",
"headers": {{
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en,en-US;q=0.5",
"Content-Type": "application/json",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Sec-GPC": "1",
"Priority": "u=0"
}},
"referrer": "https://gamebanana.com/{"tools" if is_tool else "mods"}/{os.getenv('GAMEBANANA_MODID')}",
"body": '{json.dumps({
"_aChangeLog": update_json,
"_aFileRowIds": [file_id],
"_sName": f"CelesteTAS {celestetas_version} / Studio {studio_version}",
"_sVersion": celestetas_version,
})}',
"method": "POST",
"mode": "cors"
}});
""")

driver.implicitly_wait(5)
time.sleep(5)
print("Done.", flush=True)

driver.quit()


def compute_twofac_code(uri: str) -> str:
secret, period, digits, algorithm = parse_otpauth_uri(uri)

# Get the current time step (based on period)
current_time = int(time.time())
time_step = current_time // period

# Generate the TOTP token
return get_totp_token(secret, time_step, digits, algorithm)

def parse_otpauth_uri(uri):
# Parse the URI
parsed_uri = urllib.parse.urlparse(uri)
query_params = urllib.parse.parse_qs(parsed_uri.query)

# Extract the secret and other parameters
secret = query_params.get('secret', [None])[0]
period = int(query_params.get('period', [30])[0])
digits = int(query_params.get('digits', [6])[0])
algorithm = query_params.get('algorithm', ['SHA1'])[0].upper()

return secret, period, digits, algorithm

def base32_decode(encoded_secret):
# Add padding if necessary
missing_padding = len(encoded_secret) % 8
if missing_padding != 0:
encoded_secret += '=' * (8 - missing_padding)
# Decode the base32 secret
return base64.b32decode(encoded_secret.upper())

def get_totp_token(secret, time_step, digits=6, algorithm='SHA1'):
# HMAC key is the decoded secret
key = base32_decode(secret)

# Convert time_step to bytes (8-byte integer)
time_step_bytes = struct.pack('>Q', time_step)

# Choose the hash function (default to SHA1)
if algorithm == 'SHA1':
hash_function = hashlib.sha1
elif algorithm == 'SHA256':
hash_function = hashlib.sha256
elif algorithm == 'SHA512':
hash_function = hashlib.sha512
else:
raise ValueError(f"Unsupported algorithm: {algorithm}")

# Compute HMAC hash
hmac_hash = hmac.new(key, time_step_bytes, hash_function).digest()

# Extract dynamic binary code from HMAC hash
offset = hmac_hash[-1] & 0x0F
truncated_hash = hmac_hash[offset:offset + 4]
truncated_hash = struct.unpack('>I', truncated_hash)[0] & 0x7FFFFFFF

# Get the last 'digits' digits of the number
totp_token = truncated_hash % (10 ** digits)

return totp_token

if __name__ == "__main__":
main()
Loading

0 comments on commit 5cfe393

Please sign in to comment.