build_directory.py (4731B)
1 #!/usr/bin/env python3 2 """ 3 Gera dados públicos para a landing runv.club a partir de /var/lib/runv/users.json. 4 5 Expõe apenas: username, since (created_at ISO), path (~user/), e opcionalmente 6 homepage_mtime se --homes-root existir e public_html/index.html for legível. 7 8 Nunca escreve email, fingerprint de chave nem campos de quota detalhados. 9 10 Executar no servidor (cron) como root, ou localmente com --users-json apontando 11 para uma cópia de teste. Se users.json ainda não existir, assume lista vazia (aviso em stderr). 12 13 Python 3, só biblioteca padrão. 14 """ 15 16 from __future__ import annotations 17 18 import argparse 19 import json 20 import sys 21 from datetime import datetime, timezone 22 from pathlib import Path 23 24 SCRIPT_DIR = Path(__file__).resolve().parent 25 ADMIN_DIR = SCRIPT_DIR.parent / "scripts" / "admin" 26 if str(ADMIN_DIR) not in sys.path: 27 sys.path.insert(0, str(ADMIN_DIR)) 28 29 from admin_guard import ensure_admin_cli 30 31 32 def parse_args() -> argparse.Namespace: 33 p = argparse.ArgumentParser(description="Gera members.json público para site/") 34 here = Path(__file__).resolve().parent 35 default_out = here / "public" / "data" / "members.json" 36 p.add_argument( 37 "--users-json", 38 type=Path, 39 default=Path("/var/lib/runv/users.json"), 40 help="Caminho para users.json do provisionador", 41 ) 42 p.add_argument( 43 "--output", 44 "-o", 45 type=Path, 46 default=default_out, 47 help="Ficheiro JSON de saída (pasta criada se necessário)", 48 ) 49 p.add_argument( 50 "--homes-root", 51 type=Path, 52 default=None, 53 help="Se definido (ex. /home), tenta ler mtime de <root>/<user>/public_html/index.html", 54 ) 55 p.add_argument( 56 "--dry-run", 57 action="store_true", 58 help="Imprime JSON para stdout em vez de gravar ficheiro", 59 ) 60 return p.parse_args() 61 62 63 def homepage_mtime_iso(homes_root: Path, username: str) -> str | None: 64 idx = homes_root / username / "public_html" / "index.html" 65 try: 66 st = idx.stat() 67 ts = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc) 68 return ts.isoformat() 69 except OSError: 70 return None 71 72 73 def load_users(path: Path) -> list[dict]: 74 if not path.exists(): 75 print( 76 f"Aviso: {path} ainda não existe; a assumir lista vazia (0 membros).", 77 file=sys.stderr, 78 ) 79 return [] 80 if not path.is_file(): 81 raise SystemExit(f"Não é um ficheiro: {path}") 82 raw = path.read_text(encoding="utf-8").strip() 83 if not raw: 84 return [] 85 data = json.loads(raw) 86 if not isinstance(data, list): 87 raise SystemExit(f"Formato inválido: esperado lista JSON em {path}") 88 return data 89 90 91 def main() -> None: 92 args = parse_args() 93 ensure_admin_cli( 94 script_name=Path(__file__).name, 95 dry_run=bool(args.dry_run), 96 ) 97 users = load_users(args.users_json) 98 members: list[dict] = [] 99 for row in users: 100 if not isinstance(row, dict): 101 continue 102 username = row.get("username") 103 if not isinstance(username, str) or not username: 104 continue 105 created = row.get("created_at") 106 since = created if isinstance(created, str) else "" 107 entry: dict = { 108 "username": username, 109 "since": since, 110 "path": f"/~{username}/", 111 } 112 if args.homes_root is not None: 113 mt = homepage_mtime_iso(args.homes_root, username) 114 if mt: 115 entry["homepage_mtime"] = mt 116 members.append(entry) 117 118 members.sort(key=lambda x: x["username"].lower()) 119 120 out_json = json.dumps(members, ensure_ascii=False, indent=2) + "\n" 121 if args.dry_run: 122 sys.stdout.write(out_json) 123 return 124 args.output.parent.mkdir(parents=True, exist_ok=True) 125 args.output.write_text(out_json, encoding="utf-8") 126 out_abs = args.output.resolve() 127 print(f"Escritos {len(members)} membros em {out_abs}", file=sys.stderr) 128 # O browser faz fetch a data/members.json relativo ao index — tem de ser o mesmo ficheiro 129 # que o HTTP serve (DocumentRoot), não só a cópia em site/public do repositório. 130 norm = str(out_abs).replace("\\", "/") 131 if members and "/var/www/" not in norm: 132 print( 133 "Nota: com membros > 0, confirme que este path é o servido pelo HTTP " 134 "(<DocumentRoot>/data/members.json). Se a landing em produção não mostrar os pontos, " 135 "use -o ex.: /var/www/runv.club/html/data/members.json ou copie o ficheiro para lá " 136 "(ou genlanding.py). Ver docs/07-public-members-directory.md no repositório.", 137 file=sys.stderr, 138 ) 139 140 141 if __name__ == "__main__": 142 main()