Add optimistic locking: return base_hash on load, reject 409 on conflict
This commit is contained in:
+39
-6
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user