Web de la acampada del 26S

Home con directo, comunicados y organización. HTML y CSS, sin JS.
Configuración de nginx, timer del directo y limpieza de EXIF.
This commit is contained in:
hacklab 2026-09-29 10:10:54 +02:00
commit f78773bb87
15 changed files with 1335 additions and 0 deletions

89
tools/directo.py Executable file
View file

@ -0,0 +1,89 @@
#!/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()

26
tools/limpia-img.sh Executable file
View file

@ -0,0 +1,26 @@
#!/bin/sh
# Quita metadatos (EXIF, GPS, modelo de móvil) de las imágenes antes de subirlas.
# Uso: tools/limpia-img.sh foto1.jpg foto2.png ...
# Necesita exiftool (libimage-exiftool-perl) o mat2.
set -eu
[ $# -gt 0 ] || { echo "uso: $0 imagen..." >&2; exit 1; }
if command -v exiftool >/dev/null 2>&1; then
exiftool -all= -overwrite_original -- "$@"
elif command -v mat2 >/dev/null 2>&1; then
for f in "$@"; do
mat2 --inplace -- "$f"
done
else
echo "falta exiftool o mat2" >&2
exit 1
fi
# comprobación: no debe quedar GPS
if command -v exiftool >/dev/null 2>&1; then
if exiftool -q -q -if '$GPSLatitude' -p '$FileName' -- "$@" 2>/dev/null | grep .; then
echo "ojo: quedan coordenadas GPS en las de arriba" >&2
exit 1
fi
fi

50
tools/ultimos.js Executable file
View file

@ -0,0 +1,50 @@
#!/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);
});