Add optimistic locking: return base_hash on load, reject 409 on conflict

This commit is contained in:
brutsalvadi
2026-05-07 22:35:31 +02:00
parent 69ae623035
commit 0fc1a2dc28
2 changed files with 40 additions and 7 deletions
+39 -6
View File
@@ -49,14 +49,34 @@ _GIT_DIR = os.environ.get("GIT_DIR")
_git_lock = asyncio.Lock()
async def _git_commit(file_path: Path, handle: str, verb: str) -> None:
rel = str(file_path.relative_to(_ROOT))
def _git_env() -> dict:
env = os.environ.copy()
if _GIT_DIR:
env["GIT_DIR"] = _GIT_DIR
env["GIT_WORK_TREE"] = str(_ROOT)
env.setdefault("GIT_COMMITTER_NAME", "bincio-wiki")
env.setdefault("GIT_COMMITTER_EMAIL", "wiki@bincio.wiki")
return env
async def _git_file_hash(file_path: Path) -> str:
rel = str(file_path.relative_to(_ROOT))
try:
proc = await asyncio.create_subprocess_exec(
"git", "log", "-1", "--format=%H", "--", rel,
cwd=str(_ROOT), env=_git_env(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
return stdout.decode().strip()
except Exception:
return ""
async def _git_commit(file_path: Path, handle: str, verb: str) -> None:
rel = str(file_path.relative_to(_ROOT))
env = _git_env()
try:
async with _git_lock:
add = await asyncio.create_subprocess_exec(
@@ -256,6 +276,7 @@ def _slug_to_path(slug: str, base: Path) -> Path:
class PageBody(BaseModel):
content: str
base_hash: str | None = None
def _list(base: Path) -> list[str]:
@@ -263,11 +284,13 @@ def _list(base: Path) -> list[str]:
return []
return [str(p.relative_to(base).with_suffix("")) for p in sorted(base.rglob("*.md"))]
def _get(slug: str, base: Path) -> JSONResponse:
async def _get(slug: str, base: Path) -> JSONResponse:
path = _slug_to_path(slug, base)
if not path.exists():
raise HTTPException(404, "Not found")
return JSONResponse({"slug": slug, "content": path.read_text(encoding="utf-8")})
content = path.read_text(encoding="utf-8")
base_hash = await _git_file_hash(path)
return JSONResponse({"slug": slug, "content": content, "base_hash": base_hash})
def _save(slug: str, body: PageBody, base: Path) -> JSONResponse:
path = _slug_to_path(slug, base)
@@ -295,8 +318,13 @@ async def get_page(slug: str, user: User = Depends(require_auth)) -> JSONRespons
@app.post("/pages/{slug:path}")
async def save_page(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
path = _slug_to_path(slug, pages_dir)
if body.base_hash is not None:
current = await _git_file_hash(path)
if current and current != body.base_hash:
raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare")
result = _save(slug, body, pages_dir)
await _git_commit(_slug_to_path(slug, pages_dir), user.handle, "edited")
await _git_commit(path, user.handle, "edited")
return result
@app.delete("/pages/{slug:path}")
@@ -319,8 +347,13 @@ async def get_story(slug: str, user: User = Depends(require_auth)) -> JSONRespon
@app.post("/stories/{slug:path}")
async def save_story(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
path = _slug_to_path(slug, stories_dir)
if body.base_hash is not None:
current = await _git_file_hash(path)
if current and current != body.base_hash:
raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare")
result = _save(slug, body, stories_dir)
await _git_commit(_slug_to_path(slug, stories_dir), user.handle, "edited")
await _git_commit(path, user.handle, "edited")
return result
@app.delete("/stories/{slug:path}")
+1 -1
Submodule site updated: d6eda096d9...1539bdb3a7