| 1 | #!/usr/bin/env python3 |
| 2 | """Manage the SquadScope image registry (data/image-registry.json). |
| 3 | |
| 4 | Tracks locally-hosted cover images with license, attribution, and usage metadata. |
| 5 | Enforces the no-hotlinking policy: only locally-hosted images are registered. |
| 6 | |
| 7 | Usage: |
| 8 | python scripts/manage_image_registry.py add \\ |
| 9 | --filename assets/covers/2026-W24.webp \\ |
| 10 | --license CC0 \\ |
| 11 | --source-url https://openverse.org/image/abc \\ |
| 12 | --attribution "Photo by Author on Openverse" \\ |
| 13 | --added-by operator |
| 14 | |
| 15 | python scripts/manage_image_registry.py validate |
| 16 | |
| 17 | python scripts/manage_image_registry.py list |
| 18 | """ |
| 19 | |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import argparse |
| 23 | import json |
| 24 | import sys |
| 25 | from datetime import date |
| 26 | from pathlib import Path |
| 27 | |
| 28 | REGISTRY_PATH = Path("data/image-registry.json") |
| 29 | ALLOWED_LICENSES = ("CC0", "Openverse", "local-asset") |
| 30 | |
| 31 | |
| 32 | class RegistryError(ValueError): |
| 33 | """Raised when the image registry cannot be loaded safely.""" |
| 34 | |
| 35 | |
| 36 | def load_registry(path: Path = REGISTRY_PATH, *, allow_missing: bool = True) -> dict: |
| 37 | if not path.exists(): |
| 38 | if not allow_missing: |
| 39 | raise RegistryError(f"Image registry not found: {path}") |
| 40 | return {"images": []} |
| 41 | try: |
| 42 | registry = json.loads(path.read_text(encoding="utf-8")) |
| 43 | except (json.JSONDecodeError, ValueError) as exc: |
| 44 | raise RegistryError(f"Invalid image registry JSON in {path}: {exc}") from exc |
| 45 | if not isinstance(registry, dict) or not isinstance(registry.get("images"), list): |
| 46 | raise RegistryError( |
| 47 | f"Invalid image registry format in {path}: expected an object with an 'images' list." |
| 48 | ) |
| 49 | return registry |
| 50 | |
| 51 | |
| 52 | def save_registry(registry: dict, path: Path = REGISTRY_PATH) -> None: |
| 53 | path.parent.mkdir(parents=True, exist_ok=True) |
| 54 | path.write_text(json.dumps(registry, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") |
| 55 | |
| 56 | |
| 57 | def _is_safe_local_path(filename: str) -> tuple[bool, str]: |
| 58 | """Check that filename is a safe local relative path.""" |
| 59 | if filename.startswith(("http://", "https://", "//")): |
| 60 | return False, "filename is a URL (no hotlinking allowed)" |
| 61 | if Path(filename).is_absolute(): |
| 62 | return False, "filename is an absolute path (must be relative)" |
| 63 | if ".." in Path(filename).parts: |
| 64 | return False, "filename contains path traversal (..)" |
| 65 | return True, "" |
| 66 | |
| 67 | |
| 68 | def add_image(args: argparse.Namespace) -> int: |
| 69 | registry = load_registry() |
| 70 | |
| 71 | # Validate license |
| 72 | if args.license not in ALLOWED_LICENSES: |
| 73 | print( |
| 74 | f"ERROR: Invalid license '{args.license}'. Must be one of: {', '.join(ALLOWED_LICENSES)}", |
| 75 | file=sys.stderr, |
| 76 | ) |
| 77 | return 1 |
| 78 | |
| 79 | # Validate path safety |
| 80 | safe, reason = _is_safe_local_path(args.filename) |
| 81 | if not safe: |
| 82 | print(f"ERROR: {reason}: {args.filename}", file=sys.stderr) |
| 83 | return 1 |
| 84 | |
| 85 | # Check file exists locally |
| 86 | if not Path(args.filename).exists(): |
| 87 | print(f"WARNING: File '{args.filename}' does not exist locally yet.", file=sys.stderr) |
| 88 | |
| 89 | # Check for duplicates |
| 90 | existing = [img for img in registry["images"] if img["filename"] == args.filename] |
| 91 | if existing: |
| 92 | print(f"ERROR: Image '{args.filename}' already in registry.", file=sys.stderr) |
| 93 | return 1 |
| 94 | |
| 95 | entry = { |
| 96 | "filename": args.filename, |
| 97 | "license": args.license, |
| 98 | "added_by": args.added_by, |
| 99 | "added_at": date.today().isoformat(), |
| 100 | } |
| 101 | if args.source_url: |
| 102 | entry["source_url"] = args.source_url |
| 103 | if args.attribution: |
| 104 | entry["attribution"] = args.attribution |
| 105 | |
| 106 | registry["images"].append(entry) |
| 107 | save_registry(registry) |
| 108 | print(f"Added '{args.filename}' to image registry.") |
| 109 | return 0 |
| 110 | |
| 111 | |
| 112 | def validate_registry(args: argparse.Namespace) -> int: |
| 113 | if not REGISTRY_PATH.exists(): |
| 114 | print(f"FAIL: Image registry file not found: {REGISTRY_PATH}", file=sys.stderr) |
| 115 | return 1 |
| 116 | registry = load_registry(allow_missing=False) |
| 117 | errors: list[str] = [] |
| 118 | |
| 119 | for i, img in enumerate(registry["images"]): |
| 120 | if not img.get("filename"): |
| 121 | errors.append(f"Entry {i}: missing filename") |
| 122 | if not img.get("license"): |
| 123 | errors.append(f"Entry {i}: missing license") |
| 124 | elif img["license"] not in ALLOWED_LICENSES: |
| 125 | errors.append(f"Entry {i}: invalid license '{img['license']}'") |
| 126 | if not img.get("added_by"): |
| 127 | errors.append(f"Entry {i}: missing added_by") |
| 128 | |
| 129 | # Verify no hotlinking and path safety |
| 130 | filename = img.get("filename", "") |
| 131 | safe, reason = _is_safe_local_path(filename) |
| 132 | if not safe: |
| 133 | errors.append(f"Entry {i}: {reason}: {filename}") |
| 134 | |
| 135 | if errors: |
| 136 | for err in errors: |
| 137 | print(f"FAIL: {err}", file=sys.stderr) |
| 138 | return 1 |
| 139 | |
| 140 | print(f"Image registry valid: {len(registry['images'])} entries.") |
| 141 | return 0 |
| 142 | |
| 143 | |
| 144 | def list_images(args: argparse.Namespace) -> int: |
| 145 | registry = load_registry() |
| 146 | if not registry["images"]: |
| 147 | print("No images registered.") |
| 148 | return 0 |
| 149 | for img in registry["images"]: |
| 150 | license_str = img.get("license", "unknown") |
| 151 | print(f" {img['filename']} [{license_str}] by {img.get('added_by', '?')}") |
| 152 | return 0 |
| 153 | |
| 154 | |
| 155 | def main(argv: list[str] | None = None) -> int: |
| 156 | parser = argparse.ArgumentParser(description="Manage SquadScope image registry") |
| 157 | sub = parser.add_subparsers(dest="command") |
| 158 | |
| 159 | add_p = sub.add_parser("add", help="Register a new image") |
| 160 | add_p.add_argument("--filename", required=True, help="Local path to the image file") |
| 161 | add_p.add_argument("--license", required=True, choices=ALLOWED_LICENSES, help="Image license") |
| 162 | add_p.add_argument("--source-url", default="", help="Original source URL") |
| 163 | add_p.add_argument("--attribution", default="", help="Attribution text") |
| 164 | add_p.add_argument("--added-by", required=True, help="Who added this image") |
| 165 | |
| 166 | sub.add_parser("validate", help="Validate the image registry") |
| 167 | sub.add_parser("list", help="List registered images") |
| 168 | |
| 169 | args = parser.parse_args(argv) |
| 170 | try: |
| 171 | if args.command == "add": |
| 172 | return add_image(args) |
| 173 | elif args.command == "validate": |
| 174 | return validate_registry(args) |
| 175 | elif args.command == "list": |
| 176 | return list_images(args) |
| 177 | else: |
| 178 | parser.print_help() |
| 179 | return 1 |
| 180 | except RegistryError as exc: |
| 181 | print(f"ERROR: {exc}", file=sys.stderr) |
| 182 | return 1 |
| 183 | |
| 184 | |
| 185 | if __name__ == "__main__": |
| 186 | raise SystemExit(main()) |