Several changes I should have committed sooner, they would be better

with seperate commits but I never got around to it
This commit is contained in:
Logan Joy-Koer
2026-08-23 21:00:22 -07:00
parent 7e9816e52d
commit 853004d433
17 changed files with 419 additions and 54 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
database.txt
database.txt.bak
*.bak
__pycache__/
*/videos/*
userdata/*
*server.log
+48 -15
View File
@@ -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"<h1>405 Method Not Allowed</h1>"
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,6 +294,21 @@ def handle_connection(connectionSocket, addr):
argumentsDict[j[0]] = j[1]
if responseType == "200 OK":
if requestType == "PUT":
# Raw uploads (e.g. `curl <server> -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)
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:
if os.path.exists(fullRequestPath):
if os.path.isdir(fullRequestPath):
indexFiles = ["/index.html", "/index.py"]
@@ -290,8 +323,8 @@ def handle_connection(connectionSocket, addr):
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"]
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
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+2 -1
View File
@@ -18,7 +18,8 @@
<footer class="gray">
<p style="margin: 0px; display:flex; align-items: center; justify-content: space-between;">
<span style="display: flex; align-items: center; margin-right:10px;">(c) 2026 Logan Joy-Koer (SuperbeeLavaman)
<a style="margin-left: 10px" href="http://wii-linux.org/"><img style="display: block;" src="Images/wl88x31.gif" alt="Wii-Linux NOW!" class=no-print></a></span>
<a style="margin-left: 10px" href="http://wii-linux.org/"><img style="display: block;" src="Images/wl88x31.gif" alt="Wii-Linux NOW!" class=no-print></a>
<img style="margin-left: 0px; display: block;" src="Images/wl88x31mw.gif" alt="Made with Wii-Linux"></span>
<span style="float: left;">This page last updated: <!--updateddate--></span>
</p>
</footer>
+1 -1
View File
@@ -15,7 +15,7 @@
<br>
</article>
<footer>
<p style="margin: 0px; display:flex; align-items:center;"><span style="margin-right: 10px;">(c) 2026 Logan Joy-Koer (SuperbeeLavaman)</span><a style="display: inline-block;" href="http://wii-linux.org/"><img style="margin: 0px; display: block;" src="Images/wl88x31.gif" alt="Wii-Linux NOW!"></a></p>
<p style="margin: 0px; display:flex; align-items:center;"><span style="margin-right: 10px;">(c) 2026 Logan Joy-Koer (SuperbeeLavaman)</span><a style="display: inline-block;" href="http://wii-linux.org/"><img style="margin: 0px; display: block;" src="Images/wl88x31.gif" alt="Wii-Linux NOW!"></a><img style="margin: 0px; display: block;" src="Images/wl88x31mw.gif" alt="Made with Wii-Linux"></p>
</footer>
</div>
</body>
+1 -1
View File
@@ -1,7 +1,7 @@
Title: Projects<br>
Icon: /favicon.ico<br>
Style: /styles/newLight.css<br>
Description: My recent projects.
Description: My recent projects.<br>
Author: SuperbeeLavaman<br>
Content:
<div class="article-content-block">
+4
View File
@@ -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 = "<html><head><title>hehehe</title></head><body><p>hellorld</p></body></html>"
responsecontent = page.encode()
+6 -6
View File
@@ -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>" + title + """</title>
<link rel=\"shortcut icon\" type=\"image/x-icon\" href=\"""" + icon + """\">
<link rel=\"icon\" type=\"image/x-icon\" href=\"""" + icon + """\">
<link rel=\"stylesheet\" type=\"text/css\" href=\"/styles/newCommon.css\">
""" + ("<link rel=\"stylesheet\" type=\"text/css\" href=\"/styles/newCommon.css\">" if version=="new" else "") +"""
<link rel=\"stylesheet\" type=\"text/css\" href=\"""" + html.escape(style) + """\">
<meta charset=\"UTF-8\">
<meta name=\"description\" content=\"""" + description + """\">
+3 -2
View File
@@ -19,7 +19,7 @@ if "p" in argumentsDict:
listText = "<li>Folder: <a href=\"/raw/?p=" + html.escape(os.path.dirname(path)) + "\">..</a></li>"
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 += ("<li>" + temp + "<a href=\"/raw/?p=" + html.escape(link) + "\">" + html.escape(i.name) + "</a></li>")
@@ -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()
+75
View File
@@ -0,0 +1,75 @@
<!--
Profile picture upload widget.
Drop into a Content: block.
Client-side, this only checks that the file is *some* readable image -
no type or dimension restriction, since the server now accepts any
recognizable image format, center-crops it to square, downscales to
32x32, and always transcodes to GIF. The username is derived from the
session cookie server-side, never from the client.
-->
<div id="pfp-upload">
<img id="pfp-preview" src="/Images/default_pfp.gif" alt="Profile picture preview"
style="width:64px; height:64px; image-rendering:pixelated; display:block; margin-bottom:8px; border:1px solid #888;">
<input type="file" id="pfp-file" accept="image/*">
<p id="pfp-status" style="margin-top:8px;"></p>
</div>
<script>
(function () {
const fileInput = document.getElementById("pfp-file");
const preview = document.getElementById("pfp-preview");
const status = document.getElementById("pfp-status");
function setStatus(msg, isError) {
status.textContent = msg;
status.style.color = isError ? "#c0392b" : "#2c7a2c";
}
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
setStatus("Checking image...", false);
let objectUrl;
try {
// Just confirm the browser can decode it as an image at all - the
// actual format whitelist, cropping, and resizing happens server-side.
await new Promise((resolve, reject) => {
objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => resolve();
img.onerror = () => reject(new Error("That doesn't look like a readable image."));
img.src = objectUrl;
});
// Looks good locally - show the preview immediately.
preview.src = objectUrl;
setStatus("Uploading...", false);
const response = await fetch("/upload?name=profileimages", {
method: "POST",
credentials: "same-origin", // send the session cookie
body: file,
});
const text = await response.text();
if (!response.ok) {
setStatus("Upload failed: " + text, true);
return;
}
setStatus("Profile picture updated!", false);
} catch (err) {
setStatus(err.message || "Something went wrong.", true);
} finally {
fileInput.value = "";
}
});
})();
</script>
+204
View File
@@ -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/<username>.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 <n> bytes to <filename>"
#
# 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/<hash>.<ext>, and returns a
# plain-text URL in the bashupload-compatible style:
# https://superbeelavaman.onthewifi.com/usercontent/?path=<hash>.<ext>
#
# 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=<filename> 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()
+45
View File
@@ -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"<h1>403 Forbidden</h1>"
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("<!--dir-->", html.escape(path))
listText = "<li>Folder: <a href=\"/raw/?p=" + html.escape(os.path.dirname(path)) + "\">..</a></li>"
for i in filesList:
temp = "File: "
if os.path.isdir(filePath + i.name):
temp = "Folder: "
link = path.rstrip("/") + "/" + i.name
listText += ("<li>" + temp + "<a href=\"/raw/?p=" + html.escape(link) + "\">" + html.escape(i.name) + "</a></li>")
page = page.replace("<!--fileList-->", 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()