205 lines
9.4 KiB
Python
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()
|