From 9d4f12e5df6d66f4eb3155f7f7ed239a98f62cdf Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 15:27:00 +0100 Subject: [PATCH] PROD upload: file mtime metadata drives date/time, optional date override, collision suffixes, path traversal guard --- index.html | 42 ++++++++++++++++++++++++++++++------------ server.py | 40 +++++++++++++++++++++++++++------------- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/index.html b/index.html index f493b5b..d6080cb 100644 --- a/index.html +++ b/index.html @@ -206,11 +206,12 @@
- Drop MP4/AVI files here or browse
named HH.MM.SS.mp4 or .avi, multiple files allowed + Drop MP4/AVI files here or browse
multiple files allowed; date & time come from each file's metadata
- Date: - + Upload date: - + Ready
@@ -571,6 +572,8 @@ const uploadDrop = document.querySelector("#upload-drop"); const uploadQueue = document.querySelector("#upload-queue"); const uploadStatus = document.querySelector("#upload-status"); + const overrideDate = document.querySelector("#upload-date-override"); + overrideDate.addEventListener("change", renderQueue); let queue = []; function setStatus(msg, cls) { uploadStatus.textContent = msg; @@ -589,28 +592,38 @@ const row = document.createElement("div"); row.className = "q-item" + (entry.state ? " " + entry.state : ""); const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name; + const stamp = fileStamp(entry); + const slot = document.createElement("span"); slot.className = "q-size"; + slot.textContent = stamp.date + (stamp.time ? " " + stamp.time : " (mtime time per file)"); const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB"; const bar = document.createElement("span"); bar.className = "q-bar"; const fill = document.createElement("i"); bar.append(fill); entry.bar = fill; const state = document.createElement("span"); state.className = "q-state"; state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued"; - row.append(name, size, bar, state); + row.append(name, slot, size, bar, state); uploadQueue.append(row); }); uploadBtn.disabled = !queue.some(e => e.state === "queued"); } function addFiles(files) { - if (!dateSelect.value) { - setStatus("Pick a recording date first (top right), then add files.", "err"); - return; - } for (const file of files) { if (!/\.(mp4|avi)$/i.test(file.name)) continue; queue.push({file, state: "queued"}); } renderQueue(); } + function fileStamp(entry) { + if (overrideDate.value) { + return {date: overrideDate.value.replaceAll("-", ""), time: null}; + } + const d = new Date(entry.file.lastModified); + const pad = n => String(n).padStart(2, "0"); + return { + date: `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`, + time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, + }; + } function clearDone() { queue = queue.filter(e => e.state === "err"); renderQueue(); @@ -628,16 +641,18 @@ return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null; } async function uploadOne(entry) { - const filename = normName(entry.file.name); - if (!filename) { - entry.state = "err"; entry.msg = "bad name"; + const stamp = fileStamp(entry); + if (!stamp.date) { + entry.state = "err"; entry.msg = "no date"; renderQueue(); return false; } entry.state = "busy"; renderQueue(); return new Promise(resolve => { const xhr = new XMLHttpRequest(); - xhr.open("POST", `/api/upload?date=${dateSelect.value}&filename=${encodeURIComponent(filename)}`); + let url = `/api/upload?date=${stamp.date}&filename=${encodeURIComponent(entry.file.name)}`; + if (stamp.time) url += `&time=${encodeURIComponent(stamp.time)}`; + xhr.open("POST", url); xhr.upload.addEventListener("progress", e => { if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`; }); @@ -678,7 +693,10 @@ await Promise.all(workers); const errs = queue.filter(e => e.state === "err").length; setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok"); - if (okCount) loadDate(dateSelect.value); + const touched = new Set(queue.filter(e => e.state === "ok").map(e => fileStamp(e).date)); + if (touched.size) { + loadDate(dateSelect.value || [...touched][0]); + } if (!errs) clearDone(); }); diff --git a/server.py b/server.py index 4910885..9414203 100644 --- a/server.py +++ b/server.py @@ -151,26 +151,35 @@ class PlayerHandler(BaseHTTPRequestHandler): max_bytes = 2 * 1024 * 1024 * 1024 if not 0 < length <= max_bytes: return self.send_error(413, "Missing or oversized body (limit 2 GiB)") - filename = parse_qs(urlparse(self.path).query).get("filename", [""])[0] - match = VIDEO_RE.fullmatch(filename) - if not match: - return self.send_error(400, "Filename must be HH.MM.SS.mp4 or HH.MM.SS.avi (e.g. 09.28.36.mp4)") - hour, minute, second = map(int, match.groups()[:3]) - if hour > 23 or minute > 59 or second > 59: - return self.send_error(400, "Invalid time in filename") + params = parse_qs(urlparse(self.path).query) + filename = params.get("filename", [""])[0] + if not re.fullmatch(r"[A-Za-z0-9._ -]{1,120}\.(mp4|avi)", filename, re.IGNORECASE): + return self.send_error(400, "filename must be a simple MP4/AVI name") + suffix = filename.rsplit(".", 1)[1].lower() try: - day = normalize_date(parse_qs(urlparse(self.path).query).get("date", [""])[0]) + day = normalize_date(params.get("date", [""])[0]) except ValueError: return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD") - suffix = match.group(4).lower() - if suffix not in ("mp4", "avi"): - return self.send_error(400, "Only .mp4 or .avi uploads are accepted") + try: + start = parse_clock(params.get("time", [""])[0]) + if not (0 <= start < 86400): + raise ValueError + except ValueError: + return self.send_error(400, "time must be HH:MM[:SS] within one day") target_dir = self.server.output_root / day if suffix == "avi": # AVI sources mirror the real recorder: stored in the source tree for the Encoding menu target_dir = self.server.source_root / day - target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.{suffix}" + # Archive-canonical name from the actual start time; collisions with a + # different original name get a numeric suffix. + base = f"{start // 3600:02d}.{start % 3600 // 60:02d}.{start % 60:02d}" + target = target_dir / f"{base}.{suffix}" + serial = 1 + while target.exists() or temporary_exists(target_dir, target.name): + base_alt = f"{base}-{serial:02d}" + target = target_dir / f"{base_alt}.{suffix}" + serial += 1 temporary = target.with_suffix(f".uploading.{suffix}") try: target_dir.mkdir(parents=True, exist_ok=True) @@ -193,7 +202,7 @@ class PlayerHandler(BaseHTTPRequestHandler): if suffix == "mp4" and not any(clip["name"] == target.name for clip in self.server.recordings[day]): self.server.recordings[day].append({ "name": target.name, - "start": hour * 3600 + minute * 60 + second, + "start": start, "duration": 120, "path": target, }) @@ -332,6 +341,11 @@ class PlayerHandler(BaseHTTPRequestHandler): print(f"{self.client_address[0]} - {message % args}") +def temporary_exists(directory, final_name): + stem = final_name.rsplit(".", 1)[0] + return any(path.name.startswith(stem + ".uploading.") for path in directory.glob("*.uploading.*")) if directory.is_dir() else False + + def main(): parser = argparse.ArgumentParser(description="Play dated recorder AVI files in a browser") parser.add_argument(