Cuenta de externas, arreglos de seguridad, títulos arriba y despliegue

- Rol «externas» para la asamblea de externas: pide sin cupo, siempre con el
  colectivo de fuera y su contacto, y sus solicitudes salen en naranja hasta
  que la asamblea de la Enre las confirma.
- /pedir/ explica en una línea que los colectivos externos piden fecha en la
  asamblea de externas, los lunes de 19:00 a 20:00.
- Seguridad: argon2 limitado a dos cálculos a la vez y el intento se cuenta
  antes de calcular (60 accesos simultáneos llevaban el proceso a 3,8 GB);
  bloqueo por cuenta a 20 fallos para que no se pueda dejar fuera a la
  asamblea; avisos por sesión en vez de por URL; sig sin «..»; eventos de
  31 días como mucho; parámetros de argon2 acotados.
- Títulos pegados arriba en todas las páginas; la franja de la enredadera
  recupera ese alto para seguir llenando la pantalla.
- despliegue/: instalar.sh, cuenta.sh, servicio de systemd con el proceso
  encerrado, fragmento de nginx que además deja de servir .git y las notas
  internas, y configuración de ejemplo. Ninguna contraseña en el repositorio.
This commit is contained in:
SITO 2026-09-23 12:15:33 +02:00
parent ce3127baff
commit 7d5dbeec60
24 changed files with 606 additions and 96 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@
# copias de trabajo
*.bak
index.css.puntuacion0
# el binario compilado para el servidor
despliegue/enrecal

69
despliegue/README.md Normal file
View file

@ -0,0 +1,69 @@
# Despliegue de /pedir/
El gestor de reservas es un programa en Go que escucha en `127.0.0.1:8090`.
nginx le pasa `/pedir/` y sigue sirviendo el resto de la web como hasta ahora.
| Qué | Dónde |
|---|---|
| programa y plantillas | `/opt/enrecal/` |
| configuración | `/etc/enrecal/config.json` |
| datos (registro, solo crece) | `/var/lib/enrecal/registro.jsonl` |
| accesos, para fail2ban | `/var/log/enrecal/auth.log` |
| servicio | `enrecal.service`, usuario `enrecal` |
## Primera vez
En el servidor, en la carpeta de la web (la que ya es un clon del repo):
git fetch && git checkout pedir-evento
sudo ./despliegue/instalar.sh
Después, dos cosas a mano:
1. Añadir esta línea dentro del `server{}` de www.laenre.net y recargar:
include /etc/nginx/snippets/enrecal.conf;
sudo nginx -t && sudo systemctl reload nginx
El fragmento también deja de servir `.git`, `gestor/`, `despliegue/`,
`README.md`, `context.txt` e `info.txt`, que ahora mismo se pueden
descargar desde la web.
2. Crear las cuentas. `cuenta.sh` para el servicio, crea la cuenta y lo
vuelve a arrancar:
sudo ./despliegue/cuenta.sh -usuario asamblea -nombre "Asamblea de La Enre" -rol enre -clave 'LA-CLAVE' -definitiva cuenta
sudo ./despliegue/cuenta.sh -usuario externas -nombre "Asamblea de externas" -rol externas cuenta
sudo ./despliegue/cuenta.sh -usuario hacklab -nombre "Hacklab La Raíz" cuenta
Sin `-clave`, se genera una al azar y se imprime: se entrega en mano y se
cambia al entrar. Con `-clave X` sin `-definitiva`, también se pide
cambiarla al entrar. Ninguna clave va en el repositorio.
Comprobar: `https://www.laenre.net/pedir/` carga y `https://www.laenre.net/.git/HEAD`
da 403.
## Actualizar
git pull
sudo ./despliegue/instalar.sh
No toca la configuración ni los datos.
## Si el servidor no tiene Go
`instalar.sh` compila si encuentra `go`. Si no, se compila en otra máquina y se
copia el binario a `despliegue/enrecal` antes de lanzar el instalador:
cd gestor && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false -o ../despliegue/enrecal .
scp ../despliegue/enrecal servidor:/ruta/de/la/web/despliegue/
## Día a día
- Otra clave para una cuenta: `sudo ./despliegue/cuenta.sh -usuario X clave`
- Registro del servicio: `journalctl -u enrecal`
- Copia de los datos: basta con copiar `/var/lib/enrecal/registro.jsonl`,
también con el servicio en marcha, porque solo se escribe al final.
- La configuración (cupo, ventana, qué finde es tranqui o con ruido) se cambia
en `/etc/enrecal/config.json` y se aplica con `sudo systemctl restart enrecal`.

View file

@ -0,0 +1,19 @@
{
"escucha": "127.0.0.1:8090",
"base_url": "https://www.laenre.net",
"dir_datos": "/var/lib/enrecal",
"dir_plantillas": "/opt/enrecal/plantillas",
"log_auth": "/var/log/enrecal/auth.log",
"cookie_segura": true,
"local": false,
"meses_vista": 4,
"reglas": {
"meses_ventana": 3,
"cupo_dias": 3,
"dias_minimo_confirmar": 7,
"dias_maximo_pendiente": 30,
"dias_reservables": [5, 6, 7],
"ancla_finde": "2026-09-25",
"turno_ancla": "ruido"
}
}

23
despliegue/cuenta.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
# Da de alta una cuenta o le cambia la clave, con el servicio parado.
#
# El gestor lee el registro al arrancar, así que una cuenta creada con el
# servicio en marcha no se vería hasta reiniciarlo. Este script lo para, hace
# la operación como el usuario enrecal (para que el registro no cambie de dueño)
# y lo vuelve a arrancar.
#
# sudo ./despliegue/cuenta.sh -usuario hacklab -nombre "Hacklab La Raíz" cuenta
# sudo ./despliegue/cuenta.sh -usuario hacklab clave
#
# Opciones: -rol enre|interno|externas, -clave X (si no, se genera una al
# azar), -definitiva (la clave no se pide cambiar al entrar).
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "Hay que lanzarlo con sudo." >&2
exit 1
fi
systemctl stop enrecal
trap 'systemctl start enrecal' EXIT
runuser -u enrecal -- /opt/enrecal/enrecal -config /etc/enrecal/config.json "$@"

View file

@ -0,0 +1,44 @@
[Unit]
Description=Gestor de reservas de La Enredadera (/pedir/)
After=network.target
[Service]
Type=simple
User=enrecal
Group=enrecal
ExecStart=/opt/enrecal/enrecal -config /etc/enrecal/config.json servir
Restart=on-failure
RestartSec=5
# Solo puede escribir en sus datos y en su registro de accesos.
ProtectSystem=strict
ReadWritePaths=/var/lib/enrecal /var/log/enrecal
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
UMask=0027
# Tope de memoria: el gestor limita argon2 a dos cálculos a la vez (unos
# 130 MB), así que 512 MB sobran y protegen al resto de servicios de la máquina.
MemoryMax=512M
[Install]
WantedBy=multi-user.target

77
despliegue/instalar.sh Executable file
View file

@ -0,0 +1,77 @@
#!/bin/sh
# Instala o actualiza el gestor de /pedir/ en el servidor.
#
# sudo ./despliegue/instalar.sh
#
# Se lanza desde la raíz del repositorio. Se puede repetir: la segunda vez solo
# actualiza el programa y las plantillas, y no toca ni la configuración ni los
# datos.
set -eu
RAIZ=$(cd "$(dirname "$0")/.." && pwd)
PROG=/opt/enrecal
DATOS=/var/lib/enrecal
LOGS=/var/log/enrecal
CONF=/etc/enrecal
if [ "$(id -u)" -ne 0 ]; then
echo "Hay que lanzarlo con sudo." >&2
exit 1
fi
# 1. El programa: se compila aquí si hay Go, o se usa el que se haya copiado a
# despliegue/enrecal (ver README.md).
if command -v go >/dev/null 2>&1; then
echo "Compilando..."
(cd "$RAIZ/gestor" && CGO_ENABLED=0 go build -trimpath -buildvcs=false -o "$RAIZ/despliegue/enrecal" .)
elif [ ! -x "$RAIZ/despliegue/enrecal" ]; then
echo "No hay Go en esta máquina ni un despliegue/enrecal ya compilado." >&2
echo "Compílalo en otra máquina y cópialo (ver despliegue/README.md)." >&2
exit 1
fi
# 2. Usuario propio, sin shell ni carpeta personal.
if ! id enrecal >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin enrecal
fi
# 3. Carpetas. El programa y las plantillas fuera de la web pública.
install -d -m 0755 "$PROG" "$PROG/plantillas"
install -d -m 0750 -o enrecal -g enrecal "$DATOS" "$LOGS"
install -d -m 0755 "$CONF"
install -m 0755 "$RAIZ/despliegue/enrecal" "$PROG/enrecal"
rm -f "$PROG"/plantillas/*.tmpl.html
install -m 0644 "$RAIZ"/gestor/plantillas/*.tmpl.html "$PROG/plantillas/"
if [ ! -f "$CONF/config.json" ]; then
install -m 0644 "$RAIZ/despliegue/config.ejemplo.json" "$CONF/config.json"
echo "Configuración nueva en $CONF/config.json"
fi
# 4. nginx: el límite de intentos va en http{} (conf.d) y el resto en un
# fragmento que se incluye con una línea dentro del server{} de la web.
if [ -d /etc/nginx ]; then
install -d -m 0755 /etc/nginx/conf.d /etc/nginx/snippets
install -m 0644 "$RAIZ/despliegue/nginx-zona.conf" /etc/nginx/conf.d/enrecal-zona.conf
install -m 0644 "$RAIZ/despliegue/nginx-laenre.conf" /etc/nginx/snippets/enrecal.conf
fi
# 5. systemd.
install -m 0644 "$RAIZ/despliegue/enrecal.service" /etc/systemd/system/enrecal.service
systemctl daemon-reload
systemctl enable enrecal >/dev/null 2>&1
systemctl restart enrecal
sleep 1
systemctl --no-pager --lines=3 status enrecal || true
cat <<'FIN'
Listo. Si es la primera vez, faltan dos cosas:
1. En el server{} de www.laenre.net, añadir esta línea y recargar nginx:
include /etc/nginx/snippets/enrecal.conf;
sudo nginx -t && sudo systemctl reload nginx
2. Crear las cuentas con despliegue/cuenta.sh (ver despliegue/README.md).
FIN

View file

@ -0,0 +1,28 @@
# Se incluye dentro del server{} de www.laenre.net:
# include /etc/nginx/snippets/enrecal.conf;
# La web se despliega con git, así que en la carpeta pública están también
# .git, el código del gestor y las notas internas. Nada de eso se sirve.
location ~ /\.(?!well-known) { deny all; }
location ~ ^/(gestor|despliegue)/ { deny all; }
location ~ ^/(README\.md|context\.txt|info\.txt)$ { deny all; }
location /pedir/ {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
# Imprescindible pisar las dos: si no, cualquiera manda su propia
# X-Real-IP y se salta el bloqueo por IP del gestor.
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 16k;
}
location = /pedir/entrar {
limit_req zone=enrecal burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 16k;
}

View file

@ -0,0 +1,3 @@
# Va dentro de http{}: en Debian, /etc/nginx/conf.d/ ya se incluye ahí.
# Diez intentos de acceso por minuto y por IP en /pedir/entrar.
limit_req_zone $binary_remote_addr zone=enrecal:1m rate=10r/m;

View file

@ -26,6 +26,19 @@ const (
argonSal = 16
)
// argonHuecos limita cuántos cálculos de argon2 corren a la vez. Cada uno
// reserva argonMemoria (64 MiB): sin este tope, sesenta intentos de acceso
// simultáneos llevaban el proceso a casi 4 GB y podían tumbar la máquina, que
// comparte con gitea y el resto de servicios de la Enre. Los que llegan de más
// esperan su turno en vez de reservar memoria.
var argonHuecos = make(chan struct{}, 2)
func argonID(clave, sal []byte, t, m uint32, p uint8, largo uint32) []byte {
argonHuecos <- struct{}{}
defer func() { <-argonHuecos }()
return argon2.IDKey(clave, sal, t, m, p, largo)
}
func aleatorio(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
@ -40,7 +53,7 @@ func nuevoID() string { return hex.EncodeToString(aleatorio(16)) }
// lleva dentro los parametros con los que se genero.
func HashClave(clave string) string {
sal := aleatorio(argonSal)
h := argon2.IDKey([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo)
h := argonID([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoria, argonIteraciones, argonHilos,
base64.RawStdEncoding.EncodeToString(sal),
@ -72,7 +85,13 @@ func VerificarClave(codificado, clave string) bool {
if err != nil {
return false
}
calculado := argon2.IDKey([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
// Los parámetros salen del propio hash, que escribimos nosotros; aun así se
// acotan, para que un registro manipulado no pida gigas de memoria.
if memoria <= 0 || memoria > argonMemoria || iteraciones <= 0 || iteraciones > 10 ||
hilos <= 0 || hilos > 16 || len(esperado) == 0 || len(esperado) > 64 {
return false
}
calculado := argonID([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
uint8(hilos), uint32(len(esperado)))
return subtle.ConstantTimeCompare(calculado, esperado) == 1
}
@ -187,6 +206,27 @@ func (s *Sesiones) TomarClave(id string) (string, string) {
return usuario, clave
}
func (s *Sesiones) PonerAviso(id, aviso, fallo string) {
s.mu.Lock()
defer s.mu.Unlock()
if ses, ok := s.m[id]; ok {
ses.Aviso, ses.Error = aviso, fallo
}
}
// TomarAviso devuelve el aviso pendiente y lo borra: se enseña una vez.
func (s *Sesiones) TomarAviso(id string) (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return "", ""
}
aviso, fallo := ses.Aviso, ses.Error
ses.Aviso, ses.Error = "", ""
return aviso, fallo
}
func (s *Sesiones) Borrar(id string) {
s.mu.Lock()
defer s.mu.Unlock()
@ -234,29 +274,22 @@ func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo {
}
}
func (c *Cerrojo) Bloqueado(claves ...string) (bool, time.Duration) {
// Intentar comprueba y apunta el intento en la misma operación, antes de
// calcular la clave. Si se apuntara después, como hacía Fallo, cien intentos
// simultáneos pasaban todos la comprobación antes de que se apuntara el
// primero.
func (c *Cerrojo) Intentar(clave string) (bool, time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
ahora := time.Now()
for _, k := range claves {
recientes := c.podar(k, ahora)
recientes := c.podar(clave, ahora)
if len(recientes) >= c.maxIntentos {
ultimo := recientes[len(recientes)-1]
if falta := c.espera - ahora.Sub(ultimo); falta > 0 {
return true, falta
if falta := c.espera - ahora.Sub(recientes[len(recientes)-1]); falta > 0 {
return false, falta
}
}
}
return false, 0
}
func (c *Cerrojo) Fallo(claves ...string) {
c.mu.Lock()
defer c.mu.Unlock()
ahora := time.Now()
for _, k := range claves {
c.fallos[k] = append(c.podar(k, ahora), ahora)
}
c.fallos[clave] = append(recientes, ahora)
return true, 0
}
func (c *Cerrojo) Limpiar(claves ...string) {

View file

@ -32,7 +32,7 @@ func ConfigPorDefecto() Config {
Escucha: "127.0.0.1:8090",
BaseURL: "https://www.laenre.net",
DirDatos: "/var/lib/enrecal",
DirPlantillas: "/var/www/laenre/gestor/plantillas",
DirPlantillas: "/opt/enrecal/plantillas",
DirEstatico: "/var/www/laenre",
LogAuth: "/var/log/enrecal/auth.log",
CookieSegura: true,
@ -89,6 +89,11 @@ func (c Config) Validar() error {
const (
inactividadSesion = 8 * time.Hour
maxIntentos = 5
// Por cuenta el tope es más alto que por IP: si fuera 5, cualquiera podría
// dejar a la asamblea sin entrar fallando 5 veces cada cuarto de hora. Con
// argon2 y claves de 12 caracteres, 20 intentos cada 10 minutos no bastan
// para adivinar nada.
maxIntentosCuenta = 20
ventanaIntentos = 10 * time.Minute
esperaBloqueo = 15 * time.Minute
)

View file

@ -25,7 +25,8 @@ type App struct {
cfg Config
alm Almacen
ses *Sesiones
cer *Cerrojo
cer *Cerrojo // por IP
cerU *Cerrojo // por cuenta
auth *AuthLog
// mu serializa las escrituras de eventos: comprobar las reglas y guardar
@ -51,7 +52,7 @@ func main() {
local = flag.Bool("local", false, "sirve tambien la web estatica, solo para probar sin nginx")
usuario = flag.String("usuario", "", "usuario, para las ordenes cuenta y clave")
nombre = flag.String("nombre", "", "nombre visible del colectivo, para la orden cuenta")
rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre o interno")
rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre, interno o externas")
claveFija = flag.String("clave", "", "clave temporal para cuenta y clave; sin ella se genera una al azar")
definitiva = flag.Bool("definitiva", false, "con -clave: la clave no caduca al entrar (minimo 12 caracteres)")
)
@ -156,6 +157,7 @@ func NuevaApp(cfg Config) (*App, error) {
alm: reg,
ses: NuevasSesiones(inactividadSesion),
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
cerU: NuevoCerrojo(maxIntentosCuenta, ventanaIntentos, esperaBloqueo),
auth: al,
plantillas: plantillas,
}, nil
@ -177,8 +179,17 @@ func (a *App) nombres() map[string]string {
return out
}
func (a *App) roles() map[string]Rol {
out := map[string]Rol{}
for _, c := range a.alm.Cuentas() {
out[c.Usuario] = c.Rol
}
return out
}
func (a *App) vista() Vista {
return Vista{
roles: a.roles(),
reglas: a.cfg.Reglas,
exc: a.alm.Excepciones(),
eventos: a.alm.Eventos(),
@ -224,8 +235,8 @@ func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiv
if nombre == "" {
return errors.New("falta -nombre: el nombre visible del colectivo")
}
if rol != RolEnre && rol != RolInterno {
return fmt.Errorf("el rol solo puede ser %q o %q", RolEnre, RolInterno)
if !rol.Valido() {
return fmt.Errorf("el rol solo puede ser %q, %q o %q", RolEnre, RolInterno, RolExternas)
}
if _, existe := a.alm.BuscarCuenta(usuario); existe {
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)

View file

@ -67,9 +67,15 @@ type Rol string
const (
RolEnre Rol = "enre" // la asamblea: confirma y rechaza
RolInterno Rol = "interno" // colectivo de la casa: solicita y amadrina
RolInterno Rol = "interno" // colectivo de la Enre: solicita y amadrina
// RolExternas es la cuenta de la asamblea de externas: apunta los eventos
// de colectivos de fuera que se hablan allí, sin cupo, y la asamblea de la
// Enre los confirma.
RolExternas Rol = "externas"
)
func (r Rol) Valido() bool { return r == RolEnre || r == RolInterno || r == RolExternas }
type Cuenta struct {
Usuario string `json:"usuario"`
Nombre string `json:"nombre"` // nombre visible del colectivo
@ -168,4 +174,10 @@ type Sesion struct {
// por la URL y acabe en el historial o en el log de nginx.
ClaveDe string
ClaveNueva string
// Aviso o error de la siguiente página. Van por la sesión y no por la URL:
// con «?a=» cualquiera podía fabricar un enlace de laenre.net que
// enseñara el texto que quisiera.
Aviso string
Error string
}

View file

@ -7,6 +7,7 @@ import (
"net"
"net/http"
"net/url"
"path"
"sort"
"strings"
"time"
@ -141,6 +142,7 @@ type Base struct {
Usuario string
Nombre string
EsEnre bool
EsExternas bool
CSRF string
Aviso string
Error string
@ -159,6 +161,14 @@ func destinoSeguro(sig string) string {
strings.HasPrefix(sig, "//") {
return ""
}
// Nada de «/pedir/../»: la ruta tiene que estar ya limpia.
limpia := path.Clean(u.Path)
if strings.HasSuffix(u.Path, "/") && limpia != "/" {
limpia += "/"
}
if limpia != u.Path || !strings.HasPrefix(limpia, "/pedir/") {
return ""
}
return u.RequestURI()
}
@ -181,6 +191,7 @@ func (a *App) base(titulo, sub, actual string, c Cuenta, ses *Sesion) Base {
b.Usuario = c.Usuario
b.Nombre = c.Nombre
b.EsEnre = c.Rol == RolEnre
b.EsExternas = c.Rol == RolExternas
b.CSRF = ses.CSRF
}
return b
@ -275,6 +286,12 @@ func (a *App) csrfValido(ses *Sesion, r *http.Request) bool {
return iguales(r.PostFormValue("csrf"), ses.CSRF)
}
// avisar deja un aviso para la página siguiente y redirige a ella.
func (a *App) avisar(w http.ResponseWriter, r *http.Request, ses *Sesion, ruta, aviso string) {
a.ses.PonerAviso(ses.ID, aviso, "")
http.Redirect(w, r, ruta, http.StatusSeeOther)
}
// ------------------------------------------------------------------ portada
func (a *App) verPortada(w http.ResponseWriter, r *http.Request) {
@ -302,9 +319,9 @@ func (a *App) verPortada(w http.ResponseWriter, r *http.Request) {
return
}
datos.Sig = sig
datos.Aviso = r.URL.Query().Get("a")
if sig != "" {
datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo."
datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo. " +
"Colectivos externos: se pide en la asamblea de externas, los lunes de 19:00 a 20:00."
}
a.pintar(w, "portada.tmpl.html", datos)
}
@ -325,9 +342,19 @@ func (a *App) entrar(w http.ResponseWriter, r *http.Request) {
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
clave := r.PostFormValue("clave")
ip := ipCliente(r)
cerrojos := []string{"ip:" + ip, "usuario:" + usuario}
// Un usuario con forma imposible cae siempre en el mismo cajón: si no,
// cada nombre inventado (hasta 64 KB) sería una entrada más en memoria.
llaveU := usuario
if ValidarUsuario(usuario) != nil {
llaveU = "?"
}
if bloqueado, falta := a.cer.Bloqueado(cerrojos...); bloqueado {
// El intento se apunta ANTES de calcular la clave: ver Cerrojo.Intentar.
pasa, falta := a.cer.Intentar(ip)
if pasa {
pasa, falta = a.cerU.Intentar(llaveU)
}
if !pasa {
a.auth.Escribir("AUTH_BLOCK", ip, usuario, "demasiados_intentos")
a.errorAcceso(w, r, fmt.Sprintf("Demasiados intentos. Prueba otra vez en %d minutos.",
int(falta.Minutes())+1))
@ -339,19 +366,18 @@ func (a *App) entrar(w http.ResponseWriter, r *http.Request) {
// Se gasta el mismo tiempo que con una cuenta real para que no se pueda
// averiguar que usuarios existen.
VerificarClave(hashInutil, clave)
a.cer.Fallo(cerrojos...)
a.auth.Escribir("AUTH_FAIL", ip, usuario, "cuenta_desconocida")
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
return
}
if !VerificarClave(c.Hash, clave) {
a.cer.Fallo(cerrojos...)
a.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta")
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
return
}
a.cer.Limpiar(cerrojos...)
a.cer.Limpiar(ip)
a.cerU.Limpiar(llaveU)
a.auth.Escribir("AUTH_OK", ip, usuario, "-")
ses := a.ses.Crear(c.Usuario)
a.ponerCookie(w, ses.ID, 0)
@ -415,6 +441,7 @@ type FilaMia struct {
Etiqueta string
Caduca string
Liberable bool
Externas bool // la pidió la asamblea de externas: va en naranja
}
func (a *App) filaMia(e Evento) FilaMia {
@ -422,6 +449,10 @@ func (a *App) filaMia(e Evento) FilaMia {
Evento: e, Dias: e.NumDias(), Legible: rangoLegible(e),
Etiqueta: e.Estado.Etiqueta(), Liberable: e.Estado.Ocupa(),
}
if c, ok := a.alm.BuscarCuenta(e.Solicitante); ok && c.Rol == RolExternas {
f.Externas = true
f.Etiqueta = etiquetaEstado(e.Estado, true)
}
if e.Estado == Solicitado {
limite := e.Desde.Mas(-a.cfg.Reglas.DiasMinimoConfirmar)
porTiempo := DiaDe(e.Creado.AddDate(0, 0, a.cfg.Reglas.DiasMaximoPendiente))
@ -459,7 +490,7 @@ func (a *App) verMisFechas(w http.ResponseWriter, r *http.Request, c Cuenta, ses
Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)),
DiasUsados: listaDias(usados),
}
datos.Aviso = r.URL.Query().Get("a")
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "mis-fechas.tmpl.html", datos)
}
@ -537,7 +568,7 @@ func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
if err == nil {
estado := Solicitado
if c.Rol == RolEnre {
estado = Confirmado // la asamblea de la casa no se pide permiso a si misma
estado = Confirmado // la asamblea de la Enre no se pide permiso a si misma
}
e = Evento{
ID: nuevoID(), Titulo: s.Titulo, Solicitante: c.Usuario,
@ -558,8 +589,7 @@ func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
return
}
log.Printf("%s pide %s (%s) como %s", c.Usuario, rangoLegible(e), e.Titulo, e.Estado)
http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Fecha apuntada: "+rangoLegible(e)),
http.StatusSeeOther)
a.avisar(w, r, ses, "/pedir/mis-fechas", "Fecha apuntada: "+rangoLegible(e))
}
func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
@ -592,8 +622,7 @@ func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses
if r.PostFormValue("volver") == "todas" && c.Rol == RolEnre {
destino = "/pedir/todas"
}
http.Redirect(w, r, destino+"?a="+url.QueryEscape("Fecha liberada: "+rangoLegible(e)),
http.StatusSeeOther)
a.avisar(w, r, ses, destino, "Fecha liberada: "+rangoLegible(e))
}
// ------------------------------------------------------------------- todas
@ -619,10 +648,14 @@ func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Se
if quien == "" {
quien = e.Solicitante
}
if e.Externo() {
fila := a.filaMia(e)
switch {
case fila.Externas:
quien = e.Amadrinado + " · asamblea de externas"
case e.Externo():
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
}
filas = append(filas, FilaTodas{FilaMia: a.filaMia(e), Quien: quien})
filas = append(filas, FilaTodas{FilaMia: fila, Quien: quien})
}
sort.Slice(filas, func(i, j int) bool { return filas[i].Evento.Desde < filas[j].Evento.Desde })
@ -633,7 +666,7 @@ func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Se
Base: a.base("TODAS LAS FECHAS", "Lo que viene, de todos los colectivos", "todas", c, ses),
Filas: filas,
}
datos.Aviso = r.URL.Query().Get("a")
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "todas.tmpl.html", datos)
}
@ -710,8 +743,7 @@ func (a *App) hacerEditar(w http.ResponseWriter, r *http.Request, c Cuenta, ses
a.formularioEditar(w, c, ses, nuevo, primeraMayuscula(err.Error()))
return
}
http.Redirect(w, r, "/pedir/todas?a="+url.QueryEscape("Evento modificado: "+nuevo.Titulo),
http.StatusSeeOther)
a.avisar(w, r, ses, "/pedir/todas", "Evento modificado: "+nuevo.Titulo)
}
// ---------------------------------------------------------------- pendientes
@ -759,7 +791,7 @@ func (a *App) verPendientes(w http.ResponseWriter, r *http.Request, c Cuenta, se
Base
Filas []FilaPendiente
}{Base: a.base("PENDIENTES", "Lo que espera a la asamblea", "pendientes", c, ses), Filas: filas}
datos.Aviso = r.URL.Query().Get("a")
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "pendientes.tmpl.html", datos)
}
@ -796,9 +828,8 @@ func (a *App) hacerResolver(w http.ResponseWriter, r *http.Request, c Cuenta, se
return
}
log.Printf("%s %s %q de %s", c.Usuario, e.Estado, e.Titulo, e.Solicitante)
http.Redirect(w, r, "/pedir/pendientes?a="+url.QueryEscape(
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta()))),
http.StatusSeeOther)
a.avisar(w, r, ses, "/pedir/pendientes",
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta())))
}
// -------------------------------------------------------------------- findes
@ -832,7 +863,7 @@ func (a *App) verFindes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *S
Base
Filas []FilaFinde
}{Base: a.base("TURNO DE LOS FINDES", "Tranquilo o con ruido", "findes", c, ses), Filas: filas}
datos.Aviso = r.URL.Query().Get("a")
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "findes.tmpl.html", datos)
}
@ -857,7 +888,7 @@ func (a *App) hacerFinde(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
if turno == "" {
aviso = fmt.Sprintf("El finde del %s vuelve a la alternancia automática", v.Legible())
}
http.Redirect(w, r, "/pedir/findes?a="+url.QueryEscape(aviso), http.StatusSeeOther)
a.avisar(w, r, ses, "/pedir/findes", aviso)
}
// ------------------------------------------------------------------- cuentas
@ -873,8 +904,7 @@ func (a *App) verCuentas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
Cuentas: a.alm.Cuentas(),
}
datos.DeQuien, datos.Clave = a.ses.TomarClave(ses.ID)
datos.Aviso = r.URL.Query().Get("a")
datos.Error = r.URL.Query().Get("e")
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "cuentas.tmpl.html", datos)
}
@ -882,8 +912,11 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses
accion := r.PostFormValue("accion")
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
volver := func(params string) { http.Redirect(w, r, "/pedir/cuentas"+params, http.StatusSeeOther) }
problema := func(err error) { volver("?e=" + url.QueryEscape(primeraMayuscula(err.Error()))) }
volver := func(aviso string) { a.avisar(w, r, ses, "/pedir/cuentas", aviso) }
problema := func(err error) {
a.ses.PonerAviso(ses.ID, "", primeraMayuscula(err.Error()))
http.Redirect(w, r, "/pedir/cuentas", http.StatusSeeOther)
}
switch accion {
case "crear":
@ -897,7 +930,7 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses
problema(fmt.Errorf("falta el nombre del colectivo"))
return
}
if rol != RolEnre && rol != RolInterno {
if !rol.Valido() {
problema(fmt.Errorf("rol desconocido"))
return
}
@ -952,7 +985,7 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses
if otra.Activa {
hecho = "activada"
}
volver("?a=" + url.QueryEscape(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho)))
volver(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho))
default:
a.fallo(w, r, http.StatusBadRequest, "Acción desconocida.")
@ -1018,8 +1051,7 @@ func (a *App) hacerClave(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
http.Redirect(w, r, sig, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Contraseña cambiada"),
http.StatusSeeOther)
a.avisar(w, r, nuevaSesion, "/pedir/mis-fechas", "Contraseña cambiada")
}
// -------------------------------------------------------------------- varios

View file

@ -1,6 +1,9 @@
package main
import "testing"
import (
"testing"
"time"
)
func TestDestinoSeguro(t *testing.T) {
casos := map[string]string{
@ -21,3 +24,45 @@ func TestDestinoSeguro(t *testing.T) {
}
}
}
func TestDestinoSeguroSinPuntos(t *testing.T) {
for _, sig := range []string{"/pedir/../index.html", "/pedir/./nueva", "/pedir//nueva", "/pedir/x/../../evil"} {
if got := destinoSeguro(sig); got != "" {
t.Errorf("destinoSeguro(%q) = %q, quiero vacío", sig, got)
}
}
if got := destinoSeguro("/pedir/"); got != "/pedir/" {
t.Errorf("destinoSeguro(/pedir/) = %q", got)
}
}
func TestCerrojoApuntaAntesDeCalcular(t *testing.T) {
c := NuevoCerrojo(5, time.Minute, time.Minute)
pasan := 0
for i := 0; i < 50; i++ {
if ok, _ := c.Intentar("ip:1.2.3.4"); ok {
pasan++
}
}
if pasan != 5 {
t.Fatalf("pasaron %d intentos, quiero 5", pasan)
}
}
func TestArgonParametrosAcotados(t *testing.T) {
// Un hash manipulado que pide 4 GiB de memoria no se calcula.
malo := "$argon2id$v=19$m=4194304,t=3,p=4$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaGhhc2g"
if VerificarClave(malo, "loquesea") {
t.Fatal("no deberia verificar")
}
}
func TestConfigEjemploValida(t *testing.T) {
c, err := CargarConfig("../despliegue/config.ejemplo.json")
if err != nil {
t.Fatalf("despliegue/config.ejemplo.json no carga: %v", err)
}
if !c.CookieSegura || c.Local {
t.Fatal("el ejemplo de producción tiene que llevar cookie_segura y no local")
}
}

View file

@ -25,7 +25,7 @@
{{- if .Eventos}}
<div class="cal-pills">
{{- range .Eventos}}
<div class="cal-ev cal-ev-{{.Estado}}">
<div class="cal-ev cal-ev-{{.Estado}}{{if .Externas}} cal-ev-externas{{end}}">
<span class="cal-ev-cab">{{if .Hora}}<b class="cal-ev-hora">{{.Hora}}</b>{{end}}<span class="cal-ev-estado">{{.Etiqueta}}</span></span>
<span class="cal-ev-nom">{{.Texto}}{{if .Sigue}} (sigue){{end}}</span>
<span class="cal-ev-quien">{{.Quien}}</span>

View file

@ -55,7 +55,7 @@
</section>
<section class="pantalla" aria-label="Dar de alta un colectivo">
<h2>Dar de alta un colectivo de la casa</h2>
<h2>Dar de alta un colectivo de la Enre</h2>
<form class="pedir-form" method="post" action="/pedir/cuentas" autocomplete="off">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="accion" value="crear">
@ -73,14 +73,16 @@
<label for="nuevo-rol">Rol</label>
<select id="nuevo-rol" name="rol">
<option value="interno">interno: pide fechas y amadrina</option>
<option value="externas">externas: la asamblea de externas, pide sin cupo</option>
<option value="enre">enre: además confirma y rechaza</option>
</select>
</p>
<p><button type="submit">Crear la cuenta</button></p>
</form>
<p class="pie">
Los colectivos de fuera no tienen cuenta: sus eventos los pide un colectivo de la casa
que los amadrina. Cuantas menos contraseñas haya guardadas, menos hay que perder.
Los colectivos de fuera no tienen cuenta: sus eventos los apunta la cuenta de externas
en la asamblea de externas, o un colectivo de la Enre que los amadrina. Cuantas menos
contraseñas haya guardadas, menos hay que perder.
</p>
</section>

View file

@ -1,12 +1,21 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Vuestro cupo">
{{- if .EsExternas}}
<h2>Asamblea de externas</h2>
<p class="pie">
Aquí se apuntan los eventos de colectivos de fuera que se hablan en la asamblea de
externas. No hay cupo. Quedan en naranja hasta que la asamblea de la Enre los confirme.
Se pueden pedir fechas hasta el {{.Hasta}}.
</p>
{{- else}}
<h2>Vuestro cupo</h2>
<p class="pie">
Os quedan <strong>{{.Restantes}}</strong> de {{.Reglas.CupoDias}} días dentro de los
próximos {{.Reglas.MesesVentana}} meses. Se pueden pedir fechas hasta el {{.Hasta}}.
{{- if .DiasUsados}} Ahora mismo ocupáis {{.DiasUsados}}.{{end}}
</p>
{{- end}}
<p><a class="pedir-boton" href="/pedir/nueva">Pedir una fecha</a></p>
</section>
@ -35,7 +44,7 @@
{{.Evento.Titulo}}
{{- if .Evento.Externo}}<br><span class="pista">amadrináis a {{.Evento.Amadrinado}}</span>{{end}}
</td>
<td><span class="estado estado-{{.Evento.Estado}}">{{.Etiqueta}}</span></td>
<td><span class="estado estado-{{.Evento.Estado}}{{if .Externas}} estado-externas{{end}}">{{.Etiqueta}}</span></td>
<td>{{if .Caduca}}{{.Caduca}}{{else}}—{{end}}</td>
<td>
{{- if .Liberable}}

View file

@ -4,9 +4,14 @@
<h2>Pedir una fecha</h2>
<ul class="pedir-reglas">
<li>Entre el <strong>{{.Primero.Legible}}</strong> y el <strong>{{.Ultimo.Legible}}</strong>.</li>
{{- if .EsExternas}}
<li>Cada evento es de un colectivo de fuera: poned cuál y un contacto suyo.</li>
<li>La asamblea de externas no tiene cupo. La fecha queda en naranja hasta que la asamblea de la Enre la confirme.</li>
{{- else}}
<li>Como mucho <strong>{{.Reglas.CupoDias}} días por colectivo</strong> en esos {{.Reglas.MesesVentana}} meses. Os quedan {{.Restantes}}.</li>
<li>Las fechas se piden para el finde: viernes, sábado o domingo.</li>
<li>Si el evento es de un colectivo de fuera, decid cuál: vuestro colectivo lo amadrina y responde por él.</li>
{{- end}}
<li>Las fechas se piden para el finde: viernes, sábado o domingo.</li>
</ul>
<form class="pedir-form" method="post" action="/pedir/nueva">
@ -31,9 +36,14 @@
<input id="hora" name="hora" type="time" value="{{.Sol.Hora}}">
</p>
<p>
{{- if .EsExternas}}
<label for="amadrinado">Colectivo de fuera</label>
<input id="amadrinado" name="amadrinado" type="text" required maxlength="80" value="{{.Sol.Amadrinado}}">
{{- else}}
<label for="amadrinado">Colectivo de fuera al que amadrináis</label>
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Sol.Amadrinado}}">
<span class="pista">Vacío si el evento es vuestro.</span>
{{- end}}
</p>
<p>
<label for="contacto">Contacto de ese colectivo</label>

View file

@ -1,11 +1,16 @@
{{template "arriba" .}}
{{- if not .Dentro}}
<p class="pedir-externas">Colectivos externos: las fechas se piden en persona en la <strong>asamblea de externas, lunes de 19:00 a 20:00</strong>.</p>
{{- end}}
<section class="pantalla" aria-label="Fechas pedidas y confirmadas">
<h2>Qué findes están cogidos</h2>
<p class="pie">
Pincha en un día libre del finde para pedirlo.
Los colectivos de la Enre pinchan en un día libre del finde para pedirlo.
En <strong>negro</strong>, fechas que un colectivo ha pedido y esperan a que la
asamblea las confirme. En <strong>verde chillón</strong>, fechas ya confirmadas. Los findes se
asamblea las confirme; en <strong>naranja</strong>, las que ha apuntado la asamblea de
externas. En <strong>verde chillón</strong>, fechas ya confirmadas. Los findes se
turnan entre tranquilos y con ruido. Hoy se pueden pedir fechas hasta el
{{.Hasta}}, con un máximo de {{.Reglas.CupoDias}} días por colectivo.
Los horarios fijos de cada semana están en <a href="/calendario.html">Calendario</a>.
@ -13,6 +18,7 @@
<div class="leyenda-pedir">
<span class="cal-ev cal-ev-solicitado leyenda-muestra">Solicitado</span>
<span class="cal-ev cal-ev-solicitado cal-ev-externas leyenda-muestra">Solicitado externas</span>
<span class="cal-ev cal-ev-confirmado leyenda-muestra">Confirmado</span>
<span class="leyenda-finde cal-tranqui">Finde tranqui</span>
<span class="leyenda-finde cal-ruido">Finde con ruido</span>
@ -28,16 +34,11 @@
{{- if not .Dentro}}
<section class="pantalla" id="entrar" aria-label="Cómo se pide un evento">
<h2>Cómo se pide</h2>
<h2>Entrar</h2>
<p class="pie">
Si tu colectivo <strong>no es de la casa</strong>, la fecha se pide en persona: ven a
la <strong>asamblea de externas, los lunes a las 19:00</strong>, en la Enredadera,
calle de la Coruña nº 5. Allí se habla del evento y un colectivo de la casa lo
amadrina y se hace responsable.
</p>
<p class="pie">
Si tu colectivo <strong>ya tiene credenciales</strong>, entra aquí y pide la fecha.
Desde aquí se piden también las de los colectivos de fuera a los que amadrinéis.
Solo para los <strong>colectivos de la Enre</strong>, que tienen credenciales, y para
la asamblea de externas. Si sois de fuera, no hay cuenta que pedir: la fecha se
pide en la <strong>asamblea de externas, los lunes de 19:00 a 20:00</strong>.
</p>
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">

View file

@ -23,7 +23,7 @@
<td>
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
</td>
<td><span class="estado estado-{{.Evento.Estado}}">{{.Etiqueta}}</span></td>
<td><span class="estado estado-{{.Evento.Estado}}{{if .Externas}} estado-externas{{end}}">{{.Etiqueta}}</span></td>
<td>
<div class="acciones">
<a class="pedir-boton-flojo" href="/pedir/editar?id={{.Evento.ID}}">Modificar</a>

View file

@ -19,6 +19,8 @@ type Reglas struct {
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
}
const maxDiasEvento = 31
func ReglasPorDefecto() Reglas {
return Reglas{
MesesVentana: 3,
@ -104,6 +106,9 @@ func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
if len([]rune(s.Titulo)) > 80 {
return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres")
}
if s.Rol == RolExternas && strings.TrimSpace(s.Amadrinado) == "" {
return fmt.Errorf("falta de qué colectivo de fuera es el evento")
}
if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" {
return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo")
}
@ -123,7 +128,12 @@ func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
}
dias := Rango(s.Desde, s.Hasta)
libre := s.Rol == RolEnre // la asamblea de la casa no tiene cupo ni ventana
// Tope para todos, también la asamblea: Rango corta en 400 días y un evento
// más largo quedaría pintado en el calendario pero sin bloquear sus fechas.
if len(dias) > maxDiasEvento {
return fmt.Errorf("un evento no puede durar más de %d días", maxDiasEvento)
}
libre := s.Rol == RolEnre // la asamblea de la Enre no tiene cupo ni ventana
if !libre {
primero, ultimo := rg.Ventana(hoy)
@ -157,7 +167,9 @@ func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
}
}
if !libre {
// La asamblea de externas apunta eventos de muchos colectivos distintos:
// el cupo es por colectivo y no tiene sentido sumarlos todos en su cuenta.
if !libre && s.Rol != RolExternas {
usados := rg.CupoUsado(s.Solicitante, eventos, hoy, s.Excluir)
if len(usados)+len(dias) > rg.CupoDias {
return fmt.Errorf("te pasáis del cupo: %d días por colectivo en los próximos %d meses. "+

View file

@ -248,3 +248,29 @@ func TestModificarNoChocaConsigoMismo(t *testing.T) {
t.Fatalf("mover «a» encima de «b» deberia fallar por solape, dio %v", err)
}
}
func TestExternasSinCupoPeroConColectivo(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{
evento("a", "externas", "2026-10-02", "2026-10-04", Solicitado),
evento("b", "externas", "2026-10-09", "2026-10-11", Solicitado),
}
s := solicitud("externas", "2026-10-16", "2026-10-16")
s.Rol = RolExternas
if err := rg.Validar(s, eventos, hoy); err == nil || !strings.Contains(err.Error(), "colectivo de fuera") {
t.Fatalf("sin decir de qué colectivo es deberia fallar, dio %v", err)
}
// Con seis días ya apuntados, un séptimo vale: externas no tiene cupo.
s.Amadrinado, s.Contacto = "Colectivo Sin Techo", "sintecho@example.org"
if err := rg.Validar(s, eventos, hoy); err != nil {
t.Fatalf("externas no deberia tener cupo: %v", err)
}
// Pero sigue sin poder pedir entre semana.
s.Desde, s.Hasta = "2026-10-14", "2026-10-14"
if err := rg.Validar(s, eventos, hoy); err == nil {
t.Fatal("externas no deberia poder pedir un miércoles")
}
}

View file

@ -11,6 +11,7 @@ type FilaEvento struct {
Texto string
Quien string
Estado Estado
Externas bool // la pidió la asamblea de externas
Etiqueta string
Sigue bool // dia intermedio o final de un evento de varios dias
}
@ -41,6 +42,7 @@ type Mes struct {
// Aqui no hay horarios semanales: esos siguen en calendario.html, que se dibuja
// con su propio guion y no se toca.
type Vista struct {
roles map[string]Rol
reglas Reglas
exc map[Dia]Turno
eventos []Evento
@ -58,7 +60,11 @@ func (v Vista) eventosDe(d Dia) []FilaEvento {
if quien == "" {
quien = e.Solicitante
}
if e.Externo() {
externas := v.roles[e.Solicitante] == RolExternas
switch {
case externas:
quien = e.Amadrinado + " · asamblea de externas"
case e.Externo():
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
}
out = append(out, FilaEvento{
@ -66,7 +72,8 @@ func (v Vista) eventosDe(d Dia) []FilaEvento {
Texto: e.Titulo,
Quien: quien,
Estado: e.Estado,
Etiqueta: e.Estado.Etiqueta(),
Externas: externas,
Etiqueta: etiquetaEstado(e.Estado, externas),
Sigue: d != e.Desde,
})
}
@ -143,6 +150,15 @@ func (v Vista) Proximas() []Evento {
return out
}
// etiquetaEstado distingue lo que ha pedido la asamblea de externas mientras
// espera: una vez confirmado es un evento más.
func etiquetaEstado(e Estado, externas bool) string {
if externas && e == Solicitado {
return "SOLICITADO EXTERNAS"
}
return e.Etiqueta()
}
func diasDelMes(anio, mes int) int {
return fechaMes(anio, mes).AddDate(0, 1, -1).Day()
}

View file

@ -270,6 +270,19 @@ img{ max-width:100%; height:auto; display:block; }
}
.volver:hover, .volver:active{ color:var(--rosa-encendido); border-color:var(--rosa); gap:16px; }
/* En todas las páginas el título va pegado arriba y el contenido pegado al
título, para aprovechar la pantalla. Antes había 100-140px de aire encima. */
.cabecera .sub{ margin-top:4px; line-height:1.7; }
.volver{ margin-top:6px; }
@media (min-width:1024px){
/* El botón del menú es fijo y ocupa de 20 a 78px en las dos direcciones.
100px a cada lado (simétricos, para que el título siga centrado) bastan
para que un título largo no se meta debajo aunque empiece a 22px. En
móvil no hay anchura para eso y el título sigue bajo el botón: ver el
bloque de max-width:1023px. */
.cabecera{ padding:22px 100px 0; }
}
/* ========================== 5. LA ENREDADERA ==========================
Del título bajan varias raíces que se abren en columnas. De cada columna
cuelgan hojas alternando de lado. Cada hoja lleva su propio trozo de tallo
@ -313,8 +326,10 @@ img{ max-width:100%; height:auto; display:block; }
crecer por el max-width y 15vw sigue subiendo, asi que a 1920 las hojas
se salian de su columna y se pisaban. */
/* Lo que descuenta no es un numero suelto: la bajada ya no mide en vh sino
en proporcion al ancho, asi que entra en la cuenta por su nombre. */
--fila:clamp(282px,calc(100vh - 286px - var(--bajada)),1100px);
en proporcion al ancho, asi que entra en la cuenta por su nombre.
166 y no 286: la cabecera ya no deja 100-140px de aire sobre el titulo
sino 22px, y lo que sobra se lo queda la franja. */
--fila:clamp(282px,calc(100vh - 166px - var(--bajada)),1100px);
--hoja-base:clamp(282px,56vh,660px);
--col:calc(var(--rejilla) / 6);
list-style:none; margin:0; padding:0;
@ -1232,6 +1247,16 @@ gancio-calendar{
.pedir-aviso{ border:var(--pluma) solid var(--verde-hoja); background:#e7f2e9; color:var(--tinta); }
.pedir-error{ border:var(--pluma) solid var(--rosa); background:#fdeaf5; color:var(--rosa-legible); font-weight:700; }
/* ---- Aviso para colectivos de fuera ----
Una línea en naranja, el color de «solicitado externas». El naranja del
calendario (#ff7a00) no llega a AA como texto sobre el fondo claro; este
más tostado da 5.3:1 en la zona más oscura del degradado. */
.pedir-externas{
margin:10px clamp(16px,5vw,60px) 18px;
font-family:var(--lectura); font-size:1rem; line-height:1.6; color:#9a4400;
}
.pedir-externas strong{ color:#9a4400; }
/* ---- Textos de apoyo ---- */
.pista{ display:block; margin-top:4px; font-size:.85rem; color:var(--tinta-suave); }
.pedir-reglas{ margin:0 0 26px; padding-left:22px; line-height:1.7; max-width:70ch; }
@ -1318,6 +1343,7 @@ gancio-calendar{
/* Solicitado en negro y confirmado en verde radiactivo: se distinguen de un
vistazo entre sí y de los dos fondos de finde, que son verde apagado y rosa. */
.estado-solicitado{ background:var(--negro); color:#fff; }
.estado-solicitado.estado-externas{ background:#ff7a00; color:#000; }
.estado-confirmado{ background:#39ff14; color:#000; box-shadow:inset 0 0 0 1.4px #000; }
.finde-tranqui{ background:#a3d1b0; box-shadow:inset 0 0 0 1.4px var(--verde-hoja); }
.finde-ruido{ background:#fbdcef; box-shadow:inset 0 0 0 1.4px var(--rosa); }
@ -1409,6 +1435,9 @@ gancio-calendar{
}
.cal-ev-solicitado{ background:var(--negro); color:#fff; }
.cal-ev-confirmado{ background:#39ff14; color:#000; box-shadow:inset 0 0 0 1.4px #000; }
/* Lo que apunta la asamblea de externas, mientras espera: naranja con letra
negra (8.3:1). Confirmado pasa al verde como cualquier otro. */
.cal-ev-solicitado.cal-ev-externas{ background:#ff7a00; color:#000; }
.cal-ev-cab{ display:flex; flex-wrap:wrap; align-items:baseline; gap:3px 8px; }
.cal-ev-hora{ font-family:var(--titular); font-size:.78rem; letter-spacing:.04em; }
.cal-ev-estado{
@ -1420,6 +1449,7 @@ gancio-calendar{
.cal-ev-nom{ display:block; font-weight:700; }
.cal-ev-quien{ display:block; font-size:.66rem; color:var(--tinta-suave); }
.cal-ev-solicitado .cal-ev-quien{ color:#d0d4c8; }
.cal-ev-externas .cal-ev-estado, .cal-ev-externas .cal-ev-quien{ color:#000; }
.cal-ev-confirmado .cal-ev-quien{ color:var(--tinta); }
.cal-pasado{ opacity:.55; }