235 lines
9.4 KiB
Python
235 lines
9.4 KiB
Python
"""Immutable plugin versions: publish, list, inspect, download, delete."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import tarfile
|
|
from pathlib import PurePosixPath
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
|
|
from ..auth import Ctx
|
|
from ..db import record_audit
|
|
from ..deps import get_ctx
|
|
from ..storage import artifact_path, artifact_relpath
|
|
from ..util import PLUGIN_ID_RE, VERSION_RE, now_iso, semver_tuple, sha256_bytes
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _inspect_tarball(data: bytes) -> tuple[dict, list[str]]:
|
|
"""Parse the tarball in memory: return (manifest, member names).
|
|
|
|
Rejects absolute paths and parent-directory traversal; accepts
|
|
manifest.json at the archive root or inside a single top-level dir.
|
|
"""
|
|
manifest = None
|
|
names: list[str] = []
|
|
try:
|
|
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
|
|
for m in tf.getmembers():
|
|
parts = [p for p in PurePosixPath(m.name).parts if p != "."]
|
|
# `./` is what `tar -czf .` emits for the archive root. It is
|
|
# harmless and common, so skip it rather than rejecting the
|
|
# whole bundle.
|
|
if not parts:
|
|
continue
|
|
if parts[0] == ".." or ".." in parts or m.name.startswith("/"):
|
|
raise HTTPException(400, f"unsafe path in archive: {m.name}")
|
|
if not (m.isfile() or m.isdir()):
|
|
raise HTTPException(400, f"unsupported entry type: {m.name}")
|
|
rel = "/".join(parts)
|
|
names.append(rel)
|
|
key = rel.split("/", 1)[1] if "/" in rel else rel
|
|
if key == "manifest.json" and manifest is None:
|
|
fobj = tf.extractfile(m)
|
|
if fobj is None:
|
|
raise HTTPException(400, "manifest.json is not a regular file")
|
|
manifest = json.loads(fobj.read().decode("utf-8"))
|
|
except HTTPException:
|
|
raise
|
|
except (tarfile.TarError, UnicodeDecodeError) as e:
|
|
raise HTTPException(400, f"invalid tar.gz archive: {e}")
|
|
except json.JSONDecodeError as e:
|
|
raise HTTPException(400, f"manifest.json is not valid JSON: {e}")
|
|
if manifest is None:
|
|
raise HTTPException(400, "manifest.json not found in archive")
|
|
if not isinstance(manifest, dict):
|
|
raise HTTPException(400, "manifest.json must be a JSON object")
|
|
for f in manifest.get("files", []) or []:
|
|
if not any(n == f or n.endswith("/" + str(f)) for n in names):
|
|
raise HTTPException(400, f"manifest.files entry missing from archive: {f}")
|
|
return manifest, names
|
|
|
|
|
|
def _get_version_row(ctx: Ctx, plugin_id: str, version: str):
|
|
r = ctx.conn.execute(
|
|
"SELECT * FROM plugin_versions WHERE plugin_id = %s AND version = %s",
|
|
(plugin_id, version),
|
|
).fetchone()
|
|
if r is None:
|
|
raise HTTPException(404, "plugin version not found")
|
|
return r
|
|
|
|
|
|
@router.get("/v1/plugins")
|
|
def list_plugins(
|
|
request: Request,
|
|
ctx: Ctx = Depends(get_ctx),
|
|
harness_version: str | None = None,
|
|
):
|
|
ctx.require_user(request)
|
|
meta = {r["id"]: dict(r) for r in ctx.conn.execute("SELECT * FROM plugins").fetchall()}
|
|
versions: dict[str, list] = {}
|
|
for r in ctx.conn.execute("SELECT * FROM plugin_versions").fetchall():
|
|
if harness_version and semver_tuple(r["min_harness_version"]) > semver_tuple(harness_version):
|
|
continue
|
|
versions.setdefault(r["plugin_id"], []).append(
|
|
{
|
|
"version": r["version"],
|
|
"channel": r["channel"],
|
|
"sha256": r["sha256"],
|
|
"size_bytes": r["size_bytes"],
|
|
"min_harness_version": r["min_harness_version"],
|
|
"published_at": r["published_at"],
|
|
}
|
|
)
|
|
plugins = []
|
|
for pid, m in meta.items():
|
|
vers = sorted(versions.get(pid, []), key=lambda v: semver_tuple(v["version"]), reverse=True)
|
|
stable = next((v for v in vers if v["channel"] == "stable"), None)
|
|
plugins.append(
|
|
{
|
|
"id": pid,
|
|
"name": m["name"],
|
|
"description": m["description"],
|
|
"versions": vers,
|
|
"latest": stable or (vers[0] if vers else None),
|
|
}
|
|
)
|
|
return {"plugins": plugins}
|
|
|
|
|
|
@router.get("/v1/plugins/{plugin_id}/{version}")
|
|
def get_plugin(plugin_id: str, version: str, request: Request, ctx: Ctx = Depends(get_ctx)):
|
|
ctx.require_user(request)
|
|
r = _get_version_row(ctx, plugin_id, version)
|
|
return {
|
|
"id": r["plugin_id"],
|
|
"version": r["version"],
|
|
"channel": r["channel"],
|
|
"sha256": r["sha256"],
|
|
"size_bytes": r["size_bytes"],
|
|
"min_harness_version": r["min_harness_version"],
|
|
"manifest": json.loads(r["manifest_json"]),
|
|
"published_at": r["published_at"],
|
|
}
|
|
|
|
|
|
@router.get("/v1/plugins/{plugin_id}/{version}/download")
|
|
def download_plugin(plugin_id: str, version: str, request: Request, ctx: Ctx = Depends(get_ctx)):
|
|
ctx.require_user(request)
|
|
r = _get_version_row(ctx, plugin_id, version)
|
|
try:
|
|
path = artifact_path(ctx.cfg, r["artifact_path"])
|
|
except ValueError:
|
|
raise HTTPException(500, "stored artifact path is invalid")
|
|
if not path.is_file():
|
|
# The row outlived its file (data dir moved, volume not mounted). Say so
|
|
# instead of 500ing, and name the path so an operator can fix it.
|
|
raise HTTPException(404, f"artifact file missing on server: {path.name}")
|
|
return FileResponse(
|
|
path,
|
|
media_type="application/gzip",
|
|
filename=f"{plugin_id}-{version}.tar.gz",
|
|
headers={"X-Sha256": r["sha256"]},
|
|
)
|
|
|
|
|
|
@router.post("/v1/plugins/{plugin_id}")
|
|
async def upload_plugin(
|
|
plugin_id: str,
|
|
request: Request,
|
|
ctx: Ctx = Depends(get_ctx),
|
|
file: UploadFile = File(...),
|
|
):
|
|
"""Admin publishes a new immutable plugin version (tar.gz, manifest.json inside)."""
|
|
admin = ctx.require_admin(request)
|
|
if not PLUGIN_ID_RE.match(plugin_id):
|
|
raise HTTPException(400, "invalid plugin id")
|
|
data = await file.read()
|
|
if not data:
|
|
raise HTTPException(400, "empty artifact")
|
|
if len(data) > ctx.cfg.max_plugin_bytes:
|
|
raise HTTPException(413, "artifact too large")
|
|
manifest, _names = _inspect_tarball(data)
|
|
version = str(manifest.get("version", ""))
|
|
if not VERSION_RE.match(version):
|
|
raise HTTPException(400, "manifest.version missing or invalid")
|
|
if str(manifest.get("id", "")) != plugin_id:
|
|
raise HTTPException(400, "manifest.id does not match the URL plugin id")
|
|
channel = manifest.get("channel", "stable")
|
|
if channel not in ("stable", "beta"):
|
|
raise HTTPException(400, "manifest.channel must be 'stable' or 'beta'")
|
|
min_hv = str(manifest.get("min_harness_version", "0.0.0"))
|
|
if ctx.conn.execute(
|
|
"SELECT 1 FROM plugin_versions WHERE plugin_id = %s AND version = %s",
|
|
(plugin_id, version),
|
|
).fetchone():
|
|
raise HTTPException(409, "this plugin version already exists")
|
|
|
|
sha = sha256_bytes(data)
|
|
dest = ctx.cfg.plugins_dir / plugin_id
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
final = dest / f"{version}.tar.gz"
|
|
tmp = dest / f".{version}.tmp"
|
|
tmp.write_bytes(data)
|
|
tmp.replace(final)
|
|
|
|
ctx.conn.execute(
|
|
"""INSERT INTO plugins (id, name, description, latest_version, updated_at)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
name = excluded.name,
|
|
description = excluded.description,
|
|
updated_at = excluded.updated_at""",
|
|
(plugin_id, str(manifest.get("name", plugin_id)), str(manifest.get("description", "")), version, now_iso()),
|
|
)
|
|
ctx.conn.execute(
|
|
"""INSERT INTO plugin_versions
|
|
(plugin_id, version, channel, sha256, size_bytes, manifest_json,
|
|
min_harness_version, artifact_path, published_by, published_at)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
|
(plugin_id, version, channel, sha, len(data), json.dumps(manifest),
|
|
min_hv, artifact_relpath(plugin_id, version), admin["id"], now_iso()),
|
|
)
|
|
ctx.conn.commit()
|
|
record_audit(ctx.conn, admin["id"], "plugin.publish",
|
|
{"id": plugin_id, "version": version, "channel": channel, "sha256": sha})
|
|
return {"id": plugin_id, "version": version, "sha256": sha, "size_bytes": len(data)}
|
|
|
|
|
|
@router.delete("/v1/plugins/{plugin_id}/{version}")
|
|
def delete_plugin_version(plugin_id: str, version: str, request: Request, ctx: Ctx = Depends(get_ctx)):
|
|
admin = ctx.require_admin(request)
|
|
r = _get_version_row(ctx, plugin_id, version)
|
|
ctx.conn.execute(
|
|
"DELETE FROM plugin_versions WHERE plugin_id = %s AND version = %s",
|
|
(plugin_id, version),
|
|
)
|
|
remaining = ctx.conn.execute(
|
|
"SELECT COUNT(*) AS c FROM plugin_versions WHERE plugin_id = %s", (plugin_id,)
|
|
).fetchone()["c"]
|
|
if remaining == 0:
|
|
ctx.conn.execute("DELETE FROM plugins WHERE id = %s", (plugin_id,))
|
|
ctx.conn.commit()
|
|
try:
|
|
path = artifact_path(ctx.cfg, r["artifact_path"])
|
|
except ValueError:
|
|
path = None
|
|
if path and path.exists():
|
|
path.unlink()
|
|
record_audit(ctx.conn, admin["id"], "plugin.delete", {"id": plugin_id, "version": version})
|
|
return {"deleted": True, "id": plugin_id, "version": version}
|