main
py 186 lines 6.38 KB
Raw
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())