Files
2026-08-23 21:00:22 -07:00

205 lines
9.4 KiB
Python

# 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()