commit bcfb4cf7ef7047901a858670eb10de83755855db
parent 5f26e521c93f0a7514d0a981ab9a649eff0c63dc
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sun, 22 Mar 2026 19:27:13 -0300
documentação: still-1-1-3
Diffstat:
8 files changed, 189 insertions(+), 202 deletions(-)
diff --git a/docs/06-site-and-apache.md b/docs/06-site-and-apache.md
@@ -15,7 +15,8 @@
- Opcional: `--certbot` (incompatível com `--dev`).
- Após cópia, por omissão chama `build_directory.py` para gravar `data/members.json` no DocumentRoot (`--no-refresh-members` para omitir).
- **`--sync-public-only`:** só copia `site/public/` → DocumentRoot, `chown www-data` e regenera `members.json`; **não** altera Apache (uso típico após `create_runv_user.py` e disponível para correr à mão).
-- Versão actual do script: constante `VERSION` no ficheiro (ex.: `0.05`).
+- O VirtualHost HTTP gerado inclui **`ForceType text/xml`** para **`/news/feed.rss`**, para o browser mostrar o XML do feed em vez de forçar descarga (Chromium com outros MIME). Se o **Certbot** criou um VirtualHost **HTTPS** separado, esse ficheiro não é regenerado pelo `genlanding` — pode ser necessário acrescentar o mesmo bloco `<Directory …/news>` com `<Files "feed.rss">` nesse vhost ou voltar a alinhar a configuração manualmente.
+- Versão actual do script: constante `VERSION` no ficheiro (ex.: `0.06`).
## TLS e DNS
diff --git a/docs/11-daily-operations.md b/docs/11-daily-operations.md
@@ -34,7 +34,9 @@ sudo python3 REPO/site/genlanding.py --sync-public-only \
## Notícias
-- Colocar `.md` em `site/news/`, executar `site/news/publish_news.py`; depois voltar a copiar `public/` ou correr `genlanding.py` se aplicável.
+- Colocar `.md` em `site/news/`, executar **`sudo python3 REPO/site/news/publish_news.py`** em produção. O script grava `site/public/news/` (JSON, RSS, sitemap) e, se **`/var/www/runv.club/html`** existir, invoca **`site/genlanding.py --sync-public-only`** no fim (copia `site/public/` para o DocumentRoot + `data/members.json`). Se o DocumentRoot não existir no ambiente (ex.: só clone local), aparece um AVISO com o comando manual — a publicação em `site/public/` fica feita.
+- **`--skip-genlanding`:** só gera ficheiros em `site/public/` sem copiar para Apache.
+- Ajustar paths com `--landing-document-root`, `--members-users-json` e opcionalmente `--members-homes-root` se a tua instalação divergir dos defaults.
## Wiki
diff --git a/docs/13-troubleshooting.md b/docs/13-troubleshooting.md
@@ -22,6 +22,10 @@
- `apache2ctl configtest` após alterações de vhost.
- `genlanding.py` imprime erros se `build_directory` falhar.
+## Feed RSS descarrega em vez de abrir no browser
+
+- O vhost HTTP gerado pelo `genlanding` força `text/xml` em `/news/feed.rss`. Se só o vhost **HTTPS** (ex.: Certbot) servir o site, confira se esse ficheiro inclui o mesmo bloco ou volte a correr `genlanding` e funda alterações com o SSL existente (ver [06-site-and-apache.md](06-site-and-apache.md)).
+
## Quotas
- FS não ext4 → automatização de `starthere.py` pode recusar; configurar manualmente ou usar volume ext4.
diff --git a/scripts/admin/runv_landing_sync.py b/scripts/admin/runv_landing_sync.py
@@ -17,6 +17,28 @@ _SCRIPT_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _SCRIPT_DIR.parent.parent
+def genlanding_sync_command(
+ *,
+ document_root: Path,
+ users_json: Path,
+ homes_root: Path | None = None,
+) -> list[str]:
+ """Comando completo para ``site/genlanding.py --sync-public-only`` (lista para subprocess)."""
+ script = _REPO_ROOT / "site" / "genlanding.py"
+ cmd: list[str] = [
+ sys.executable,
+ str(script),
+ "--sync-public-only",
+ "--document-root",
+ str(document_root),
+ "--members-users-json",
+ str(users_json),
+ ]
+ if homes_root is not None:
+ cmd.extend(["--members-homes-root", str(homes_root)])
+ return cmd
+
+
def try_sync_landing_via_genlanding(
*,
document_root: Path,
@@ -36,17 +58,11 @@ def try_sync_landing_via_genlanding(
script,
)
return False, None
- cmd = [
- sys.executable,
- str(script),
- "--sync-public-only",
- "--document-root",
- str(document_root),
- "--members-users-json",
- str(users_json),
- ]
- if homes_root is not None:
- cmd.extend(["--members-homes-root", str(homes_root)])
+ cmd = genlanding_sync_command(
+ document_root=document_root,
+ users_json=users_json,
+ homes_root=homes_root,
+ )
out = document_root / "data" / "members.json"
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py
@@ -373,47 +373,61 @@ def wait_for_unit_active(
def ensure_le_tls_readable_for_molly(
cert_path: Path,
+ key_path: Path,
*,
dry_run: bool,
log: logging.Logger,
) -> None:
"""
- Ajusta /etc/letsencrypt/live e archive (e o directório do certificado) para 755, e
+ Ajusta /etc/letsencrypt/live, archive, live/<domínio>, archive/<domínio> para 755 e
archive/<domínio>/privkey*.pem para grupo ssl-cert + 640, para o molly-brown ler a chave.
- Só actua se cert_path estiver sob .../live/<domínio>/ (Let's Encrypt típico).
- Usa raízes resolvidas para não saltar quando /etc/letsencrypt/live é symlink.
+
+ Usa caminhos lógicos (sem resolver fullchain.pem → archive), porque o symlink típico do
+ Let's Encrypt fazia falhar a detecção quando se aplicava resolve() ao certificado.
"""
+ cert_p = Path(cert_path)
+ key_p = Path(key_path)
+
try:
- cert_resolved = _path_resolved(cert_path)
- except OSError as e:
- log.debug("LE TLS: resolve %s: %s — salto", cert_path, e)
+ cert_rel = cert_p.relative_to(LETSENCRYPT_LIVE)
+ except ValueError:
+ log.debug(
+ "LE TLS: cert_path não está sob %s (%s) — salto",
+ LETSENCRYPT_LIVE,
+ cert_p,
+ )
return
- live_root = _path_resolved(LETSENCRYPT_LIVE)
- archive_root = _path_resolved(LETSENCRYPT_ARCHIVE)
+ cparts = cert_rel.parts
+ if len(cparts) < 2:
+ log.debug(
+ "LE TLS: esperado %s/<domínio>/<ficheiro> — salto (%s)",
+ LETSENCRYPT_LIVE,
+ cert_p,
+ )
+ return
+ domain = cparts[0]
try:
- cert_resolved.relative_to(live_root)
+ key_rel = key_p.relative_to(LETSENCRYPT_LIVE)
except ValueError:
log.debug(
- "LE TLS: cert não está sob a árvore LE resolvida (%s) — salto (%s)",
- live_root,
- cert_resolved,
+ "LE TLS: key_path não está sob %s (%s) — salto",
+ LETSENCRYPT_LIVE,
+ key_p,
)
return
-
- live_domain_dir = cert_resolved.parent
- parent_resolved = _path_resolved(live_domain_dir.parent)
- if parent_resolved != live_root:
+ if not key_rel.parts or key_rel.parts[0] != domain:
log.debug(
- "LE TLS: esperado .../live/<domínio>/fullchain.pem — salto (pai=%s live_root=%s)",
- parent_resolved,
- live_root,
+ "LE TLS: key_path não está sob %s/%s/ — salto (%s)",
+ LETSENCRYPT_LIVE,
+ domain,
+ key_p,
)
return
- domain = live_domain_dir.name
- archive_domain_dir = archive_root / domain
+ live_domain_dir = LETSENCRYPT_LIVE / domain
+ archive_domain_dir = LETSENCRYPT_ARCHIVE / domain
try:
ssl_gid = grp.getgrnam(SSL_CERT_GROUP).gr_gid
@@ -425,8 +439,8 @@ def ensure_le_tls_readable_for_molly(
ssl_gid = None
dirs_755: list[Path] = [
- live_root,
- archive_root,
+ LETSENCRYPT_LIVE,
+ LETSENCRYPT_ARCHIVE,
live_domain_dir,
]
if archive_domain_dir.is_dir():
@@ -1156,7 +1170,7 @@ def main(argv: list[str] | None = None) -> int:
return 1
if not args.skip_gemini:
- ensure_le_tls_readable_for_molly(cert, dry_run=args.dry_run, log=log)
+ ensure_le_tls_readable_for_molly(cert, key, dry_run=args.dry_run, log=log)
pkgs: list[str] = []
if not args.skip_install:
diff --git a/site/genlanding.py b/site/genlanding.py
@@ -4,12 +4,13 @@ Configura o Apache (Debian) para servir a landing runv.club: VirtualHost,
mod_userdir + mod_rewrite, cópia de site/public para DocumentRoot, redirect
www → apex em HTTP. Produção ou modo --dev para testes locais.
Metadados SEO: editar site/public/. FAQ estático: public/faq/ (copiado com o resto).
-Notícias: site/news/publish_news.py gera public/news/data/news.json e feed.rss —
-depois volte a correr este script para copiar.
+Notícias: site/news/publish_news.py gera public/news/data/news.json e feed.rss e, em produção,
+tenta ``genlanding --sync-public-only`` no fim (DocumentRoot existente). O VirtualHost abaixo
+força ``text/xml`` em ``/news/feed.rss`` para o browser mostrar o feed em vez de descarregar.
Executar como root (excepto --dry-run). Apenas biblioteca padrão Python 3.
-Versão 0.05 — runv.club
+Versão 0.06 — runv.club
"""
from __future__ import annotations
@@ -26,7 +27,7 @@ import sys
from pathlib import Path
from typing import Final
-VERSION: Final[str] = "0.05"
+VERSION: Final[str] = "0.06"
EXIT_OK: Final[int] = 0
EXIT_USAGE: Final[int] = 1
EXIT_ERROR: Final[int] = 2
@@ -95,6 +96,13 @@ def render_vhost(
Require all granted
</Directory>
+ # Chromium descarrega feed.rss com alguns MIME; text/xml mostra o XML na aba.
+ <Directory {document_root}/news>
+ <Files "feed.rss">
+ ForceType text/xml
+ </Files>
+ </Directory>
+
ErrorLog ${{APACHE_LOG_DIR}}/{log_tag}-error.log
CustomLog ${{APACHE_LOG_DIR}}/{log_tag}-access.log combined
</VirtualHost>
diff --git a/site/news/publish_news.py b/site/news/publish_news.py
@@ -14,8 +14,12 @@ Os ``.md`` processados são **apagados**. Ficheiros cujo nome começa por ``_``
Não versionar notícias no HTML: os dados ficam em ``news.json`` (tipicamente ignorado pelo git
no servidor após gerar conteúdo local).
+Após publicar (sem ``--dry-run``), tenta ``site/genlanding.py --sync-public-only`` quando o
+DocumentRoot da landing existir (por omissão ``/var/www/runv.club/html``), para copiar
+``site/public/`` para o Apache. Em produção use ``sudo``. ``--skip-genlanding`` omite esse passo.
+
Uso::
- python3 site/news/publish_news.py [--dry-run] [--verbose]
+ sudo python3 site/news/publish_news.py [--dry-run] [--verbose] [--skip-genlanding]
"""
from __future__ import annotations
@@ -24,6 +28,7 @@ import argparse
import html
import json
import re
+import subprocess
import sys
import uuid
from xml.sax.saxutils import escape as xml_escape
@@ -34,6 +39,8 @@ from zoneinfo import ZoneInfo
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_SITE = SCRIPT_DIR.parent
+_REPO_ROOT = REPO_SITE.parent
+
PUBLIC_NEWS = REPO_SITE / "public" / "news"
DATA_DIR = PUBLIC_NEWS / "data"
JSON_PATH = DATA_DIR / "news.json"
@@ -44,6 +51,64 @@ TZ_BR: Final[str] = "America/Sao_Paulo"
# Brasil sem DST: fallback se ``tzdata`` não estiver instalado (ex.: Windows minimal).
BR_FALLBACK_TZ = timezone(timedelta(hours=-3))
SITE_URL: Final[str] = "https://runv.club"
+DEFAULT_LANDING_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv.club/html")
+DEFAULT_MEMBERS_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json")
+
+
+def sync_landing_after_news(
+ *,
+ document_root: Path,
+ members_users_json: Path,
+ members_homes_root: Path | None,
+ verbose: bool,
+) -> int:
+ """
+ Copia site/public → DocumentRoot via genlanding --sync-public-only.
+ Devolve 0 se omitido (sem script / sem DocumentRoot) ou sync OK; 1 se genlanding falhou.
+ """
+ gl = _REPO_ROOT / "site" / "genlanding.py"
+ if not gl.is_file():
+ print(
+ f"AVISO: genlanding.py não encontrado em {gl}; não sincronizou DocumentRoot.",
+ file=sys.stderr,
+ )
+ return 0
+ root = document_root.resolve()
+ if not root.is_dir():
+ homes_opt = ""
+ if members_homes_root is not None:
+ homes_opt = f" --members-homes-root {members_homes_root.resolve()}"
+ print(
+ f"AVISO: DocumentRoot da landing inexistente ({root}) — site/public não foi copiado para Apache.\n"
+ f"Manual: sudo python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only "
+ f"--document-root {root} --members-users-json {members_users_json}{homes_opt}",
+ file=sys.stderr,
+ )
+ return 0
+ admin = _REPO_ROOT / "scripts" / "admin"
+ if str(admin) not in sys.path:
+ sys.path.insert(0, str(admin))
+ from runv_landing_sync import genlanding_sync_command
+
+ cmd = genlanding_sync_command(
+ document_root=root,
+ users_json=members_users_json.resolve(),
+ homes_root=members_homes_root.resolve() if members_homes_root else None,
+ )
+ if verbose:
+ print(f" $ {' '.join(cmd)}")
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+ if r.returncode == 0:
+ print(f"Landing sincronizada (public + members) → {root}")
+ return 0
+ combined = ((r.stdout or "") + "\n" + (r.stderr or "")).strip()
+ print(
+ f"Erro: genlanding --sync-public-only terminou com código {r.returncode}.",
+ file=sys.stderr,
+ )
+ if combined:
+ print(combined[:4000], file=sys.stderr)
+ return 1
def _apply_underline(s: str) -> str:
@@ -230,6 +295,32 @@ def main() -> int:
ap = argparse.ArgumentParser(description="Publica notícias a partir de .md em site/news/")
ap.add_argument("--dry-run", action="store_true", help="Só mostra o que faria")
ap.add_argument("--verbose", "-v", action="store_true")
+ ap.add_argument(
+ "--landing-document-root",
+ type=Path,
+ default=DEFAULT_LANDING_DOCUMENT_ROOT,
+ help=(
+ "DocumentRoot Apache; se existir como directório e não usar --skip-genlanding, "
+ "corre site/genlanding.py --sync-public-only após publicar"
+ ),
+ )
+ ap.add_argument(
+ "--members-users-json",
+ type=Path,
+ default=DEFAULT_MEMBERS_USERS_JSON,
+ help="Fonte para data/members.json no genlanding (default: /var/lib/runv/users.json)",
+ )
+ ap.add_argument(
+ "--members-homes-root",
+ type=Path,
+ default=None,
+ help="Opcional: --members-homes-root para genlanding (ex. /home)",
+ )
+ ap.add_argument(
+ "--skip-genlanding",
+ action="store_true",
+ help="Não copiar site/public para DocumentRoot após publicar",
+ )
args = ap.parse_args()
try:
@@ -285,6 +376,17 @@ def main() -> int:
print(f" removido {path.name}")
print(f"Publicadas {len(new_entries)} notícia(s). Total: {len(articles)}.")
+
+ if not args.skip_genlanding:
+ rc = sync_landing_after_news(
+ document_root=args.landing_document_root,
+ members_users_json=args.members_users_json,
+ members_homes_root=args.members_homes_root,
+ verbose=args.verbose,
+ )
+ if rc != 0:
+ return rc
+
return 0
diff --git a/tests/test_patch_irc.py b/tests/test_patch_irc.py
@@ -1,160 +0,0 @@
-"""
-Testes unitários para patches/patch_irc.py (parsing, idempotência, autoconnect).
-Executar na raiz do repo: python3 -m unittest tests.test_patch_irc -v
-
-No Windows o módulo alvo não carrega (falta ``pwd``); os testes são ignorados.
-"""
-
-from __future__ import annotations
-
-import importlib.util
-import logging
-import sys
-import tempfile
-import unittest
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parent.parent
-
-
-def _load_patch_irc():
- path = ROOT / "patches" / "patch_irc.py"
- spec = importlib.util.spec_from_file_location("patch_irc_test_mod", path)
- assert spec and spec.loader
- mod = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(mod)
- return mod
-
-
-class _PatchIrcTestBase(unittest.TestCase):
- p = None
-
- @classmethod
- def setUpClass(cls) -> None:
- if sys.platform.startswith("win"):
- raise unittest.SkipTest("patch_irc e estes testes requerem Unix (pwd)")
- cls.p = _load_patch_irc()
-
-
-def _runv_section(p, username: str) -> str:
- nicks = p.expected_nicks(username)
- return f"""[server]
-runv.addresses = "irc.portalidea.com.br/6697"
-runv.tls = on
-runv.nicks = "{nicks}"
-runv.username = "{username}"
-runv.realname = "{username}"
-runv.autoconnect = on
-runv.autojoin = "#runv"
-"""
-
-
-class TestParseServers(_PatchIrcTestBase):
- def test_parse_all_server_names(self) -> None:
- p = self.p
- text = """
-[server]
-runv.addresses = "x"
-libera.addresses = "y"
-runv.tls = on
-"""
- names = p.parse_all_server_names(text)
- self.assertEqual(names, {"runv", "libera"})
-
- def test_parse_server_options(self) -> None:
- p = self.p
- text = _runv_section(p, "alice")
- o = p.parse_server_options(text, "runv")
- self.assertEqual(o.get("addresses"), "irc.portalidea.com.br/6697")
- self.assertTrue(p.tls_effective(o))
- self.assertEqual(o.get("autojoin"), "#runv")
-
-
-class TestCompliance(_PatchIrcTestBase):
- def setUp(self) -> None:
- self.log = logging.getLogger("t")
- self.log.disabled = True
-
- def test_fully_compliant_noop(self) -> None:
- p = self.p
- body = _runv_section(p, "bob")
- with tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False, encoding="utf-8") as f:
- f.write(body)
- path = Path(f.name)
- try:
- self.assertTrue(
- p.config_matches(
- path,
- server="runv",
- host="irc.portalidea.com.br",
- port=6697,
- tls=True,
- unix_username="bob",
- autojoin="#runv",
- log=self.log,
- )
- )
- finally:
- path.unlink(missing_ok=True)
-
- def test_other_autoconnect_breaks_compliance(self) -> None:
- p = self.p
- body = _runv_section(p, "bob") + """
-libera.addresses = "irc.libera.chat/6697"
-libera.tls = on
-libera.autoconnect = on
-"""
- with tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False, encoding="utf-8") as f:
- f.write(body)
- path = Path(f.name)
- try:
- self.assertFalse(
- p.config_matches(
- path,
- server="runv",
- host="irc.portalidea.com.br",
- port=6697,
- tls=True,
- unix_username="bob",
- autojoin="#runv",
- log=self.log,
- )
- )
- finally:
- path.unlink(missing_ok=True)
-
- def test_disable_other_chain(self) -> None:
- p = self.p
- body = _runv_section(p, "bob") + """
-libera.addresses = "irc.libera.chat/6697"
-libera.autoconnect = on
-"""
- chain = p.build_disable_other_autoconnect_chain(body, "runv")
- self.assertIn("/set irc.server.libera.autoconnect off", chain)
-
- def test_disable_chain_empty_when_all_off(self) -> None:
- p = self.p
- body = _runv_section(p, "bob") + """
-libera.addresses = "irc.libera.chat/6697"
-libera.autoconnect = off
-"""
- chain = p.build_disable_other_autoconnect_chain(body, "runv")
- self.assertEqual(chain, "")
-
- def test_server_add_has_tls_not_autoconnect_flag(self) -> None:
- p = self.p
- chain = p.build_apply_command_chain(
- server="runv",
- host="irc.portalidea.com.br",
- port=6697,
- tls=True,
- unix_username="u",
- autojoin="#runv",
- )
- self.assertIn("/server add runv irc.portalidea.com.br/6697 -tls", chain)
- first = chain.split(" ; ")[0]
- self.assertNotIn("-autoconnect", first)
-
-
-if __name__ == "__main__":
- unittest.main()