Contenido desde OASIS y colectivos

Comunicados, comisiones y debates salen del pub: los publica quien es
miembro de la tribu de la acampada y además sigue la cuenta de la
acampada. Directo enlaza al HUB. Lista de centros sociales y colectivos
de Madrid con enlaces comprobados.
This commit is contained in:
hacklab 2026-09-29 10:25:28 +02:00
parent f78773bb87
commit cb551e2a3c
13 changed files with 505 additions and 261 deletions

View file

@ -1,6 +1,7 @@
# acampada26s.net
Web de la acampada del 26S en Madrid. Comunicados, directo y cómo organizarse.
Web de la acampada del 26S en Madrid. Comunicados, directo, cómo organizarse
y dónde están los colectivos.
HTML y CSS. Sin JavaScript en el navegador, sin cookies, sin analítica y sin
peticiones a terceros. La tipografía va dentro del repo.
@ -14,69 +15,95 @@ cae o la tiran, el pub y lo publicado siguen en la red.
```
site/ lo que se sirve (copiar tal cual a /var/www/acampada26s.net)
deploy/ nginx y timer de systemd
tools/ limpiar imágenes y regenerar el directo
tools/ generador desde OASIS y limpieza de imágenes
```
## Publicar un comunicado
## Qué sale de dónde
1. Copiar `site/comunicados/2026-09-26-convocatoria.html` a
`site/comunicados/AAAA-MM-DD-titulo.html` y cambiar título, fecha y texto.
2. Añadir un `<li>` arriba del todo en la lista `#comunicados` de `site/index.html`.
3. Fotos a `site/img/`, siempre limpias antes:
- **Directo**: enlace al HUB del pub (`/c?type=feed`). Lo sube la gente de
comunicación desde OASIS. No pasa por esta web.
- **Comunicados, comisiones y debates**: los genera `tools/` a partir de OASIS
cada dos minutos. Nadie edita HTML a mano.
- **Colectivos**: lista fija en `site/index.html`. Para añadir uno, un `<li>`
más. Solo webs o cuentas comprobadas.
```
tools/limpia-img.sh site/img/*.jpg
```
## Publicar desde OASIS
El EXIF lleva GPS, modelo de móvil y hora. Además: caras tapadas salvo
permiso expreso.
Quien publica en la web: miembros de la tribu pública de la acampada **a los
que la cuenta de la acampada sigue**. Hacen falta las dos cosas. Unirse a una
tribu es un mensaje que cualquiera puede publicar sobre sí mismo (el código de
invitación solo lo comprueba la interfaz), así que la tribu sola no vale como
permiso. El follow lo firma la cuenta de la acampada y no se puede falsificar.
4. Toda imagen con `alt`, `width` y `height`.
- Dar permiso: invitar a la tribu y seguir desde la cuenta de la acampada.
- Quitarlo: dejar de seguir. Sus comunicados desaparecen en la siguiente pasada.
Qué se publica:
- **Comunicado**: un post con `#comunicado26s`. La primera línea es el título.
Las imágenes adjuntas salen en la página. Si se borra en OASIS, se borra aquí.
- **Comisión**: una subtribu pública de la tribu de la acampada, creada por
alguien con permiso.
- **Debate**: un foro público abierto por alguien con permiso.
Las tribus privadas y lo que se escribe dentro de las tribus va cifrado; eso
no sale en la web ni debe salir.
La tribu tiene que ser pública (no anónima) y con invitación estricta, y la
tiene que crear la cuenta de la acampada.
## Imágenes
Las de los comunicados las sirve el pub (`/c/blob/...`). Las que se suban a
mano a `site/img/`, siempre limpias antes:
```
tools/limpia-img.sh site/img/*.jpg
```
El EXIF lleva GPS, modelo de móvil y hora. Caras tapadas salvo permiso expreso.
## Despliegue
Requisitos: nginx, certbot. Para el directo: el pub OASIS en la misma máquina,
Node 22 y Python 3.9+.
Requisitos: nginx, certbot, el pub OASIS en la misma máquina, Node 22 y
Python 3.9+.
```
rsync -a --delete site/ /var/www/acampada26s.net/
rsync -a --exclude comunicados/ site/ /var/www/acampada26s.net/
cp deploy/nginx.conf /etc/nginx/sites-available/acampada26s.net
cp deploy/hub.conf /etc/nginx/snippets/acampada26s-hub.conf
ln -s ../sites-available/acampada26s.net /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
```
`rsync --delete` borra el bloque directo generado; el timer lo repone en un minuto.
Un `rsync` de `index.html` deja los bloques generados vacíos; el timer los
repone en dos minutos.
### Pub OASIS
El pub se arranca con `sh oasis.sh server` y el HUB web escucha en `:3000`.
Conviene que escuche solo en local (`--host=127.0.0.1`): nginx publica `/c`
en solo lectura (GET/HEAD) y le quita la IP del visitante.
`sh oasis.sh server`. El HUB web escucha en `:3000`; mejor solo en local
(`--host=127.0.0.1`). nginx publica `/c` en solo lectura y sin la IP de quien
visita.
Invite para la home: `sh oasis.sh invite 1000`, y pegarlo en `#organizate`.
### Directo
`tools/ultimos.js` lee los últimos posts de la identidad de la acampada por el
socket local del sbot. `tools/directo.py` los escribe en `index.html` entre
`<!-- directo:inicio -->` y `<!-- directo:fin -->`, escapado y con escritura
atómica. Sigue siendo un fichero estático: aguanta mucha carga y no hace falta
JavaScript.
### Generador
```
cp -r . /opt/26s
cp deploy/directo.service deploy/directo.timer /etc/systemd/system/
# editar FEED, OASIS_DIR y User en directo.service
systemctl enable --now directo.timer
cp deploy/web26s.service deploy/web26s.timer /etc/systemd/system/
# rellenar CUENTA, TRIBU, OASIS_DIR y User en web26s.service
systemctl enable --now web26s.timer
```
A mano:
```
OASIS_DIR=~/oasis node tools/ultimos.js @feed.ed25519 5 | python3 tools/directo.py site/index.html
OASIS_DIR=~/oasis CUENTA=@...ed25519 TRIBU=%...sha256 node tools/extrae.js \
| python3 tools/genera.py site
```
`extrae.js` solo lee del sbot por su socket local. `genera.py` escapa todo lo
que viene de SSB, solo deja enlaces `http(s)` y escribe de forma atómica.
## Comprobaciones antes de subir
```

View file

@ -1,16 +0,0 @@
[Unit]
Description=acampada26s: regenera el bloque DIRECTO de la home
After=oasis.service
[Service]
Type=oneshot
User=oasis
Environment=OASIS_DIR=/home/oasis/oasis
Environment=FEED=@CAMBIAR.ed25519
Environment=WEB=/var/www/acampada26s.net/index.html
# node tiene que estar en el PATH del servicio (o poner la ruta completa)
ExecStart=/bin/sh -c 'node /opt/26s/tools/ultimos.js "$FEED" 5 | python3 /opt/26s/tools/directo.py "$WEB"'
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/www/acampada26s.net
PrivateTmp=yes

View file

@ -1,9 +0,0 @@
[Unit]
Description=acampada26s: DIRECTO cada minuto
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target

18
deploy/web26s.service Normal file
View file

@ -0,0 +1,18 @@
[Unit]
Description=acampada26s: comunicados, comisiones y debates desde OASIS
After=oasis.service
[Service]
Type=oneshot
User=oasis
Environment=OASIS_DIR=/home/oasis/oasis
# cuenta de la acampada y clave (%...sha256) de su tribu pública
Environment=CUENTA=
Environment=TRIBU=
Environment=WEB=/var/www/acampada26s.net
# node tiene que estar en el PATH del servicio (o poner la ruta completa)
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

9
deploy/web26s.timer Normal file
View file

@ -0,0 +1,9 @@
[Unit]
Description=acampada26s: actualizar cada 2 minutos
[Timer]
OnBootSec=1min
OnUnitActiveSec=2min
[Install]
WantedBy=timers.target

View file

@ -1,31 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; img-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'">
<meta name="referrer" content="no-referrer">
<title>Primer día en la plaza · Acampada 26S</title>
<meta name="description" content="Comunicados, directo y organización de la acampada del 26S en Madrid.">
<link rel="stylesheet" href="/css/main.css">
<link rel="preload" href="/fonts/anton.woff2" as="font" type="font/woff2" crossorigin>
</head>
<body>
<header class="w">
<p><a class="bt volver" href="/">Acampada 26S</a></p>
</header>
<main class="w">
<article>
<h1>Primer día en la plaza</h1>
<p><time datetime="2026-09-27">27.09.2026</time> <span class="borrador">Borrador</span></p>
<p>Crónica de la primera jornada pendiente de aprobar en asamblea.</p>
<p>Las fotos se suben limpias de metadatos: <code>tools/limpia-img.sh</code>.</p>
</article>
</main>
<footer class="w">
Textos CC BY-SA 4.0 · Código AGPL-3.0 · <a href="https://gitea.laenre.net/hacklab/26s" rel="noopener noreferrer">Fuente</a>
</footer>
</body>
</html>

View file

@ -96,3 +96,11 @@ footer { margin: 64px 0 24px; border-top: var(--bd); padding-top: 12px; font-siz
.cuadro:hover, .cuadro:focus-visible,
.bt:hover, .bt:focus-visible { transform: none; }
}
/* colectivos */
.colectivos { list-style: none; margin: 0 0 16px; padding: 0; columns: 2 280px; column-gap: 32px; }
.colectivos li { break-inside: avoid; padding: 6px 0; border-bottom: 1px solid var(--fg); }
.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; }

View file

@ -23,44 +23,71 @@
<nav aria-label="Redes">
<ul class="botonera">
<li><a class="bt" href="/c">OASIS</a></li>
<li><a class="bt" href="https://t.me/CAMBIAR" rel="noopener noreferrer">Telegram</a></li>
<li><a class="bt" href="https://CAMBIAR/@acampada26s" rel="noopener noreferrer">Mastodon</a></li>
<li><a class="bt" href="https://www.instagram.com/CAMBIAR" rel="noopener noreferrer">Instagram</a></li>
<li><a class="bt" href="https://x.com/CAMBIAR" rel="noopener noreferrer">X</a></li>
<li><a class="bt" href="https://0asis.net" rel="noopener noreferrer">Instalar OASIS</a></li>
</ul>
</nav>
</header>
<main class="w">
<!-- directo:inicio -->
<!-- directo:fin -->
<section id="comunicados">
<h2>Comunicados</h2>
<ol class="lista" reversed>
<li>
<time datetime="2026-09-27">27.09.2026</time> <span class="borrador">Borrador</span>
<h3><a href="/comunicados/2026-09-27-primer-dia.html">Primer día en la plaza</a></h3>
<p>Resumen de la primera jornada y de las asambleas convocadas.</p>
</li>
<li>
<time datetime="2026-09-26">26.09.2026</time> <span class="borrador">Borrador</span>
<h3><a href="/comunicados/2026-09-26-convocatoria.html">Convocatoria 26S</a></h3>
<p>Por qué acampamos, dónde y cómo sumarse.</p>
</li>
</ol>
<!-- comunicados:inicio -->
<p>Todavía no hay comunicados.</p>
<!-- comunicados:fin -->
</section>
<section id="organizate">
<h2>Organízate</h2>
<p>La acampada publica en su propio pub de <a href="/c">OASIS</a>, una red social libre sobre Secure Scuttlebutt. Sin servidores de empresa y sin cuentas que puedan cerrar desde fuera. Lo que se publica en la plaza aparece en <a href="/c?type=feed">Directo</a>.</p>
<h3>Entrar al pub</h3>
<p>La acampada se organiza en <a href="/c">OASIS</a>, 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.</p>
<ol>
<li>Instala OASIS en el ordenador o la APK en el móvil.</li>
<li>Abre OASIS, ve a <em>Invites</em> y pega este código:</li>
<li>Instala OASIS desde <a href="https://0asis.net" rel="noopener noreferrer">0asis.net</a>, en el móvil o en el ordenador.</li>
<li>Busca la tribu <strong>Acampada 26S</strong>.</li>
<li>Entra en la comisión que te toque y en los debates.</li>
</ol>
<pre><code>acampada26s.net:8008:@CAMBIAR.ed25519~CAMBIAR</code></pre>
<p>A partir de ahí puedes leer, publicar y hablar con el resto de la acampada aunque esta web se caiga.</p>
<p><a href="/c?type=feed">Directo</a> es lo que la acampada va subiendo al pub. Se ve sin instalar nada.</p>
<h3>Comisiones</h3>
<!-- comisiones:inicio -->
<p>Las comisiones se crean como tribus dentro de OASIS.</p>
<!-- comisiones:fin -->
<h3>Debates</h3>
<!-- debates:inicio -->
<p>Los debates se abren como foros dentro de OASIS.</p>
<!-- debates:fin -->
</section>
<section id="colectivos">
<h2>Colectivos</h2>
<p>Centros sociales y colectivos de Madrid. Aquí también hay información y asambleas.</p>
<ul class="colectivos">
<li><a href="https://csolarosa.net" rel="noopener noreferrer">CSO La Rosa</a> <span>Centro · Lavapiés</span></li>
<li><a href="https://3peces3.wordpress.com" rel="noopener noreferrer">CS Tres Peces Tres</a> <span>Centro · Lavapiés</span></li>
<li><a href="https://eskalerakarakola.org" rel="noopener noreferrer">Eskalera Karakola</a> <span>Centro · Lavapiés</span></li>
<li><a href="https://estaesunaplaza.blogspot.com" rel="noopener noreferrer">Esta es una Plaza</a> <span>Centro · Lavapiés</span></li>
<li><a href="https://latabacalera.net" rel="noopener noreferrer">CSA La Tabacalera</a> <span>Centro · Lavapiés</span></li>
<li><a href="https://ateneomaliciosa.net" rel="noopener noreferrer">Ateneo La Maliciosa</a> <span>Arganzuela</span></li>
<li><a href="https://www.instagram.com/evarganzuela/" rel="noopener noreferrer">EVA · Espacio Vecinal Arganzuela</a> <span>Arganzuela</span></li>
<li><a href="https://eslaeko.net" rel="noopener noreferrer">ESLA EKO</a> <span>Carabanchel</span></li>
<li><a href="https://asambleadecarabanchel.org" rel="noopener noreferrer">Asamblea Popular de Carabanchel</a> <span>Carabanchel</span></li>
<li><a href="https://ateneolibertariocarabanchellatina.wordpress.com" rel="noopener noreferrer">Ateneo Libertario Carabanchel-Latina</a> <span>Carabanchel</span></li>
<li><a href="https://www.laenre.net" rel="noopener noreferrer">CSOA La Enredadera</a> <span>Tetuán</span></li>
<li><a href="https://www.instagram.com/csolaanimosa/" rel="noopener noreferrer">CSO La Animosa</a> <span>Hortaleza</span></li>
<li><a href="https://www.lapiluka.org" rel="noopener noreferrer">CS La Piluka</a> <span>Fuencarral · Barrio del Pilar</span></li>
<li><a href="https://www.instagram.com/elsoldelaconce/" rel="noopener noreferrer">El Sol de la Conce</a> <span>Ciudad Lineal · La Concepción</span></li>
<li><a href="https://www.csseco.org" rel="noopener noreferrer">CS Seco</a> <span>Retiro</span></li>
<li><a href="https://lavillana.org" rel="noopener noreferrer">La Villana de Vallekas</a> <span>Puente de Vallecas</span></li>
<li><a href="https://www.ateneorepublicanodevallecas.org" rel="noopener noreferrer">Ateneo Republicano de Vallekas</a> <span>Puente de Vallecas</span></li>
<li><a href="https://apoyalacasika.wordpress.com" rel="noopener noreferrer">CSOA La Casika</a> <span>Móstoles</span></li>
</ul>
<h3>Vivienda y coordinadoras</h3>
<ul class="colectivos">
<li><a href="https://inquilinato.org" rel="noopener noreferrer">Sindicato de Inquilinas de Madrid</a></li>
<li><a href="https://x.com/pahmadrid" rel="noopener noreferrer">PAH Madrid</a></li>
<li><a href="https://coordinadoraviviendamadrid.com" rel="noopener noreferrer">Coordinadora de Vivienda de Madrid</a></li>
<li><a href="https://yayoflautasmadrid.org" rel="noopener noreferrer">Yayoflautas Madrid</a></li>
</ul>
<p>¿Falta tu colectivo? Escribe a la comisión de comunicación en OASIS.</p>
</section>
</main>

View file

@ -1,89 +0,0 @@
#!/usr/bin/env python3
"""Reescribe el bloque DIRECTO de index.html con los últimos posts del pub.
Lee de stdin el JSON de tools/ultimos.js y sustituye lo que haya entre
<!-- directo:inicio --> y <!-- directo:fin -->. Escritura atómica: el
servidor web nunca ve un fichero a medias.
node tools/ultimos.js @feed.ed25519 5 | python3 tools/directo.py /var/www/acampada26s.net/index.html
"""
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
INI = "<!-- directo:inicio -->"
FIN = "<!-- directo:fin -->"
MAX = 400 # caracteres por post
TZ = ZoneInfo("Europe/Madrid")
def limpia(texto):
# fuera markdown de imágenes/enlaces y referencias ssb, queda el texto
texto = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", texto)
texto = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", texto)
texto = " ".join(texto.split())
if len(texto) > MAX:
texto = texto[:MAX].rsplit(" ", 1)[0] + "…"
return texto
def bloque(posts):
li = []
for p in posts:
t = datetime.fromtimestamp(p["ts"] / 1000, tz=TZ)
img = ""
if p.get("img"):
src = "/c/blob/" + quote(p["img"], safe="")
img = f'\n <img src="{html.escape(src)}" alt="" loading="lazy">'
li.append(
" <li>\n"
f' <time datetime="{t.isoformat(timespec="minutes")}">{t:%d.%m %H:%M}</time>{img}\n'
f" <p>{html.escape(limpia(p['text']))}</p>\n"
" </li>"
)
if not li:
return f"{INI}\n {FIN}"
return (
f'{INI}\n <section id="directo">\n <h2>Directo</h2>\n'
f' <ol class="lista">\n{chr(10).join(li)}\n </ol>\n'
f' <p><a href="/c?type=feed">Todo en OASIS</a></p>\n </section>\n {FIN}'
)
def main():
if len(sys.argv) != 2:
sys.exit("uso: ... | directo.py ruta/index.html")
ruta = sys.argv[1]
posts = json.load(sys.stdin)
if not isinstance(posts, list):
sys.exit("entrada no es una lista")
with open(ruta, encoding="utf-8") as f:
doc = f.read()
i, j = doc.find(INI), doc.find(FIN)
if i < 0 or j < i:
sys.exit("no están las marcas directo:inicio / directo:fin")
nuevo = doc[:i] + bloque(posts) + doc[j + len(FIN):]
if nuevo == doc:
return
d = os.path.dirname(os.path.abspath(ruta))
fd, tmp = tempfile.mkstemp(dir=d, prefix=".index.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(nuevo)
os.chmod(tmp, 0o644)
os.replace(tmp, ruta)
except BaseException:
os.unlink(tmp)
raise
if __name__ == "__main__":
main()

161
tools/extrae.js Executable file
View file

@ -0,0 +1,161 @@
#!/usr/bin/env node
// Lee del sbot de OASIS lo que va a la web y lo saca en JSON para tools/genera.py.
//
// Quién publica: miembros de la tribu TRIBU a los que CUENTA sigue, más CUENTA.
// La tribu sola no basta: en SSB cualquiera puede publicar que se une a una tribu
// (la invitación solo la comprueba la interfaz de OASIS). El follow de CUENTA sí
// está firmado por CUENTA.
//
// comunicados posts con #comunicado26s de quien publica
// comisiones subtribus públicas de TRIBU creadas por quien publica
// debates foros públicos abiertos por quien publica
//
// Uso: OASIS_DIR=~/oasis CUENTA=@...ed25519 TRIBU=%...sha256 node tools/extrae.js
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'));
// igual que STRUCTURAL_FIELDS en src/models/tribes_model.js
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);
// Cadena de una tribu (create + updates por `replaces`) con las mismas reglas que
// buildTribeIndex: el autor raíz cambia lo que quiera; otro miembro solo puede
// entrar o salir él mismo. Devuelve el último estado válido.
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));
// a quién sigue CUENTA (el último contact gana)
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)]);
// borrado: tombstone del propio autor
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);
});

193
tools/genera.py Executable file
View file

@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Escribe en la web lo que sale de tools/extrae.js.
Lee el JSON por stdin y, dentro de SITE:
- rellena en index.html los bloques entre <!-- X:inicio --> y <!-- X:fin -->
para X = comunicados, comisiones, debates
- crea comunicados/AAAA-MM-DD-xxxxxxxx.html por comunicado y borra los que
ya no están (borrados en OASIS o de alguien que ya no publica)
Todo el texto que viene de SSB se escapa. Escritura atómica: nginx nunca sirve
un fichero a medias.
node tools/extrae.js | python3 tools/genera.py /var/www/acampada26s.net
"""
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 sin markdown de imágenes/enlaces ni la etiqueta, en una línea."""
texto = IMG.sub("", texto)
texto = ENLACE.sub(r"\1", texto)
texto = ETIQUETA.sub("", texto)
return " ".join(texto.split())
def partir(texto):
"""Primera línea con contenido = título; el resto = cuerpo."""
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):
"""Markdown mínimo: párrafos, saltos, imágenes del pub y enlaces http(s)."""
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'<img src="{html.escape(blob(m.group(1)))}" alt="" loading="lazy">')
else:
trozos.append(f'<a href="{html.escape(m.group(3))}" rel="noopener noreferrer">'
f"{html.escape(m.group(2))}</a>")
pos = m.end()
trozos.append(html.escape(parrafo[pos:]))
p = "".join(trozos).strip().replace("\n", "<br>\n")
out.append(f" <p>{p}</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"<!-- {clave}:inicio -->", f"<!-- {clave}:fin -->"
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 " <p>Todavía no hay comunicados.</p>"
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(
" <li>\n"
f' <time datetime="{t:%Y-%m-%d}">{t:%d.%m.%Y}</time>\n'
f' <h3><a href="/comunicados/{nombre(c)}">{html.escape(titulo)}</a></h3>\n'
+ (f" <p>{html.escape(ext)}</p>\n" if ext else "")
+ " </li>")
return ' <ol class="lista">\n' + "\n".join(li) + "\n </ol>"
def lista_simple(items, vacio, sub):
if not items:
return f" <p>{vacio}</p>"
li = []
for it in items:
s = sub(it)
li.append(f" <li><strong>{html.escape(it['titulo'])}</strong>"
+ (f" · {html.escape(s)}" if s else "") + "</li>")
return ' <ul class="lista">\n' + "\n".join(li) + "\n </ul>"
def pagina(plantilla, c):
t = fecha(c["ts"])
titulo, resto = partir(c["texto"])
art = (
"<article>\n"
f" <h1>{html.escape(titulo)}</h1>\n"
f' <p><time datetime="{t.isoformat(timespec="minutes")}">{t:%d.%m.%Y %H:%M}</time></p>\n'
f"{cuerpo(resto)}\n"
" </article>")
doc = cambia(plantilla, "articulo", " " + art)
return doc.replace("<title>Comunicado · Acampada 26S</title>",
f"<title>{html.escape(titulo)} · Acampada 26S</title>")
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()

View file

@ -5,23 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; img-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'">
<meta name="referrer" content="no-referrer">
<title>Convocatoria 26S · Acampada 26S</title>
<title>Comunicado · Acampada 26S</title>
<meta name="description" content="Comunicados, directo y organización de la acampada del 26S en Madrid.">
<link rel="stylesheet" href="/css/main.css">
<link rel="preload" href="/fonts/anton.woff2" as="font" type="font/woff2" crossorigin>
</head>
<body>
<header class="w">
<p><a class="bt volver" href="/">Acampada 26S</a></p>
<a class="bt volver" href="/">Acampada 26S</a>
</header>
<main class="w">
<article>
<h1>Convocatoria 26S</h1>
<p><time datetime="2026-09-26">26.09.2026</time> <span class="borrador">Borrador</span></p>
<p>Texto de la convocatoria pendiente de aprobar en asamblea.</p>
<p>Lugar, hora y motivos van aquí.</p>
</article>
<!-- articulo:inicio -->
<!-- articulo:fin -->
</main>
<footer class="w">

View file

@ -1,50 +0,0 @@
#!/usr/bin/env node
// Saca los últimos posts de un feed SSB en JSON, para tools/directo.py.
// Se ejecuta en el servidor del pub, con el sbot de OASIS arrancado.
// Uso: OASIS_DIR=~/oasis node tools/ultimos.js @feed.ed25519 [N]
const path = require('path');
const dir = process.env.OASIS_DIR || path.join(process.env.HOME, 'oasis');
const ssbConfig = require(path.join(dir, 'src', 'server', 'ssb_config'));
const ssbClient = require(path.join(dir, 'src', 'server', 'node_modules', 'ssb-client'));
const id = process.argv[2];
const n = Math.min(50, Math.max(1, parseInt(process.argv[3] || '5', 10)));
if (!id || !id.startsWith('@')) {
console.error('uso: node tools/ultimos.js @feed.ed25519 [N]');
process.exit(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((ssb) => {
const out = [];
const src = ssb.createUserStream({ id, reverse: true, limit: 200 });
const next = () => src(null, (end, msg) => {
if (end || out.length >= n) {
src(true, () => {});
ssb.close(() => console.log(JSON.stringify(out)));
return;
}
const c = msg.value && msg.value.content;
if (c && c.type === 'post' && typeof c.text === 'string') {
const img = (Array.isArray(c.mentions) ? c.mentions : [])
.find((m) => m && typeof m.link === 'string' && m.link.startsWith('&') &&
typeof m.type === 'string' && m.type.startsWith('image/'));
// OASIS no siempre pone type en mentions: se mira también el markdown
const md = c.text.match(/!\[[^\]]*\]\((&[A-Za-z0-9+/=]+\.sha256)\)/);
out.push({
key: msg.key,
ts: msg.value.timestamp,
text: c.text,
img: img ? img.link : (md ? md[1] : null),
});
}
next();
});
next();
}).catch((e) => {
console.error(e.message);
process.exit(1);
});