Validate NC manifest SQL view identifiers

This commit is contained in:
Terranom674
2026-08-18 08:48:11 +02:00
parent 53ab96e73b
commit d9c9a88a66

View File

@@ -7,174 +7,109 @@ import argparse
import csv import csv
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$")
def validate_view(name: str) -> str:
value = str(name).strip()
if not IDENTIFIER.fullmatch(value):
raise ValueError(f"invalid SQL view name: {value!r}")
return value
def read_config(path: Path) -> dict[str, tuple[str, Path]]: def read_config(path: Path) -> dict[str, tuple[str, Path]]:
result: dict[str, tuple[str, Path]] = {} result: dict[str, tuple[str, Path]] = {}
with path.open(encoding="utf-8") as handle: with path.open(encoding="utf-8") as handle:
for number, line in enumerate(handle, 1): for number, line in enumerate(handle, 1):
line = line.rstrip("\n") line=line.rstrip("\n")
if not line or line.startswith("#"): if not line or line.startswith("#"):continue
continue fields=line.split("\t")
fields = line.split("\t") if len(fields)!=3:raise ValueError(f"{path}:{number}: expected storage_id, source_prefix and local_mount")
if len(fields) != 3: storage_id,prefix,mount=fields
raise ValueError(f"{path}:{number}: expected storage_id, source_prefix and local_mount") if not storage_id.strip():raise ValueError(f"{path}:{number}: storage_id is empty")
storage_id, prefix, mount = fields result[storage_id]=(prefix.strip("/"),Path(mount))
result[storage_id] = (prefix.strip("/"), Path(mount))
return result return result
def run_query(args: argparse.Namespace, env: dict[str, str], sql: str) -> subprocess.CompletedProcess[str]: def run_query(args: argparse.Namespace, env: dict[str,str], sql: str) -> subprocess.CompletedProcess[str]:
command = [ command=["psql","-X","-A","-F","\t","-t","-v","ON_ERROR_STOP=1","-h",args.host,"-p",str(args.port),"-U",args.user,"-d",args.database,"-c",sql]
"psql", "-X", "-A", "-F", "\t", "-t", return subprocess.run(command,env=env,check=False,text=True,capture_output=True)
"-h", args.host, "-p", str(args.port), "-U", args.user, "-d", args.database,
"-c", sql,
]
return subprocess.run(command, env=env, check=False, text=True, capture_output=True)
def query_rows(args: argparse.Namespace) -> list[list[str]]: def query_rows(args: argparse.Namespace) -> list[list[str]]:
password = args.password_file.read_text(encoding="utf-8").strip() password=args.password_file.read_text(encoding="utf-8").strip();env=os.environ.copy();env["PGPASSWORD"]=password;view=validate_view(args.view)
env = os.environ.copy() modern_sql=f"SELECT share_id, item_type, display_name, storage_id, source_path FROM {view} ORDER BY share_id"
env["PGPASSWORD"] = password completed=run_query(args,env,modern_sql)
if completed.returncode==0:return list(csv.reader(completed.stdout.splitlines(),delimiter="\t"))
modern_sql = f"SELECT share_id, item_type, display_name, storage_id, source_path FROM {args.view} ORDER BY share_id" if "item_type" not in completed.stderr or "does not exist" not in completed.stderr:raise RuntimeError(completed.stderr.strip() or "Nextcloud source view query failed")
completed = run_query(args, env, modern_sql) legacy_sql=f"SELECT share_id, display_name, storage_id, source_path FROM {view} ORDER BY share_id"
if completed.returncode == 0: completed=run_query(args,env,legacy_sql)
return list(csv.reader(completed.stdout.splitlines(), delimiter="\t")) if completed.returncode!=0:raise RuntimeError(completed.stderr.strip() or "legacy Nextcloud source view query failed")
rows=[]
# Existing installations may still expose the original four-column view. for row in csv.reader(completed.stdout.splitlines(),delimiter="\t"):
# Keep them usable and derive folder/file from the resolved source path. if len(row)==4:
if "item_type" not in completed.stderr or "does not exist" not in completed.stderr: share_id,display_name,storage_id,source_path=row;rows.append([share_id,"",display_name,storage_id,source_path])
raise RuntimeError(completed.stderr.strip() or "Nextcloud source view query failed") else:rows.append(row)
legacy_sql = f"SELECT share_id, display_name, storage_id, source_path FROM {args.view} ORDER BY share_id"
completed = run_query(args, env, legacy_sql)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or "legacy Nextcloud source view query failed")
rows: list[list[str]] = []
for row in csv.reader(completed.stdout.splitlines(), delimiter="\t"):
if len(row) == 4:
share_id, display_name, storage_id, source_path = row
rows.append([share_id, "", display_name, storage_id, source_path])
else:
rows.append(row)
return rows return rows
def contained_join(root: Path, relative: str) -> Path: def contained_join(root: Path, relative: str) -> Path:
parts = PurePosixPath(relative).parts parts=PurePosixPath(relative).parts
if relative.startswith("/") or ".." in parts: if relative.startswith("/") or ".." in parts:raise ValueError(f"unsafe source path: {relative}")
raise ValueError(f"unsafe source path: {relative}")
return root.joinpath(*parts) return root.joinpath(*parts)
def build(args: argparse.Namespace) -> dict[str, object]: def build(args: argparse.Namespace) -> dict[str,object]:
adapters = read_config(args.storage_config) validate_view(args.view);adapters=read_config(args.storage_config);rows=query_rows(args)
rows = query_rows(args) if not rows and not args.allow_empty:raise RuntimeError("Nextcloud returned no Showcase shares; refusing an empty manifest")
if not rows and not args.allow_empty: manifest=[];errors=[];folder_count=0;file_count=0
raise RuntimeError("Nextcloud returned no Showcase shares; refusing an empty manifest")
manifest: list[str] = []
errors: list[str] = []
folder_count = 0
file_count = 0
for row in rows: for row in rows:
if len(row) != 5: if len(row)!=5:errors.append(f"invalid database row with {len(row)} columns");continue
errors.append(f"invalid database row with {len(row)} columns") share_id,item_type,display_name,storage_id,source_path=row;item_type=item_type.strip().lower()
continue if item_type and item_type not in {"folder","file"}:errors.append(f"share {share_id}: unsupported item_type {item_type!r}");continue
adapter=adapters.get(storage_id)
share_id, item_type, display_name, storage_id, source_path = row if not adapter:errors.append(f"share {share_id}: unknown storage {storage_id}");continue
item_type = item_type.strip().lower() prefix,mount=adapter;relative=source_path.strip("/")
if item_type and item_type not in {"folder", "file"}:
errors.append(f"share {share_id}: unsupported item_type {item_type!r}")
continue
adapter = adapters.get(storage_id)
if not adapter:
errors.append(f"share {share_id}: unknown storage {storage_id}")
continue
prefix, mount = adapter
relative = source_path.strip("/")
if prefix: if prefix:
expected = prefix + "/" expected=prefix+"/"
if relative != prefix and not relative.startswith(expected): if relative!=prefix and not relative.startswith(expected):errors.append(f"share {share_id}: path does not match configured prefix");continue
errors.append(f"share {share_id}: path does not match configured prefix") relative=relative[len(prefix):].lstrip("/")
continue source=contained_join(mount,relative)
relative = relative[len(prefix):].lstrip("/") if not mount.is_mount():errors.append(f"share {share_id}: storage mount unavailable: {mount}");continue
source = contained_join(mount, relative)
if not mount.is_mount():
errors.append(f"share {share_id}: storage mount unavailable: {mount}")
continue
if not item_type: if not item_type:
if source.is_dir(): if source.is_dir():item_type="folder"
item_type = "folder" elif source.is_file():item_type="file"
elif source.is_file(): else:errors.append(f"share {share_id}: source unavailable: {source}");continue
item_type = "file" if item_type=="folder":
else: if not source.is_dir():errors.append(f"share {share_id}: source directory unavailable: {source}");continue
errors.append(f"share {share_id}: source unavailable: {source}") folder_count+=1
continue
if item_type == "folder":
if not source.is_dir():
errors.append(f"share {share_id}: source directory unavailable: {source}")
continue
folder_count += 1
else: else:
if not source.is_file(): if not source.is_file():errors.append(f"share {share_id}: source file unavailable: {source}");continue
errors.append(f"share {share_id}: source file unavailable: {source}") file_count+=1
continue if "\t" in display_name or "\n" in display_name or "\r" in display_name:errors.append(f"share {share_id}: display name contains unsupported control characters");continue
file_count += 1 source_text=str(source)
if "\t" in source_text or "\n" in source_text or "\r" in source_text:errors.append(f"share {share_id}: source path contains unsupported control characters");continue
manifest.append(f"{share_id}\t{item_type}\t{display_name.lstrip('/')}\t{source}") manifest.append(f"{share_id}\t{item_type}\t{display_name.lstrip('/')}\t{source}")
if errors:raise RuntimeError("; ".join(errors))
if errors: if len(manifest)!=len(rows):raise RuntimeError("not all Showcase shares could be resolved")
raise RuntimeError("; ".join(errors)) args.output.parent.mkdir(parents=True,exist_ok=True)
if len(manifest) != len(rows): with tempfile.NamedTemporaryFile("w",encoding="utf-8",dir=args.output.parent,delete=False) as handle:
raise RuntimeError("not all Showcase shares could be resolved") handle.write("\n".join(manifest)+("\n" if manifest else ""));temporary=Path(handle.name)
args.output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=args.output.parent, delete=False) as handle:
handle.write("\n".join(manifest) + ("\n" if manifest else ""))
temporary = Path(handle.name)
temporary.replace(args.output) temporary.replace(args.output)
return {"shares":len(manifest),"folders":folder_count,"files":file_count,"manifest":str(args.output)}
return {
"shares": len(manifest),
"folders": folder_count,
"files": file_count,
"manifest": str(args.output),
}
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser() parser=argparse.ArgumentParser();parser.add_argument("--host",required=True);parser.add_argument("--port",type=int,default=5432);parser.add_argument("--database",required=True);parser.add_argument("--user",required=True);parser.add_argument("--password-file",required=True,type=Path);parser.add_argument("--view",default="piwigo_showcase_sources");parser.add_argument("--storage-config",required=True,type=Path);parser.add_argument("--output",required=True,type=Path);parser.add_argument("--allow-empty",action="store_true");args=parser.parse_args()
parser.add_argument("--host", required=True) try:print(json.dumps(build(args),ensure_ascii=False))
parser.add_argument("--port", type=int, default=5432) except Exception as error:print(f"manifest: {error}",file=sys.stderr);return 1
parser.add_argument("--database", required=True)
parser.add_argument("--user", required=True)
parser.add_argument("--password-file", required=True, type=Path)
parser.add_argument("--view", default="piwigo_showcase_sources")
parser.add_argument("--storage-config", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--allow-empty", action="store_true")
args = parser.parse_args()
try:
print(json.dumps(build(args), ensure_ascii=False))
except Exception as error:
print(f"manifest: {error}", file=sys.stderr)
return 1
return 0 return 0
if __name__ == "__main__": if __name__=="__main__":raise SystemExit(main())
raise SystemExit(main())