diff --git a/README.md b/README.md index 01cf6ea..c0b62fa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Simple Universal Game Updater +# Universal Game Updater A simple, self-hosted updater for distributing game client files. Consists of three parts: @@ -68,9 +68,15 @@ Edit the configuration block at the top of `updater.py`: ```python GAME_NAME = "My Game" +UPDATER_NAME = "Launcher" +WINDOW_WIDTH = 450 +WINDOW_HEIGHT = 300 +BANNER_WIDTH = 430 +BANNER_HEIGHT = 140 + SERVER_ADDRESS = "update.mygame.com" # domain or IP -SERVER_PORT = "3232" -USE_HTTPS = False +SERVER_PORT = "443" # 80/443 for HTTP/HTTPS, or a custom port for direct IP +USE_HTTPS = True CLIENT_KEYS = { "Main": "", @@ -84,8 +90,12 @@ ICON_RELATIVE_PATH = "icon.ico" ``` - `SERVER_ADDRESS` accepts either a domain name or a raw IP — nothing else needs to change. +- `SERVER_PORT` / `USE_HTTPS` should match how your server is actually reachable. If nginx force-redirects port 80 to 443, set `SERVER_PORT = "443"` and `USE_HTTPS = True` directly rather than relying on the redirect — the updater downloads files one request at a time, so an extra 80→443 redirect on every file adds real overhead on large clients. - `CLIENT_KEYS` maps a friendly name shown in the UI to the actual access key sent in the request URL. Keys must match `config.json` on the server exactly. - `GAME_EXE_RELATIVE_PATH` is where the updater looks for the game's executable, relative to the folder the updater itself is placed in. +- `BANNER_WIDTH` / `BANNER_HEIGHT` — the banner image is always scaled to exactly this size, so it doesn't matter what dimensions the source `banner.png` actually has; the layout won't shift. + +Banner scaling uses [Pillow](https://pypi.org/project/Pillow/) if it's installed (`pip install Pillow`), for proper resizing. If Pillow isn't available, the updater falls back to Tkinter's built-in (cruder, integer-only) zoom/subsample — it'll still work, just less precisely scaled. Install Pillow for best results, and make sure it's bundled when compiling (see below). Then compile with your favorite tool, e.g. [PyInstaller](https://pyinstaller.org/): @@ -139,4 +149,5 @@ Add any player-specific files (settings, saves, screenshots, logs) here so the u - File integrity is checked with MD5 — fine for detecting changed files, not intended as a cryptographic guarantee. - The server has no built-in rate limiting or HTTPS; put it behind a reverse proxy (nginx, Caddy) if you need either. +- The server never serves directory listings — a bare `/`, an unknown key, or a path that tries to escape the client folder (`..`) all get a 404, including the server's own script/config files. - `updater.py` uses Tkinter for the UI, which ships with most standard Python installs on Windows. diff --git a/server.py b/server.py index ad60228..57ac9d5 100644 --- a/server.py +++ b/server.py @@ -18,25 +18,82 @@ HOST = CONFIG.get("host", "0.0.0.0") PORT = CONFIG.get("port", 3232) # Maps a URL access key (long hex string, see generate_key.py) -> physical -# folder on the server holding that client's files. Using a random hex key -# instead of a plain name like "main" just makes the files harder to stumble +# folder on the server holding that client's files. A random hex key instead +# of a plain name like "main" just makes the update path harder to stumble # upon by guessing; it does not stop someone who deliberately extracts the # key from the updater executable. If a key ever leaks, generate a new one # and update it here plus in the updater's CLIENT_KEYS - no need to touch # folder names or anything else. CLIENTS = CONFIG.get("clients", {}) +# Absolute, resolved base directories for each client key, computed once at +# startup. Every request is checked against these to make sure the final +# path can never land outside the intended client folder. +CLIENT_BASE_DIRS = {key: os.path.realpath(folder) for key, folder in CLIENTS.items()} + +# A path that is guaranteed not to exist, used to force a 404 through the +# normal http.server flow instead of ever falling back to serving files +# from the script's own working directory. +NONEXISTENT_PATH = os.path.join(os.path.abspath(os.sep), "__no_such_path__") + class MultiClientHandler(SimpleHTTPRequestHandler): - def translate_path(self, path): + """ + Serves files only under a URL path that starts with a known access key, + e.g. //manifest.json or //some/file. + + Everything else gets a 404: + - a bare "/" (the directory listing that exposes server.py, config.json, + the raw client folders, etc.) + - an unknown or missing key + - any path that tries to escape the client folder (e.g. via "..") + - directory listings even *inside* a valid client folder + + This is enforced by never letting execution fall through to the default + SimpleHTTPRequestHandler behavior, which serves the process's current + working directory. + """ + + def _resolve_client_path(self, path): path = urllib.parse.urlparse(path).path + path = urllib.parse.unquote(path) parts = [p for p in path.split('/') if p] - if len(parts) >= 1 and parts[0] in CLIENTS: - client_key = parts[0] - base_dir = CLIENTS[client_key] - rel_path = posixpath.join(*parts[1:]) if len(parts) > 1 else "" - return os.path.join(base_dir, rel_path) - return super().translate_path(path) + + if not parts: + return None + + key = parts[0] + if key not in CLIENT_BASE_DIRS: + return None + + base_dir = CLIENT_BASE_DIRS[key] + rel_parts = parts[1:] + rel_path = posixpath.join(*rel_parts) if rel_parts else "" + + full_path = os.path.realpath(os.path.join(base_dir, rel_path)) + + # Confirm the resolved path is still inside base_dir (blocks "..", + # symlink tricks, etc.). commonpath needs both paths to exist on the + # same drive/root, which realpath + os.path.join guarantees here. + try: + if os.path.commonpath([full_path, base_dir]) != base_dir: + return None + except ValueError: + return None + + return full_path + + def translate_path(self, path): + resolved = self._resolve_client_path(path) + if resolved is None: + return NONEXISTENT_PATH + return resolved + + def list_directory(self, path): + # Never render a directory listing, even for a path inside a valid + # client folder - only exact file paths are servable. + self.send_error(404, "Not Found") + return None if __name__ == '__main__': diff --git a/updater.py b/updater.py index 93c47f6..822d253 100644 --- a/updater.py +++ b/updater.py @@ -9,19 +9,33 @@ import subprocess import tkinter as tk from tkinter import ttk, messagebox +try: + from PIL import Image, ImageTk + HAS_PIL = True +except ImportError: + HAS_PIL = False + # ============================================================ # CONFIGURATION - edit these values for your game/project # ============================================================ GAME_NAME = "My Game" # Shown in the window title +UPDATER_NAME = "Launcher" # Updater, Launcher, etc. WINDOW_WIDTH = 450 -WINDOW_HEIGHT = 280 +WINDOW_HEIGHT = 300 + +# Banner image is scaled to exactly this size (pixels) regardless of the +# source file's actual dimensions, so the layout never shifts if someone +# drops in a differently-sized image. Keep this narrower than WINDOW_WIDTH +# and leave room for the controls below it. +BANNER_WIDTH = 430 +BANNER_HEIGHT = 140 # Server address. Use either a domain or an IP - whichever you set SERVER_ADDRESS to. # Examples: "update.mygame.com" or "123.456.78.90" -SERVER_ADDRESS = "update.mygame.com" -SERVER_PORT = "3232" -USE_HTTPS = False # Set True if your server is behind HTTPS/SSL +SERVER_ADDRESS = "example.com" +SERVER_PORT = "443" # 80/443 for HTTP/HTTPS or servcer port for direct ip +USE_HTTPS = True # Set True if your server is behind HTTPS/SSL # Available client versions: display name -> access key. # The access key is a long random hex string (generate with generate_key.py) @@ -33,8 +47,8 @@ USE_HTTPS = False # Set True if your server is behind HTTPS # generate a new one, update it here and in the server's config.json, and # rebuild the exe - no need to rename anything else. CLIENT_KEYS = { - "Main": "d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0", - "Test": "aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1", + "Main": "d5af3f9fd06b15fcfcd1e6204b10xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "Test": "aa697a8988d49f161c9764c3bb54xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", } DEFAULT_CLIENT = "Main" @@ -78,7 +92,7 @@ def build_server_base_url(): class UpdaterApp: def __init__(self, root): self.root = root - self.root.title(f"{GAME_NAME} Updater") + self.root.title(f"{GAME_NAME} {UPDATER_NAME}") self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") self.root.resizable(False, False) @@ -86,11 +100,15 @@ class UpdaterApp: self.root.iconbitmap(ICON_FILE) except Exception: pass - try: - self.bg_image = tk.PhotoImage(file=BANNER_FILE) + + self.bg_image = self._load_banner() + if self.bg_image is not None: tk.Label(root, image=self.bg_image).pack(pady=5) - except Exception: - tk.Label(root, text="[ Banner not found ]", bg="gray", width=60, height=6).pack(pady=5) + else: + tk.Label( + root, text="[ Banner not found ]", bg="gray", + width=60, height=BANNER_HEIGHT // 20 + ).pack(pady=5) # Client version selector mode_frame = tk.Frame(root) @@ -124,6 +142,45 @@ class UpdaterApp: self.btn_play = tk.Button(btn_frame, text="Play", command=self.play_game, width=20, state=btn_state) self.btn_play.pack(side="left", padx=10) + def _load_banner(self): + """ + Loads BANNER_FILE and scales it to exactly (BANNER_WIDTH, BANNER_HEIGHT). + Uses Pillow for proper resizing when available; falls back to + tk.PhotoImage's coarse integer zoom/subsample if Pillow isn't + installed, so the app still runs without the extra dependency. + Returns None if the banner file is missing or fails to load. + """ + if not os.path.exists(BANNER_FILE): + return None + + if HAS_PIL: + try: + img = Image.open(BANNER_FILE).convert("RGBA") + img = img.resize((BANNER_WIDTH, BANNER_HEIGHT), Image.LANCZOS) + return ImageTk.PhotoImage(img) + except Exception: + return None + + # Pillow not available: best-effort scaling with tk.PhotoImage. + # zoom/subsample only take integer factors, so this can't hit the + # target size exactly - it just gets as close as possible. + try: + image = tk.PhotoImage(file=BANNER_FILE) + src_w, src_h = image.width(), image.height() + if src_w <= 0 or src_h <= 0: + return image + + if src_w > BANNER_WIDTH or src_h > BANNER_HEIGHT: + factor = max(1, round(max(src_w / BANNER_WIDTH, src_h / BANNER_HEIGHT))) + image = image.subsample(factor, factor) + elif src_w < BANNER_WIDTH and src_h < BANNER_HEIGHT: + factor = max(1, round(min(BANNER_WIDTH / src_w, BANNER_HEIGHT / src_h))) + image = image.zoom(factor, factor) + + return image + except Exception: + return None + def update_ui(self, text=None, prog_val=None, prog_max=None): if text: self.root.after(0, lambda: self.status_var.set(text)) if prog_max: self.root.after(0, lambda: self.progress.config(maximum=prog_max)) @@ -218,4 +275,4 @@ class UpdaterApp: if __name__ == "__main__": root = tk.Tk() app = UpdaterApp(root) - root.mainloop() + root.mainloop() \ No newline at end of file