diff --git a/.gitignore b/.gitignore index aac4a4a..62cbe07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ database.txt -database.txt.bak +*.bak __pycache__/ */videos/* +userdata/* +*server.log diff --git a/server.py b/server.py index 86262e5..4506016 100644 --- a/server.py +++ b/server.py @@ -13,7 +13,7 @@ import subprocess running = True -ALLOWED_METHODS = {"GET", "POST", "HEAD"} +ALLOWED_METHODS = {"GET", "POST", "HEAD", "PUT"} script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) webroot_real = os.path.realpath(script_directory + "/webroot") @@ -47,7 +47,12 @@ def is_newer_than(path: str, date_str: str) -> bool: def recv_full(sock, timeout=5): """Receive a full HTTP request, not just the first 1024 bytes. Reads until the end of headers, then reads Content-Length more bytes - for the body if present.""" + for the body if present. + + Returns (header_text, body_bytes). Headers are decoded as text (safe, + always ASCII/UTF-8 in practice); the body is returned as raw bytes so + binary uploads (images, gzip, etc.) aren't corrupted by a lossy text + decode. Callers that want the body as text can decode it themselves.""" sock.settimeout(timeout) data = b"" try: @@ -58,15 +63,15 @@ def recv_full(sock, timeout=5): data += chunk # Split off headers to check Content-Length if b"\r\n\r\n" in data: - header_part, body_so_far = data.split(b"\r\n\r\n", 1) + header_bytes, body_start = data.split(b"\r\n\r\n", 1) elif b"\n\n" in data: - header_part, body_so_far = data.split(b"\n\n", 1) + header_bytes, body_start = data.split(b"\n\n", 1) else: - return data.decode(errors="replace") + return data.decode(errors="replace"), b"" # Check for Content-Length and read remaining body bytes content_length = 0 - for line in header_part.decode(errors="replace").splitlines(): + for line in header_bytes.decode(errors="replace").splitlines(): if line.lower().startswith("content-length:"): try: content_length = int(line.split(":", 1)[1].strip()) @@ -74,15 +79,25 @@ def recv_full(sock, timeout=5): pass break + # bytearray, not bytes: bytes += in a loop recopies the whole + # buffer on every chunk (quadratic time/memory as it grows). + # bytearray.extend() appends in place, so this stays linear even + # for multi-GB bodies. + body_so_far = bytearray(body_start) while len(body_so_far) < content_length: - chunk = sock.recv(4096) + chunk = sock.recv(65536) if not chunk: break - body_so_far += chunk + body_so_far.extend(chunk) - return (header_part + b"\r\n\r\n" + body_so_far).decode(errors="replace") + # Trim to Content-Length: guards against over-reads and, when no + # Content-Length was sent at all (content_length stays 0), drops + # any stray bytes so a bodyless request doesn't pick up leftovers. + body_so_far = bytes(body_so_far[:content_length]) + + return header_bytes.decode(errors="replace"), body_so_far except TimeoutError: - return data.decode(errors="replace") + return data.decode(errors="replace"), b"" blocklist_lock = threading.Lock() @@ -116,10 +131,13 @@ def handle_connection(connectionSocket, addr): fullRequestPath = "" contentType = "" - message = recv_full(connectionSocket) + header_text, requestBody = recv_full(connectionSocket) connectionSocket.settimeout(None) try: - messages = message.replace('\r\n', '\n').split('\n') + # requestBody is raw bytes (unmodified body, e.g. for uploads). + # `messages` below is built only from headers, never the body, so + # it stays text-safe even when the body isn't valid UTF-8. + messages = header_text.replace('\r\n', '\n').split('\n') requestheaders = [] for i in range(len(messages)): @@ -242,7 +260,7 @@ def handle_connection(connectionSocket, addr): responsecontent = b"

405 Method Not Allowed

" fullRequestPath = "" contentType = "text/html" - responseHeaders += b"Allow: GET, POST, HEAD\n" + responseHeaders += b"Allow: GET, POST, HEAD, PUT\n" else: # URL-decode the path before sanitizing to catch encoded traversal sequences raw_path = (messages[0].split()[1] + "?").split("?")[0] @@ -276,32 +294,13 @@ def handle_connection(connectionSocket, addr): argumentsDict[j[0]] = j[1] if responseType == "200 OK": - if os.path.exists(fullRequestPath): - if os.path.isdir(fullRequestPath): - indexFiles = ["/index.html", "/index.py"] - for i in indexFiles: - if os.path.exists(fullRequestPath + i): - fullRequestPath += i - break - else: - fullRequestPath = script_directory + "/webroot/Scripts/listFiles.py" - if not os.path.exists(fullRequestPath): - responseType = "404 Not Found" - fullRequestPath = script_directory + "/webroot/404.html" - - plainresponse = False - extensions = [".html", ".json", ".png", ".gif", ".ico", ".jpg", ".css", ".txt", ".ttf", ".mp4"] - contentTypes = ["text/html", "text/json", "image/png", "image/gif", "image/x-icon", "image/jpeg", "text/css", "text/plain", "application/octet-stream", "video/mp4"] - for i in range(len(extensions)): - if fullRequestPath.endswith(extensions[i]): - plainresponse = True - contentType = contentTypes[i] - break - - if plainresponse: - with open(fullRequestPath, "rb") as file: - responsecontent = file.read() - elif fullRequestPath.endswith(".py"): + if requestType == "PUT": + # Raw uploads (e.g. `curl -T localfile`) always route + # straight to the upload handler. The requested path is + # client-controlled (curl fills it in from the local filename) + # and generally won't correspond to a real file in webroot, so + # the normal exists()/extension routing below doesn't apply. + fullRequestPath = script_directory + "/webroot/upload/index.py" with open(fullRequestPath) as script: local_vars = locals() exec(script.read(), globals(), local_vars) @@ -310,9 +309,43 @@ def handle_connection(connectionSocket, addr): responseHeaders = local_vars.get("responseHeaders", responseHeaders) contentType = local_vars.get("contentType", contentType) else: - responseType = "415 Unsupported Media Type" - contentType = "" - responsecontent = b"" + if os.path.exists(fullRequestPath): + if os.path.isdir(fullRequestPath): + indexFiles = ["/index.html", "/index.py"] + for i in indexFiles: + if os.path.exists(fullRequestPath + i): + fullRequestPath += i + break + else: + fullRequestPath = script_directory + "/webroot/Scripts/listFiles.py" + if not os.path.exists(fullRequestPath): + responseType = "404 Not Found" + fullRequestPath = script_directory + "/webroot/404.html" + + plainresponse = False + extensions = [ ".html", ".json", ".png", ".gif", ".ico", ".jpg", ".css", ".txt", ".ttf", ".mp4", ".gz", ".img"] + contentTypes = ["text/html", "text/json", "image/png", "image/gif", "image/x-icon", "image/jpeg", "text/css", "text/plain", "application/octet-stream", "video/mp4", "application/gzip", "application/octet-stream"] + for i in range(len(extensions)): + if fullRequestPath.endswith(extensions[i]): + plainresponse = True + contentType = contentTypes[i] + break + + if plainresponse: + with open(fullRequestPath, "rb") as file: + responsecontent = file.read() + elif fullRequestPath.endswith(".py"): + with open(fullRequestPath) as script: + local_vars = locals() + exec(script.read(), globals(), local_vars) + responsecontent = local_vars.get("responsecontent", responsecontent) + responseType = local_vars.get("responseType", responseType) + responseHeaders = local_vars.get("responseHeaders", responseHeaders) + contentType = local_vars.get("contentType", contentType) + else: + responseType = "415 Unsupported Media Type" + contentType = "" + responsecontent = b"" except Exception as e: print("error: ", e) diff --git a/webroot/500partitions.img b/webroot/500partitions.img new file mode 100644 index 0000000..202dee2 Binary files /dev/null and b/webroot/500partitions.img differ diff --git a/webroot/917partitions.img b/webroot/917partitions.img new file mode 100644 index 0000000..db57d96 Binary files /dev/null and b/webroot/917partitions.img differ diff --git a/webroot/Images/profileimages/superbeelavaman.gif b/webroot/Images/profileimages/superbeelavaman.gif deleted file mode 100644 index 2d6998c..0000000 Binary files a/webroot/Images/profileimages/superbeelavaman.gif and /dev/null differ diff --git a/webroot/Images/profileimages/tech64.gif b/webroot/Images/profileimages/tech64.gif deleted file mode 100644 index 00b94c9..0000000 Binary files a/webroot/Images/profileimages/tech64.gif and /dev/null differ diff --git a/webroot/Images/wl88x31_powered.gif b/webroot/Images/wl88x31_powered.gif new file mode 100644 index 0000000..4859305 Binary files /dev/null and b/webroot/Images/wl88x31_powered.gif differ diff --git a/webroot/Images/wl88x31mw.gif b/webroot/Images/wl88x31mw.gif new file mode 100644 index 0000000..732e149 Binary files /dev/null and b/webroot/Images/wl88x31mw.gif differ diff --git a/webroot/Templates/index.html b/webroot/Templates/index.html index 355f9bd..7f933ab 100644 --- a/webroot/Templates/index.html +++ b/webroot/Templates/index.html @@ -18,7 +18,8 @@ diff --git a/webroot/Templates/oldindex.html b/webroot/Templates/oldindex.html index a65a766..5860b5f 100644 --- a/webroot/Templates/oldindex.html +++ b/webroot/Templates/oldindex.html @@ -15,7 +15,7 @@
diff --git a/webroot/content/Projects/index.html b/webroot/content/Projects/index.html index acf732b..12bc02b 100644 --- a/webroot/content/Projects/index.html +++ b/webroot/content/Projects/index.html @@ -1,7 +1,7 @@ Title: Projects
Icon: /favicon.ico
Style: /styles/newLight.css
-Description: My recent projects. +Description: My recent projects.
Author: SuperbeeLavaman
Content:
diff --git a/webroot/funny.py b/webroot/funny.py new file mode 100644 index 0000000..299e72c --- /dev/null +++ b/webroot/funny.py @@ -0,0 +1,4 @@ +contentType = "text/html" +responseType = "999 \033[31mH\033[91me\033[33ml\033[32ml\033[34mo\033[35m,\033[95m \033[31mW\033[91mo\033[33mr\033[32ml\033[34md\033[35m!\033[0m" +page = "hehehe

hellorld

" +responsecontent = page.encode() diff --git a/webroot/index.py b/webroot/index.py index fb50704..0406790 100644 --- a/webroot/index.py +++ b/webroot/index.py @@ -43,13 +43,13 @@ if "p" in argumentsDict: session_user = "" if session_status == "success": - pfp_base = os.path.realpath(script_directory + "/webroot/Images/profileimages") - pfp_real_gif = os.path.realpath(script_directory + "/webroot/Images/profileimages/" + session_user + ".gif") - pfp_real_jpg = os.path.realpath(script_directory + "/webroot/Images/profileimages/" + session_user + ".jpg") + pfp_base = os.path.realpath(script_directory + "/userdata/profileimages") + pfp_real_gif = os.path.realpath(script_directory + "/userdata/profileimages/" + session_user + ".gif") + pfp_real_jpg = os.path.realpath(script_directory + "/userdata/profileimages/" + session_user + ".jpg") if pfp_real_gif.startswith(pfp_base + os.sep) and os.path.exists(pfp_real_gif): - pfp_src = "/Images/profileimages/" + html.escape(session_user) + ".gif" + pfp_src = "/usercontent/?p=profileimages/" + html.escape(session_user) + ".gif" elif pfp_real_jpg.startswith(pfp_base + os.sep) and os.path.exists(pfp_real_jpg): - pfp_src = "/Images/profileimages/" + html.escape(session_user) + ".jpg" + pfp_src = "/usercontent/?p=profileimages/" + html.escape(session_user) + ".jpg" else: pfp_src = "/Images/default_pfp.gif" userbar = ( @@ -97,7 +97,7 @@ if "p" in argumentsDict: head = "" + title + """ - + """ + ("" if version=="new" else "") +""" diff --git a/webroot/raw/index.py b/webroot/raw/index.py index b3f791e..cf2ab57 100644 --- a/webroot/raw/index.py +++ b/webroot/raw/index.py @@ -19,7 +19,7 @@ if "p" in argumentsDict: listText = "
  • Folder: ..
  • " for i in filesList: temp = "File: " - if os.path.isdir(filePath + i.name): + if os.path.isdir(filePath + '/' + i.name): temp = "Folder: " link = path.rstrip("/") + "/" + i.name listText += ("
  • " + temp + "" + html.escape(i.name) + "
  • ") @@ -27,7 +27,8 @@ if "p" in argumentsDict: responsecontent = page.encode() else: contentType = magic.Magic(mime=True).from_file(filePath) - if contentType in (contentTypes + ["application/x-python-code", "text/x-python"]): + print(contentType) + if contentType in (contentTypes + ["application/x-python-code", "text/x-python", "text/x-script.python"]): if contentType.startswith("text"): contentType = "text/plain" responsecontent = open(filePath, "rb").read() diff --git a/webroot/test.html b/webroot/test.html new file mode 100644 index 0000000..7778c26 --- /dev/null +++ b/webroot/test.html @@ -0,0 +1,75 @@ + +
    + Profile picture preview + + + +

    +
    + + diff --git a/webroot/upload/index.py b/webroot/upload/index.py new file mode 100644 index 0000000..b6ca98d --- /dev/null +++ b/webroot/upload/index.py @@ -0,0 +1,204 @@ +# webroot/upload/index.py +# +# Three supported flows: +# +# 1. POST /upload?name=profileimages +# Profile picture upload. The username is NEVER taken from the +# client - it's derived from the validated session cookie, so one +# logged-in user can't overwrite another user's picture. The body +# can be any recognizable image format; it's center-cropped to +# square, downscaled to 32x32, and always saved as a GIF (chosen +# over JPEG for compatibility with old browsers, and because JPEG +# compression artifacts are rough at 32x32 anyway) at +# userdata/profileimages/.gif, overwriting any previous +# picture. Non-image or unreadable input -> 415. +# +# 2. POST /upload?name=somefilename.txt +# Saves the request body to userdata/somefilename.txt exactly as named. +# Response body: "Uploaded bytes to " +# +# 3. PUT with no explicit name, e.g.: +# curl https://superbeelavaman.onthewifi.com -s -T /tmp/logs.txt.gz +# curl has no path in the URL, so it auto-fills the request path with +# the local filename ("PUT /logs.txt.gz"). server.py routes ALL PUT +# requests here regardless of path (see the PUT branch in server.py), +# since that path is client-controlled and won't exist on disk. +# This flow hashes the body, keeps the extension (if any) from the +# implicit filename, saves as userdata/., and returns a +# plain-text URL in the bashupload-compatible style: +# https://superbeelavaman.onthewifi.com/usercontent/?path=. +# +# The request body (requestBody, set by server.py's recv_full()) is raw +# bytes, not a lossily-decoded string, so binary files (images, .gz, etc.) +# round-trip correctly. +# +# Flow 1 requires Pillow: pip install Pillow --break-system-packages +import hashlib +import io +from PIL import Image as PILImage + +PROFILE_IMAGE_SIZE = 32 + +USERCONTENT_URL_BASE = "https://superbeelavaman.onthewifi.com/usercontent/" +MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB +HASH_LENGTH = 16 # hex chars taken from sha256; raise for lower collision odds + +userdata_real = os.path.realpath(script_directory + "/userdata") + +if requestType not in ("POST", "PUT"): + responseType = "405 Method Not Allowed" + contentType = "text/plain" + responsecontent = b"405 Method Not Allowed. Use POST /upload?name= or PUT with -T.\n" + responseHeaders += b"Allow: POST, PUT\n" + +else: + explicit_name = urllib.parse.unquote(argumentsDict.get("name", "")).strip() + + try: + content_length = int(ClientContentLength) if ClientContentLength else 0 + except ValueError: + content_length = 0 + + if content_length <= 0: + responseType = "400 Bad Request" + contentType = "text/plain" + responsecontent = b"400 Bad Request: missing or empty request body.\n" + + elif content_length > MAX_UPLOAD_BYTES: + responseType = "413 Payload Too Large" + contentType = "text/plain" + responsecontent = b"413 Payload Too Large.\n" + + else: + # requestBody is the raw, unmodified body as bytes (see recv_full() + # / handle_connection() in server.py) - safe for binary uploads. + body_bytes = requestBody + + if len(body_bytes) > MAX_UPLOAD_BYTES: + responseType = "413 Payload Too Large" + contentType = "text/plain" + responsecontent = b"413 Payload Too Large.\n" + + elif explicit_name == "profileimages": + # --- Flow 1: profile picture upload --- + # Username comes from the session cookie, never from the + # client, so this can't be used to overwrite someone else's + # picture no matter what the request claims. + session_token = Cookies.get("session", "") + if session_token: + session_status, session_user = userManagement.validate_session(session_token) + else: + session_status, session_user = "fail", "" + + if session_status != "success": + responseType = "401 Unauthorized" + contentType = "text/plain" + responsecontent = b"401 Unauthorized: log in before uploading a profile picture.\n" + else: + try: + source_image = PILImage.open(io.BytesIO(body_bytes)) + source_image.load() # force full decode now, so bad/corrupt data 415s here + except Exception: + source_image = None + + if source_image is None: + responseType = "415 Unsupported Media Type" + contentType = "text/plain" + responsecontent = b"415 Unsupported Media Type: not a recognized image format.\n" + else: + # Center-crop to square, then downscale to 32x32. + w, h = source_image.size + side = min(w, h) + left = (w - side) // 2 + top = (h - side) // 2 + cropped = source_image.crop((left, top, left + side, top + side)) + resized = cropped.resize( + (PROFILE_IMAGE_SIZE, PROFILE_IMAGE_SIZE), PILImage.LANCZOS + ) + + # GIF has no real alpha channel (just binary transparency), + # so flatten any transparency onto white before quantizing + # down to a GIF-safe palette. + if resized.mode in ("RGBA", "LA") or ( + resized.mode == "P" and "transparency" in resized.info + ): + rgba = resized.convert("RGBA") + flattened = PILImage.new("RGB", rgba.size, (255, 255, 255)) + flattened.paste(rgba, mask=rgba.split()[3]) + else: + flattened = resized.convert("RGB") + + gif_image = flattened.convert("P", palette=PILImage.ADAPTIVE) + + pfp_dir = os.path.realpath(userdata_real + "/profileimages") + os.makedirs(pfp_dir, exist_ok=True) + + # Defense in depth: session_user should already be a + # clean username, but don't let a stray path separator + # in it escape pfp_dir. + safe_username = re.sub(r"[^A-Za-z0-9_-]", "", session_user) + target_path = os.path.realpath(pfp_dir + "/" + safe_username + ".gif") + + if not safe_username or not target_path.startswith(pfp_dir + os.sep): + responseType = "403 Forbidden" + contentType = "text/plain" + responsecontent = b"403 Forbidden.\n" + else: + gif_image.save(target_path, format="GIF") + responseType = "200 OK" + contentType = "text/plain" + responsecontent = ( + "Profile picture updated: profileimages/" + safe_username + ".gif\n" + ).encode() + + elif explicit_name: + # --- Flow 2: explicit ?name=... (unchanged prior behavior) --- + filename = os.path.basename(explicit_name) + if ( + filename in (".", "..") + or filename.startswith(".") + or not re.fullmatch(r"[A-Za-z0-9._-]{1,128}", filename) + ): + responseType = "400 Bad Request" + contentType = "text/plain" + responsecontent = b"400 Bad Request: invalid 'name' parameter.\n" + else: + os.makedirs(userdata_real, exist_ok=True) + target_path = os.path.realpath(userdata_real + "/" + filename) + if not target_path.startswith(userdata_real + os.sep): + responseType = "403 Forbidden" + contentType = "text/plain" + responsecontent = b"403 Forbidden.\n" + else: + with open(target_path, "wb") as f: + f.write(body_bytes) + responseType = "200 OK" + contentType = "text/plain" + responsecontent = ( + "Uploaded " + str(len(body_bytes)) + " bytes to " + filename + "\n" + ).encode() + + else: + # --- Flow 3: no name given -> hash-name it, return a URL --- + # Pull an extension (if any) from curl's implicit filename, i.e. + # the request path it auto-fills for `-T`, e.g. "/logs.txt.gz". + implicit_name = os.path.basename(requestPath.rstrip("/")) + _, ext = os.path.splitext(implicit_name) + if not re.fullmatch(r"\.[A-Za-z0-9]{1,16}", ext or ""): + ext = "" + + file_hash = hashlib.sha256(body_bytes).hexdigest()[:HASH_LENGTH] + filename = file_hash + ext + + os.makedirs(userdata_real, exist_ok=True) + target_path = os.path.realpath(userdata_real + "/" + filename) + if not target_path.startswith(userdata_real + os.sep): + responseType = "403 Forbidden" + contentType = "text/plain" + responsecontent = b"403 Forbidden.\n" + else: + with open(target_path, "wb") as f: + f.write(body_bytes) + responseType = "200 OK" + contentType = "text/plain" + responsecontent = (USERCONTENT_URL_BASE + "?p=" + filename + "\n").encode() diff --git a/webroot/usercontent/index.py b/webroot/usercontent/index.py new file mode 100644 index 0000000..6923a2e --- /dev/null +++ b/webroot/usercontent/index.py @@ -0,0 +1,45 @@ +page = "" +responseType = "200 OK" +contentType = "text/html" + +if "p" in argumentsDict: + path = urllib.parse.unquote(argumentsDict["p"]) + + userdata_real = os.path.realpath(script_directory + "/userdata") + filePath = os.path.realpath(script_directory + "/userdata/" + path) + if not filePath.startswith(userdata_real + os.sep) and filePath != userdata_real: + responseType = "403 Forbidden" + contentType = "text/html" + responsecontent = b"

    403 Forbidden

    " + elif os.path.exists(filePath): + if os.path.isdir(filePath): + filesList = list(os.scandir(filePath)) + page = open(script_directory + "/webroot/Templates/fileList.html").read() + page = page.replace("", html.escape(path)) + listText = "
  • Folder: ..
  • " + for i in filesList: + temp = "File: " + if os.path.isdir(filePath + i.name): + temp = "Folder: " + link = path.rstrip("/") + "/" + i.name + listText += ("
  • " + temp + "" + html.escape(i.name) + "
  • ") + page = page.replace("", listText) + responsecontent = page.encode() + else: + contentType = magic.Magic(mime=True).from_file(filePath) + if contentType in (contentTypes + ["application/x-python-code", "text/x-python"]): + if contentType.startswith("text"): + contentType = "text/plain" + responsecontent = open(filePath, "rb").read() + else: + responseType = "415 Unsupported Media Type" + contentType = "" + responsecontent = b"" + else: + responseType = "404 Not Found" + fullRequestPath = script_directory + "/webroot/404.html" + responsecontent = open(fullRequestPath, "rb").read() +else: + responseType = "400 Bad Request" + fullRequestPath = script_directory + "/webroot/400.html" + responsecontent = open(fullRequestPath, "rb").read()