diff --git a/main.py b/main.py index a95810c..763a365 100644 --- a/main.py +++ b/main.py @@ -1266,6 +1266,56 @@ async def delete_run(request: Request, run_id: str): return RedirectResponse(url=f"{BASE_PATH}/", status_code=303) +@app.post("/d/{run_id}/fork", response_class=HTMLResponse) +async def fork_run(request: Request, run_id: str): + src_dir = _run_dir_for_id(run_id) + if src_dir is None: + return RedirectResponse(url=f"{BASE_PATH}/?error=Invalid%20run%20id", status_code=303) + + client_ip = request.client.host if request.client else "unknown" + + # Forking counts as a convert: enforce daily convert quota + if not _consume_rate_limit(client_ip, key="convert", limit=CONVERT_DAILY_LIMIT): + logger.info("fork.rate_limit_exceeded from=%s ip=%s", run_id, client_ip) + return RedirectResponse(url=f"{BASE_PATH}/?error=Rate%20limit%20exceeded", status_code=303) + + try: + new_id = _new_run_id() + new_dir = RUNS_DIR / new_id + new_dir.mkdir(parents=True, exist_ok=False) + + # Copy a selected set of files so the fork is usable: source .tex, original upload, and common artifacts + candidates = list(src_dir.glob("original.*")) + [ + src_dir / "diagram.tex", + src_dir / "diagram.pdf", + src_dir / "diagram.png", + src_dir / "diagram.svg", + src_dir / "preview.png", + src_dir / "run.log.txt", + src_dir / "meta.json", + ] + for p in candidates: + if p.exists() and p.is_file(): + shutil.copy2(p, new_dir / p.name) + + # Update or create meta for the new run + meta = _read_run_meta(new_dir) + meta["forked_from"] = run_id + meta["created_at"] = time.time() + meta["updated_at"] = time.time() + _write_run_meta(new_dir, meta) + + logger.info("fork.ok from=%s to=%s ip=%s", run_id, new_id, client_ip) + run_log = RunLogger(new_dir, logger) + run_log.line(f"fork.from={run_id} by_ip={client_ip}") + run_log.line("rate.consumed key=convert") + except Exception: + logger.exception("fork.error from=%s", run_id) + return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}?error=Fork%20failed", status_code=303) + + return RedirectResponse(url=f"{BASE_PATH}/d/{new_id}", status_code=303) + + @app.get("/d/{run_id}/files/{filename}") async def get_run_file(run_id: str, filename: str): path = _safe_join_run_file(run_id, filename) diff --git a/templates/edit.html b/templates/edit.html index eed2b75..4567442 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -86,6 +86,15 @@ Delete Diagram + @@ -143,13 +152,14 @@ const currentTex = latexArea ? latexArea.value : "" let historyEntries = [] - // Validate edit/compile/delete actions. + // Validate edit/compile/delete/fork actions. if (editForm && instructionsInput) { editForm.addEventListener("submit", e => { const submitter = e.submitter const action = (submitter && submitter.getAttribute("formaction")) || "" const isAlternateAction = !!action const isDeleteAction = action.endsWith("/delete") + const isForkAction = action.endsWith("/fork") const val = (instructionsInput.value || "").trim() if (isDeleteAction) { @@ -159,6 +169,13 @@ return } + if (isForkAction) { + if (!confirm("Create a fork (copy) of this run?")) { + e.preventDefault() + } + return + } + if (!isAlternateAction && !val) { e.preventDefault() }