diff --git a/README.md b/README.md index 9683d02..5b32659 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Web de la acampada del 26S en Madrid. HTML y CSS, sin JavaScript. 1. `site/` va a `/var/www/acampada26s.net`. `deploy/nginx.conf` y `deploy/hub.conf` a nginx. -2. Directo es el HUB del pub OASIS (`/c`), servido en local en `127.0.0.1:3000`. -3. Comunicados, comisiones y debates salen de OASIS con `deploy/web26s.timer`. Rellenar `CUENTA` y `TRIBU` en `deploy/web26s.service`. -4. Publica quien está en la tribu pública de la acampada y lo sigue la cuenta de la acampada. Comunicado: post con `#comunicado26s`, primera línea de título. Comisión: subtribu pública. Debate: foro público. +2. `/c` es el HUB del pub OASIS, en `127.0.0.1:3000` de la misma máquina. +3. Directo: `/c?type=feed`. Comunicados: `/c/inhabitant/CUENTA_ACAMPADA?type=posts`. +4. Cambiar `CUENTA_ACAMPADA` en `site/index.html` por la id de la cuenta de la acampada codificada (`@` → `%40`, `/` → `%2F`, `=` → `%3D`). Esa cuenta tiene que tener activada la visibilidad clearnet en OASIS y el pub tiene que seguirla. 5. Fotos subidas a mano: `tools/limpia-img.sh` antes, siempre. Código AGPL-3.0. Textos CC BY-SA 4.0. Tipografía Anton, OFL. diff --git a/deploy/web26s.service b/deploy/web26s.service deleted file mode 100644 index 80875c7..0000000 --- a/deploy/web26s.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=acampada26s: comunicados, comisiones y debates desde OASIS -After=oasis.service - -[Service] -Type=oneshot -User=oasis -Environment=OASIS_DIR=/home/oasis/oasis -Environment=CUENTA= -Environment=TRIBU= -Environment=WEB=/var/www/acampada26s.net -ExecStart=/bin/sh -c 'set -e; node /opt/26s/tools/extrae.js > /tmp/26s.json; python3 /opt/26s/tools/genera.py "$WEB" < /tmp/26s.json' -NoNewPrivileges=yes -ProtectSystem=strict -ReadWritePaths=/var/www/acampada26s.net -PrivateTmp=yes diff --git a/deploy/web26s.timer b/deploy/web26s.timer deleted file mode 100644 index 92488b5..0000000 --- a/deploy/web26s.timer +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=acampada26s: actualizar cada 2 minutos - -[Timer] -OnBootSec=1min -OnUnitActiveSec=2min - -[Install] -WantedBy=timers.target diff --git a/site/css/main.css b/site/css/main.css index 4fa0fe4..ac1f37a 100644 --- a/site/css/main.css +++ b/site/css/main.css @@ -57,7 +57,6 @@ h3 { font-size: 26px; margin: 0 0 6px; } } .cuadro { font-size: 30px; padding: 28px 12px; } .bt { font-size: 20px; padding: 12px; } -.volver { display: inline-block; margin-top: 24px; } .cuadro:hover, .cuadro:focus-visible, .bt:hover, .bt:focus-visible { @@ -67,21 +66,6 @@ h3 { font-size: 26px; margin: 0 0 6px; } outline: none; } -.lista { list-style: none; margin: 0; padding: 0; } -.lista > li { border-bottom: 1px solid var(--fg); padding: 20px 0; } -.lista a:hover, .lista a:focus-visible { color: var(--acc); } -time { font-family: var(--tit); font-size: 18px; } -.borrador { border: var(--bd); padding: 0 6px; font-family: var(--tit); font-size: 14px; } - -article { max-width: 720px; } -article p { margin: 0 0 1em; } - -code, pre { - font: 14px/1.4 ui-monospace, "DejaVu Sans Mono", monospace; - word-break: break-all; -} -pre { border: var(--bd); padding: 12px; white-space: pre-wrap; margin: 0 0 1em; } - footer { margin: 64px 0 24px; border-top: var(--bd); padding-top: 12px; font-size: 14px; } @media (max-width: 640px) { @@ -100,4 +84,3 @@ footer { margin: 64px 0 24px; border-top: var(--bd); padding-top: 12px; font-siz .colectivos a { font-family: var(--tit); font-size: 22px; text-transform: uppercase; text-decoration: none; } .colectivos a:hover, .colectivos a:focus-visible { color: var(--acc); } .colectivos span { display: block; font-size: 14px; } -.lista strong { font-family: var(--tit); font-weight: 400; font-size: 22px; text-transform: uppercase; } diff --git a/site/index.html b/site/index.html index 8609ed6..8cc9c07 100644 --- a/site/index.html +++ b/site/index.html @@ -16,7 +16,7 @@ @@ -29,13 +29,6 @@
-
-

Comunicados

- -

Todavía no hay comunicados.

- -
-

Organízate

La acampada se organiza en OASIS, una red social libre sobre Secure Scuttlebutt. No hay empresa detrás ni cuentas que se puedan cerrar desde fuera. Al instalarla entras solo en La Plaza, el pub común, y desde ahí llegas a la acampada.

@@ -44,17 +37,8 @@
  • Busca la tribu Acampada 26S.
  • Entra en la comisión que te toque y en los debates.
  • -

    Directo es lo que la acampada va subiendo al pub. Se ve sin instalar nada.

    - -

    Comisiones

    - -

    Las comisiones se crean como tribus dentro de OASIS.

    - - -

    Debates

    - -

    Los debates se abren como foros dentro de OASIS.

    - +

    Directo y comunicados se ven sin instalar nada.

    +

    Las comisiones son tribus y los debates son foros. Se entra desde la app.

    diff --git a/tools/extrae.js b/tools/extrae.js deleted file mode 100755 index 4759ff0..0000000 --- a/tools/extrae.js +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env node -const path = require('path'); - -const dir = process.env.OASIS_DIR || path.join(process.env.HOME, 'oasis'); -const CUENTA = process.env.CUENTA; -const TRIBU = process.env.TRIBU; -const ETIQUETA = 'comunicado26s'; - -if (!CUENTA || !CUENTA.startsWith('@') || !TRIBU || !TRIBU.startsWith('%')) { - console.error('faltan CUENTA=@... y TRIBU=%...'); - process.exit(1); -} - -const ssbConfig = require(path.join(dir, 'src', 'server', 'ssb_config')); -const ssbClient = require(path.join(dir, 'src', 'server', 'node_modules', 'ssb-client')); - -const ESTRUCTURA = ['title', 'description', 'image', 'location', 'tags', 'isAnonymous', - 'inviteMode', 'status', 'parentTribeId', 'mapUrl']; - -const todos = (src) => new Promise((res, rej) => { - const out = []; - const leer = () => src(null, (end, m) => { - if (end === true) return res(out); - if (end) return rej(end); - out.push(m); - leer(); - }); - leer(); -}); - -const lista = (x) => (Array.isArray(x) ? x : []); -const igual = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); - -function puntaTribu(raiz, msgs) { - const porPadre = new Map(); - for (const m of msgs) { - const r = m.value.content.replaces; - if (!r) continue; - if (!porPadre.has(r)) porPadre.set(r, []); - porPadre.get(r).push(m); - } - const autorRaiz = raiz.value.author; - let punta = raiz; - for (;;) { - const prev = punta.value.content; - const validos = lista(porPadre.get(punta.key)).filter((m) => { - const c = m.value.content; - const a = m.value.author; - if (a === autorRaiz) return true; - const antes = lista(prev.members); - const despues = lista(c.members); - if (!antes.includes(a) && !despues.includes(a)) return false; - const altas = despues.filter((x) => !antes.includes(x)); - const bajas = antes.filter((x) => !despues.includes(x)); - const delta = altas.length + bajas.length === 0 || - (altas.length === 1 && bajas.length === 0 && altas[0] === a) || - (bajas.length === 1 && altas.length === 0 && bajas[0] === a); - if (!delta) return false; - const inv = new Set(lista(prev.invites).map((i) => i && i.codeHash)); - if (lista(c.invites).some((i) => i && i.codeHash && !inv.has(i.codeHash))) return false; - return ESTRUCTURA.every((f) => igual(prev[f], c[f])); - }); - if (!validos.length) return punta; - validos.sort((x, y) => - (y.value.author === autorRaiz) - (x.value.author === autorRaiz) || - x.value.timestamp - y.value.timestamp || - (x.key < y.key ? -1 : 1)); - punta = validos[0]; - } -} - -function imagenes(texto) { - const re = /!\[[^\]]*\]\((&[A-Za-z0-9+/=]+\.sha256)\)/g; - return [...texto.matchAll(re)].map((m) => m[1]); -} - -const pub = (ssbConfig.keys.public || '').replace('.ed25519', ''); -const remote = `unix:${path.join(ssbConfig.path, 'socket')}~noauth:${pub}`; - -ssbClient(ssbConfig.keys, { remote, caps: ssbConfig.caps }).then(async (ssb) => { - const tipo = (type) => todos(ssb.messagesByType({ type })); - const [contactos, tribus, posts, feeds, foros, lapidas] = await Promise.all( - ['contact', 'tribe', 'post', 'feed', 'forum', 'tombstone'].map(tipo)); - - const sigue = new Map(); - for (const m of contactos) { - const c = m.value.content; - if (m.value.author !== CUENTA || typeof c.contact !== 'string') continue; - if (typeof c.following === 'boolean') sigue.set(c.contact, c.following && !c.blocking); - else if (c.blocking) sigue.set(c.contact, false); - } - - const raiz = tribus.find((m) => m.key === TRIBU); - if (!raiz || raiz.value.author !== CUENTA || raiz.value.content.isAnonymous !== false) { - throw new Error('TRIBU no existe, no es pública o no la creó CUENTA'); - } - const deTribu = (r) => tribus.filter((m) => m.value.content.replaces).concat([r]); - const miembros = lista(puntaTribu(raiz, deTribu(raiz)).value.content.members); - const editores = new Set([CUENTA, ...miembros.filter((id) => sigue.get(id) === true)]); - - const borrado = new Set(lapidas - .filter((m) => typeof m.value.content.target === 'string') - .map((m) => `${m.value.author} ${m.value.content.target}`)); - const vivo = (m) => !borrado.has(`${m.value.author} ${m.key}`); - const deEditor = (m) => editores.has(m.value.author) && vivo(m); - - const lleva = (c) => (typeof c.text === 'string' && - c.text.toLowerCase().includes(`#${ETIQUETA}`)) || - lista(c.tags).some((t) => String(t).toLowerCase().replace(/^#/, '') === ETIQUETA); - - const comunicados = posts.concat(feeds) - .filter((m) => deEditor(m) && !m.value.content.root && lleva(m.value.content)) - .map((m) => ({ - key: m.key, - autor: m.value.author, - ts: m.value.timestamp, - texto: m.value.content.text, - imgs: imagenes(m.value.content.text), - })); - - const comisiones = tribus - .filter((m) => !m.value.content.replaces && m.value.content.parentTribeId === TRIBU && - m.value.content.isAnonymous === false && deEditor(m)) - .map((r) => { - const c = puntaTribu(r, deTribu(r)).value.content; - return { key: r.key, ts: r.value.timestamp, titulo: c.title || '', texto: c.description || '' }; - }); - - const debates = foros - .filter((m) => typeof m.value.content === 'object' && !m.value.content.root && - !m.value.content.isPrivate && deEditor(m)) - .map((m) => ({ - key: m.key, - ts: m.value.timestamp, - titulo: m.value.content.title || '', - categoria: m.value.content.category || '', - })); - - ssb.close(() => console.log(JSON.stringify({ comunicados, comisiones, debates }))); -}).catch((e) => { - console.error(e.message); - process.exit(1); -}); diff --git a/tools/genera.py b/tools/genera.py deleted file mode 100755 index 961b16f..0000000 --- a/tools/genera.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import html -import json -import os -import re -import sys -import tempfile -from datetime import datetime -from urllib.parse import quote -from zoneinfo import ZoneInfo - -TZ = ZoneInfo("Europe/Madrid") -ETIQUETA = re.compile(r"#comunicado26s\b", re.I) -IMG = re.compile(r"!\[[^\]]*\]\((&[A-Za-z0-9+/=]+\.sha256)\)") -ENLACE = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)") -NOMBRE = re.compile(r"^\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.html$") -EXTRACTO = 240 - - -def fecha(ts): - return datetime.fromtimestamp(ts / 1000, tz=TZ) - - -def blob(ref): - return "/c/blob/" + quote(ref, safe="") - - -def plano(texto): - texto = IMG.sub("", texto) - texto = ENLACE.sub(r"\1", texto) - texto = ETIQUETA.sub("", texto) - return " ".join(texto.split()) - - -def partir(texto): - lineas = ETIQUETA.sub("", texto).replace("\r", "").split("\n") - for i, l in enumerate(lineas): - t = plano(l).lstrip("# ").strip() - if t: - return t[:120], "\n".join(lineas[i + 1:]) - return "Comunicado", "" - - -def cuerpo(texto): - out = [] - for parrafo in re.split(r"\n\s*\n", texto.replace("\r", "").strip()): - if not parrafo.strip(): - continue - trozos, pos = [], 0 - for m in re.finditer(f"{IMG.pattern}|{ENLACE.pattern}", parrafo): - trozos.append(html.escape(parrafo[pos:m.start()])) - if m.group(1): - trozos.append(f'') - else: - trozos.append(f'' - f"{html.escape(m.group(2))}") - pos = m.end() - trozos.append(html.escape(parrafo[pos:])) - p = "".join(trozos).strip().replace("\n", "
    \n") - out.append(f"

    {p}

    ") - return "\n".join(out) - - -def nombre(c): - h = hashlib.sha256(c["key"].encode()).hexdigest()[:8] - return f"{fecha(c['ts']):%Y-%m-%d}-{h}.html" - - -def cambia(doc, clave, contenido): - ini, fin = f"", f"" - i, j = doc.find(ini), doc.find(fin) - if i < 0 or j < i: - sys.exit(f"faltan las marcas {clave}:inicio / {clave}:fin") - return doc[:i + len(ini)] + "\n" + contenido + "\n " + doc[j:] - - -def escribe(ruta, texto): - try: - with open(ruta, encoding="utf-8") as f: - if f.read() == texto: - return - except FileNotFoundError: - pass - fd, tmp = tempfile.mkstemp(dir=os.path.dirname(ruta), prefix=".tmp.") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(texto) - os.chmod(tmp, 0o644) - os.replace(tmp, ruta) - except BaseException: - os.unlink(tmp) - raise - - -def lista_comunicados(cs): - if not cs: - return "

    Todavía no hay comunicados.

    " - li = [] - for c in cs: - t = fecha(c["ts"]) - titulo, resto = partir(c["texto"]) - ext = plano(resto) - if len(ext) > EXTRACTO: - ext = ext[:EXTRACTO].rsplit(" ", 1)[0] + "…" - li.append( - "
  • \n" - f' \n' - f'

    {html.escape(titulo)}

    \n' - + (f"

    {html.escape(ext)}

    \n" if ext else "") - + "
  • ") - return '
      \n' + "\n".join(li) + "\n
    " - - -def lista_simple(items, vacio, sub): - if not items: - return f"

    {vacio}

    " - li = [] - for it in items: - s = sub(it) - li.append(f"
  • {html.escape(it['titulo'])}" - + (f" · {html.escape(s)}" if s else "") + "
  • ") - return ' " - - -def pagina(plantilla, c): - t = fecha(c["ts"]) - titulo, resto = partir(c["texto"]) - art = ( - "
    \n" - f"

    {html.escape(titulo)}

    \n" - f'

    \n' - f"{cuerpo(resto)}\n" - "
    ") - doc = cambia(plantilla, "articulo", " " + art) - return doc.replace("Comunicado · Acampada 26S", - f"{html.escape(titulo)} · Acampada 26S") - - -def main(): - if len(sys.argv) != 2: - sys.exit("uso: node tools/extrae.js | genera.py SITE") - site = sys.argv[1] - datos = json.load(sys.stdin) - cs = sorted(datos.get("comunicados", []), key=lambda c: c["ts"], reverse=True) - com = sorted(datos.get("comisiones", []), key=lambda c: c["titulo"].lower()) - deb = sorted(datos.get("debates", []), key=lambda c: c["ts"], reverse=True) - - idx = os.path.join(site, "index.html") - with open(idx, encoding="utf-8") as f: - doc = f.read() - doc = cambia(doc, "comunicados", lista_comunicados(cs)) - doc = cambia(doc, "comisiones", lista_simple( - com, "Las comisiones se crean como tribus dentro de OASIS.", - lambda c: plano(c["texto"])[:EXTRACTO])) - doc = cambia(doc, "debates", lista_simple( - deb, "Los debates se abren como foros dentro de OASIS.", - lambda d: d["categoria"])) - escribe(idx, doc) - - dcom = os.path.join(site, "comunicados") - os.makedirs(dcom, exist_ok=True) - with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla.html"), - encoding="utf-8") as f: - plantilla = f.read() - quedan = set() - for c in cs: - n = nombre(c) - quedan.add(n) - escribe(os.path.join(dcom, n), pagina(plantilla, c)) - for n in os.listdir(dcom): - if NOMBRE.match(n) and n not in quedan: - os.unlink(os.path.join(dcom, n)) - - -if __name__ == "__main__": - main() diff --git a/tools/plantilla.html b/tools/plantilla.html deleted file mode 100644 index 13b7cda..0000000 --- a/tools/plantilla.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - -Comunicado · Acampada 26S - - - - - -
    - Acampada 26S -
    - -
    - - -
    - - - -