runv-server

server tooling for runv.club
Log | Files | Refs | README

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()