runv-server

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

runv-bulletin (6045B)


      1 #!/usr/bin/env python3
      2 """Mural comunitário simples da runv.club."""
      3 
      4 from __future__ import annotations
      5 
      6 import argparse
      7 import json
      8 import os
      9 import secrets
     10 import sys
     11 from datetime import datetime, timezone
     12 from pathlib import Path
     13 from typing import Any
     14 
     15 sys.tracebacklimit = 0
     16 
     17 
     18 def _bootstrap() -> None:
     19     installed = Path("/usr/local/share/runv/lib")
     20     candidates = [installed]
     21     script = Path(__file__).resolve()
     22     if script.parent.name == "bin":
     23         candidates.insert(0, script.parent.parent / "lib")
     24     for c in candidates:
     25         if (c / "runv_community.py").is_file() and str(c) not in sys.path:
     26             sys.path.insert(0, str(c))
     27             return
     28 
     29 
     30 _bootstrap()
     31 import runv_community as rc  # noqa: E402
     32 
     33 DEFAULT_BULLETIN_PATH = Path("/var/lib/runv/bulletin/posts.ndjson")
     34 MAX_BODY_LEN = 500
     35 DEFAULT_LIST_LIMIT = 20
     36 
     37 
     38 def bulletin_paths() -> tuple[Path, Path]:
     39     raw = os.environ.get("RUNV_BULLETIN_PATH", "").strip()
     40     ndjson = Path(raw) if raw else DEFAULT_BULLETIN_PATH
     41     lock = ndjson.parent / "posts.lock"
     42     return ndjson, lock
     43 
     44 
     45 def current_username() -> str:
     46     import pwd
     47 
     48     try:
     49         name = pwd.getpwuid(os.getuid()).pw_name
     50     except (KeyError, OSError) as e:
     51         rc.friendly_exit(f"não foi possível determinar o utilizador: {e}")
     52     return rc.validate_username(name)
     53 
     54 
     55 def new_post_id(username: str) -> str:
     56     ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
     57     suffix = secrets.token_hex(3)
     58     return f"{ts}-{username}-{suffix}"
     59 
     60 
     61 def append_post(body: str) -> dict[str, Any]:
     62     username = current_username()
     63     clean = body.strip()
     64     if not clean:
     65         rc.friendly_exit("mensagem obrigatória.")
     66     if "\x00" in clean:
     67         rc.friendly_exit("mensagem inválida (caracter NUL).")
     68     if len(clean) > MAX_BODY_LEN:
     69         rc.friendly_exit(f"mensagem demasiado longa (máximo {MAX_BODY_LEN} caracteres).")
     70 
     71     post = {
     72         "id": new_post_id(username),
     73         "username": username,
     74         "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
     75         "body": clean,
     76     }
     77     line = json.dumps(post, ensure_ascii=False) + "\n"
     78 
     79     ndjson_path, lock_path = bulletin_paths()
     80     try:
     81         ndjson_path.parent.mkdir(parents=True, exist_ok=True)
     82     except OSError as e:
     83         rc.friendly_exit(f"não foi possível criar o diretório do mural: {e}")
     84 
     85     try:
     86         import fcntl
     87 
     88         lock_path.parent.mkdir(parents=True, exist_ok=True)
     89         with open(lock_path, "a+", encoding="utf-8") as lock_f:
     90             fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX)
     91             try:
     92                 with open(ndjson_path, "a", encoding="utf-8") as out:
     93                     out.write(line)
     94             finally:
     95                 fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN)
     96     except PermissionError:
     97         rc.friendly_exit(
     98             f"sem permissão para escrever em {ndjson_path}\n"
     99             "peça ao admin para criar o diretório com permissões de grupo para membros"
    100         )
    101     except OSError as e:
    102         rc.friendly_exit(f"erro ao escrever no mural: {e}")
    103 
    104     return post
    105 
    106 
    107 def read_posts(limit: int) -> list[dict[str, Any]]:
    108     ndjson_path, _lock_path = bulletin_paths()
    109     if not ndjson_path.is_file():
    110         return []
    111     try:
    112         raw = ndjson_path.read_text(encoding="utf-8")
    113     except OSError:
    114         return []
    115     posts: list[dict[str, Any]] = []
    116     for line in raw.splitlines():
    117         line = line.strip()
    118         if not line:
    119             continue
    120         try:
    121             item = json.loads(line)
    122         except json.JSONDecodeError:
    123             continue
    124         if isinstance(item, dict) and isinstance(item.get("id"), str):
    125             posts.append(item)
    126     return posts[-limit:]
    127 
    128 
    129 def format_post_line(post: dict[str, Any]) -> str:
    130     created = post.get("created_at", "")
    131     display_time = created
    132     try:
    133         if isinstance(created, str) and created:
    134             dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
    135             display_time = dt.astimezone().strftime("%Y-%m-%d %H:%M")
    136     except ValueError:
    137         pass
    138     user = post.get("username", "?")
    139     body = post.get("body", "")
    140     return f"[{display_time}] {user}\n  {body}"
    141 
    142 
    143 def cmd_list(limit: int, as_json: bool) -> int:
    144     posts = read_posts(limit)
    145     if as_json:
    146         print(json.dumps(posts, ensure_ascii=False, indent=2))
    147         return 0
    148     if not posts:
    149         print("mural vazio ainda.")
    150         return 0
    151     print("Mural runv.club\n")
    152     for post in posts:
    153         print()
    154         print(format_post_line(post))
    155     print()
    156     return 0
    157 
    158 
    159 def build_parser() -> argparse.ArgumentParser:
    160     p = argparse.ArgumentParser(
    161         prog="runv-bulletin",
    162         description="Mural comunitário simples em terminal.",
    163     )
    164     p.add_argument("--json", action="store_true", help="saída em JSON (list)")
    165     p.add_argument(
    166         "--limit",
    167         type=int,
    168         default=None,
    169         metavar="N",
    170         help=f"últimos N posts (padrão {DEFAULT_LIST_LIMIT} em list)",
    171     )
    172     sub = p.add_subparsers(dest="command")
    173     sub.add_parser("list", help="listar posts recentes")
    174     post_p = sub.add_parser("post", help="publicar mensagem")
    175     post_p.add_argument("message", help="texto do post")
    176     return p
    177 
    178 
    179 def main(argv: list[str] | None = None) -> int:
    180     try:
    181         args = build_parser().parse_args(argv)
    182         if args.command is None:
    183             args.command = "list"
    184 
    185         limit = args.limit if args.limit is not None else DEFAULT_LIST_LIMIT
    186         if limit < 1:
    187             rc.friendly_exit("--limit deve ser um inteiro positivo.")
    188 
    189         if args.command == "post":
    190             append_post(args.message)
    191             print("post publicado.")
    192             return 0
    193 
    194         return cmd_list(limit, bool(args.json))
    195     except KeyboardInterrupt:
    196         print("\nInterrompido.", file=sys.stderr)
    197         return 130
    198     except SystemExit:
    199         raise
    200     except Exception as e:
    201         rc.friendly_exit(f"erro: {e}")
    202 
    203 
    204 if __name__ == "__main__":
    205     raise SystemExit(main())