PROD upload: file mtime metadata drives date/time, optional date override, collision suffixes, path traversal guard
This commit is contained in:
+30
-12
@@ -206,11 +206,12 @@
|
||||
<button class="encode-toggle upload-toggle" id="upload-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Upload</button>
|
||||
<div class="encode-body upload-body" id="upload-body" style="flex-wrap:wrap;gap:14px">
|
||||
<div class="upload-drop" id="upload-drop" title="Click to browse, or drag MP4/AVI files here">
|
||||
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">named HH.MM.SS.mp4 or .avi, multiple files allowed</span>
|
||||
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">multiple files allowed; date & time come from each file's metadata</span>
|
||||
<input type="file" id="upload-file" accept="video/mp4,video/x-msvideo,.avi" multiple>
|
||||
</div>
|
||||
<div class="upload-queue" id="upload-queue"></div>
|
||||
<span class="enc-status" style="min-width:110px">Date: <b id="upload-date" style="color:var(--text)">-</b></span>
|
||||
<span class="enc-status" style="min-width:110px">Upload date: <b id="upload-date" style="color:var(--text)">-</b></span>
|
||||
<input id="upload-date-override" type="date" title="Override: force all files into this date instead of file mtime" style="background:var(--surface);color:var(--text);border:1px solid #555;border-radius:3px;padding:6px 8px;font-size:12px">
|
||||
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
|
||||
<span class="enc-status upload-status" id="upload-status" style="min-width:200px;text-align:left">Ready</span>
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user