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:
parent
ce3127baff
commit
7d5dbeec60
24 changed files with 606 additions and 96 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,3 +2,6 @@
|
||||||
# copias de trabajo
|
# copias de trabajo
|
||||||
*.bak
|
*.bak
|
||||||
index.css.puntuacion0
|
index.css.puntuacion0
|
||||||
|
|
||||||
|
# el binario compilado para el servidor
|
||||||
|
despliegue/enrecal
|
||||||
|
|
|
||||||
69
despliegue/README.md
Normal file
69
despliegue/README.md
Normal 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`.
|
||||||
19
despliegue/config.ejemplo.json
Normal file
19
despliegue/config.ejemplo.json
Normal 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
23
despliegue/cuenta.sh
Executable 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 "$@"
|
||||||
44
despliegue/enrecal.service
Normal file
44
despliegue/enrecal.service
Normal 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
77
despliegue/instalar.sh
Executable 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
|
||||||
28
despliegue/nginx-laenre.conf
Normal file
28
despliegue/nginx-laenre.conf
Normal 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;
|
||||||
|
}
|
||||||
3
despliegue/nginx-zona.conf
Normal file
3
despliegue/nginx-zona.conf
Normal 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;
|
||||||
|
|
@ -26,6 +26,19 @@ const (
|
||||||
argonSal = 16
|
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 {
|
func aleatorio(n int) []byte {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
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.
|
// lleva dentro los parametros con los que se genero.
|
||||||
func HashClave(clave string) string {
|
func HashClave(clave string) string {
|
||||||
sal := aleatorio(argonSal)
|
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",
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
argon2.Version, argonMemoria, argonIteraciones, argonHilos,
|
argon2.Version, argonMemoria, argonIteraciones, argonHilos,
|
||||||
base64.RawStdEncoding.EncodeToString(sal),
|
base64.RawStdEncoding.EncodeToString(sal),
|
||||||
|
|
@ -72,7 +85,13 @@ func VerificarClave(codificado, clave string) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
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)))
|
uint8(hilos), uint32(len(esperado)))
|
||||||
return subtle.ConstantTimeCompare(calculado, esperado) == 1
|
return subtle.ConstantTimeCompare(calculado, esperado) == 1
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +206,27 @@ func (s *Sesiones) TomarClave(id string) (string, string) {
|
||||||
return usuario, clave
|
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) {
|
func (s *Sesiones) Borrar(id string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
ahora := time.Now()
|
ahora := time.Now()
|
||||||
for _, k := range claves {
|
recientes := c.podar(clave, ahora)
|
||||||
recientes := c.podar(k, ahora)
|
if len(recientes) >= c.maxIntentos {
|
||||||
if len(recientes) >= c.maxIntentos {
|
if falta := c.espera - ahora.Sub(recientes[len(recientes)-1]); falta > 0 {
|
||||||
ultimo := recientes[len(recientes)-1]
|
return false, falta
|
||||||
if falta := c.espera - ahora.Sub(ultimo); falta > 0 {
|
|
||||||
return true, falta
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, 0
|
c.fallos[clave] = append(recientes, ahora)
|
||||||
}
|
return true, 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cerrojo) Limpiar(claves ...string) {
|
func (c *Cerrojo) Limpiar(claves ...string) {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ func ConfigPorDefecto() Config {
|
||||||
Escucha: "127.0.0.1:8090",
|
Escucha: "127.0.0.1:8090",
|
||||||
BaseURL: "https://www.laenre.net",
|
BaseURL: "https://www.laenre.net",
|
||||||
DirDatos: "/var/lib/enrecal",
|
DirDatos: "/var/lib/enrecal",
|
||||||
DirPlantillas: "/var/www/laenre/gestor/plantillas",
|
DirPlantillas: "/opt/enrecal/plantillas",
|
||||||
DirEstatico: "/var/www/laenre",
|
DirEstatico: "/var/www/laenre",
|
||||||
LogAuth: "/var/log/enrecal/auth.log",
|
LogAuth: "/var/log/enrecal/auth.log",
|
||||||
CookieSegura: true,
|
CookieSegura: true,
|
||||||
|
|
@ -89,6 +89,11 @@ func (c Config) Validar() error {
|
||||||
const (
|
const (
|
||||||
inactividadSesion = 8 * time.Hour
|
inactividadSesion = 8 * time.Hour
|
||||||
maxIntentos = 5
|
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
|
ventanaIntentos = 10 * time.Minute
|
||||||
esperaBloqueo = 15 * time.Minute
|
esperaBloqueo = 15 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ type App struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
alm Almacen
|
alm Almacen
|
||||||
ses *Sesiones
|
ses *Sesiones
|
||||||
cer *Cerrojo
|
cer *Cerrojo // por IP
|
||||||
|
cerU *Cerrojo // por cuenta
|
||||||
auth *AuthLog
|
auth *AuthLog
|
||||||
|
|
||||||
// mu serializa las escrituras de eventos: comprobar las reglas y guardar
|
// 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")
|
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")
|
usuario = flag.String("usuario", "", "usuario, para las ordenes cuenta y clave")
|
||||||
nombre = flag.String("nombre", "", "nombre visible del colectivo, para la orden cuenta")
|
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")
|
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)")
|
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,
|
alm: reg,
|
||||||
ses: NuevasSesiones(inactividadSesion),
|
ses: NuevasSesiones(inactividadSesion),
|
||||||
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
|
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
|
||||||
|
cerU: NuevoCerrojo(maxIntentosCuenta, ventanaIntentos, esperaBloqueo),
|
||||||
auth: al,
|
auth: al,
|
||||||
plantillas: plantillas,
|
plantillas: plantillas,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -177,8 +179,17 @@ func (a *App) nombres() map[string]string {
|
||||||
return out
|
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 {
|
func (a *App) vista() Vista {
|
||||||
return Vista{
|
return Vista{
|
||||||
|
roles: a.roles(),
|
||||||
reglas: a.cfg.Reglas,
|
reglas: a.cfg.Reglas,
|
||||||
exc: a.alm.Excepciones(),
|
exc: a.alm.Excepciones(),
|
||||||
eventos: a.alm.Eventos(),
|
eventos: a.alm.Eventos(),
|
||||||
|
|
@ -224,8 +235,8 @@ func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiv
|
||||||
if nombre == "" {
|
if nombre == "" {
|
||||||
return errors.New("falta -nombre: el nombre visible del colectivo")
|
return errors.New("falta -nombre: el nombre visible del colectivo")
|
||||||
}
|
}
|
||||||
if rol != RolEnre && rol != RolInterno {
|
if !rol.Valido() {
|
||||||
return fmt.Errorf("el rol solo puede ser %q o %q", RolEnre, RolInterno)
|
return fmt.Errorf("el rol solo puede ser %q, %q o %q", RolEnre, RolInterno, RolExternas)
|
||||||
}
|
}
|
||||||
if _, existe := a.alm.BuscarCuenta(usuario); existe {
|
if _, existe := a.alm.BuscarCuenta(usuario); existe {
|
||||||
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)
|
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,15 @@ type Rol string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RolEnre Rol = "enre" // la asamblea: confirma y rechaza
|
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 {
|
type Cuenta struct {
|
||||||
Usuario string `json:"usuario"`
|
Usuario string `json:"usuario"`
|
||||||
Nombre string `json:"nombre"` // nombre visible del colectivo
|
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.
|
// por la URL y acabe en el historial o en el log de nginx.
|
||||||
ClaveDe string
|
ClaveDe string
|
||||||
ClaveNueva 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
gestor/pedir.go
122
gestor/pedir.go
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -134,19 +135,20 @@ func ipCliente(r *http.Request) string {
|
||||||
// Base es lo que necesita el armazón de la página: el título, quién ha entrado
|
// Base es lo que necesita el armazón de la página: el título, quién ha entrado
|
||||||
// y los avisos.
|
// y los avisos.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
Titulo string
|
Titulo string
|
||||||
Sub string
|
Sub string
|
||||||
Actual string // qué entrada del menú de la sección va marcada
|
Actual string // qué entrada del menú de la sección va marcada
|
||||||
Dentro bool
|
Dentro bool
|
||||||
Usuario string
|
Usuario string
|
||||||
Nombre string
|
Nombre string
|
||||||
EsEnre bool
|
EsEnre bool
|
||||||
CSRF string
|
EsExternas bool
|
||||||
Aviso string
|
CSRF string
|
||||||
Error string
|
Aviso string
|
||||||
Reglas Reglas
|
Error string
|
||||||
Hasta string
|
Reglas Reglas
|
||||||
Sig string // adónde volver después de entrar, ya comprobado
|
Hasta string
|
||||||
|
Sig string // adónde volver después de entrar, ya comprobado
|
||||||
}
|
}
|
||||||
|
|
||||||
// destinoSeguro deja pasar solo rutas de la propia sección: nada de otro
|
// destinoSeguro deja pasar solo rutas de la propia sección: nada de otro
|
||||||
|
|
@ -159,6 +161,14 @@ func destinoSeguro(sig string) string {
|
||||||
strings.HasPrefix(sig, "//") {
|
strings.HasPrefix(sig, "//") {
|
||||||
return ""
|
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()
|
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.Usuario = c.Usuario
|
||||||
b.Nombre = c.Nombre
|
b.Nombre = c.Nombre
|
||||||
b.EsEnre = c.Rol == RolEnre
|
b.EsEnre = c.Rol == RolEnre
|
||||||
|
b.EsExternas = c.Rol == RolExternas
|
||||||
b.CSRF = ses.CSRF
|
b.CSRF = ses.CSRF
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
|
|
@ -275,6 +286,12 @@ func (a *App) csrfValido(ses *Sesion, r *http.Request) bool {
|
||||||
return iguales(r.PostFormValue("csrf"), ses.CSRF)
|
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
|
// ------------------------------------------------------------------ portada
|
||||||
|
|
||||||
func (a *App) verPortada(w http.ResponseWriter, r *http.Request) {
|
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
|
return
|
||||||
}
|
}
|
||||||
datos.Sig = sig
|
datos.Sig = sig
|
||||||
datos.Aviso = r.URL.Query().Get("a")
|
|
||||||
if sig != "" {
|
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)
|
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")))
|
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
|
||||||
clave := r.PostFormValue("clave")
|
clave := r.PostFormValue("clave")
|
||||||
ip := ipCliente(r)
|
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.auth.Escribir("AUTH_BLOCK", ip, usuario, "demasiados_intentos")
|
||||||
a.errorAcceso(w, r, fmt.Sprintf("Demasiados intentos. Prueba otra vez en %d minutos.",
|
a.errorAcceso(w, r, fmt.Sprintf("Demasiados intentos. Prueba otra vez en %d minutos.",
|
||||||
int(falta.Minutes())+1))
|
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
|
// Se gasta el mismo tiempo que con una cuenta real para que no se pueda
|
||||||
// averiguar que usuarios existen.
|
// averiguar que usuarios existen.
|
||||||
VerificarClave(hashInutil, clave)
|
VerificarClave(hashInutil, clave)
|
||||||
a.cer.Fallo(cerrojos...)
|
|
||||||
a.auth.Escribir("AUTH_FAIL", ip, usuario, "cuenta_desconocida")
|
a.auth.Escribir("AUTH_FAIL", ip, usuario, "cuenta_desconocida")
|
||||||
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
|
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !VerificarClave(c.Hash, clave) {
|
if !VerificarClave(c.Hash, clave) {
|
||||||
a.cer.Fallo(cerrojos...)
|
|
||||||
a.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta")
|
a.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta")
|
||||||
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
|
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.cer.Limpiar(cerrojos...)
|
a.cer.Limpiar(ip)
|
||||||
|
a.cerU.Limpiar(llaveU)
|
||||||
a.auth.Escribir("AUTH_OK", ip, usuario, "-")
|
a.auth.Escribir("AUTH_OK", ip, usuario, "-")
|
||||||
ses := a.ses.Crear(c.Usuario)
|
ses := a.ses.Crear(c.Usuario)
|
||||||
a.ponerCookie(w, ses.ID, 0)
|
a.ponerCookie(w, ses.ID, 0)
|
||||||
|
|
@ -415,6 +441,7 @@ type FilaMia struct {
|
||||||
Etiqueta string
|
Etiqueta string
|
||||||
Caduca string
|
Caduca string
|
||||||
Liberable bool
|
Liberable bool
|
||||||
|
Externas bool // la pidió la asamblea de externas: va en naranja
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) filaMia(e Evento) FilaMia {
|
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),
|
Evento: e, Dias: e.NumDias(), Legible: rangoLegible(e),
|
||||||
Etiqueta: e.Estado.Etiqueta(), Liberable: e.Estado.Ocupa(),
|
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 {
|
if e.Estado == Solicitado {
|
||||||
limite := e.Desde.Mas(-a.cfg.Reglas.DiasMinimoConfirmar)
|
limite := e.Desde.Mas(-a.cfg.Reglas.DiasMinimoConfirmar)
|
||||||
porTiempo := DiaDe(e.Creado.AddDate(0, 0, a.cfg.Reglas.DiasMaximoPendiente))
|
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)),
|
Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)),
|
||||||
DiasUsados: listaDias(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)
|
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 {
|
if err == nil {
|
||||||
estado := Solicitado
|
estado := Solicitado
|
||||||
if c.Rol == RolEnre {
|
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{
|
e = Evento{
|
||||||
ID: nuevoID(), Titulo: s.Titulo, Solicitante: c.Usuario,
|
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
|
return
|
||||||
}
|
}
|
||||||
log.Printf("%s pide %s (%s) como %s", c.Usuario, rangoLegible(e), e.Titulo, e.Estado)
|
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)),
|
a.avisar(w, r, ses, "/pedir/mis-fechas", "Fecha apuntada: "+rangoLegible(e))
|
||||||
http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
|
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 {
|
if r.PostFormValue("volver") == "todas" && c.Rol == RolEnre {
|
||||||
destino = "/pedir/todas"
|
destino = "/pedir/todas"
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, destino+"?a="+url.QueryEscape("Fecha liberada: "+rangoLegible(e)),
|
a.avisar(w, r, ses, destino, "Fecha liberada: "+rangoLegible(e))
|
||||||
http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- todas
|
// ------------------------------------------------------------------- todas
|
||||||
|
|
@ -619,10 +648,14 @@ func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Se
|
||||||
if quien == "" {
|
if quien == "" {
|
||||||
quien = e.Solicitante
|
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)
|
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 })
|
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),
|
Base: a.base("TODAS LAS FECHAS", "Lo que viene, de todos los colectivos", "todas", c, ses),
|
||||||
Filas: filas,
|
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)
|
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()))
|
a.formularioEditar(w, c, ses, nuevo, primeraMayuscula(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/pedir/todas?a="+url.QueryEscape("Evento modificado: "+nuevo.Titulo),
|
a.avisar(w, r, ses, "/pedir/todas", "Evento modificado: "+nuevo.Titulo)
|
||||||
http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- pendientes
|
// ---------------------------------------------------------------- pendientes
|
||||||
|
|
@ -759,7 +791,7 @@ func (a *App) verPendientes(w http.ResponseWriter, r *http.Request, c Cuenta, se
|
||||||
Base
|
Base
|
||||||
Filas []FilaPendiente
|
Filas []FilaPendiente
|
||||||
}{Base: a.base("PENDIENTES", "Lo que espera a la asamblea", "pendientes", c, ses), Filas: filas}
|
}{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)
|
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
|
return
|
||||||
}
|
}
|
||||||
log.Printf("%s %s %q de %s", c.Usuario, e.Estado, e.Titulo, e.Solicitante)
|
log.Printf("%s %s %q de %s", c.Usuario, e.Estado, e.Titulo, e.Solicitante)
|
||||||
http.Redirect(w, r, "/pedir/pendientes?a="+url.QueryEscape(
|
a.avisar(w, r, ses, "/pedir/pendientes",
|
||||||
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta()))),
|
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta())))
|
||||||
http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------- findes
|
// -------------------------------------------------------------------- findes
|
||||||
|
|
@ -832,7 +863,7 @@ func (a *App) verFindes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *S
|
||||||
Base
|
Base
|
||||||
Filas []FilaFinde
|
Filas []FilaFinde
|
||||||
}{Base: a.base("TURNO DE LOS FINDES", "Tranquilo o con ruido", "findes", c, ses), Filas: filas}
|
}{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)
|
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 == "" {
|
if turno == "" {
|
||||||
aviso = fmt.Sprintf("El finde del %s vuelve a la alternancia automática", v.Legible())
|
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
|
// ------------------------------------------------------------------- cuentas
|
||||||
|
|
@ -873,8 +904,7 @@ func (a *App) verCuentas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
|
||||||
Cuentas: a.alm.Cuentas(),
|
Cuentas: a.alm.Cuentas(),
|
||||||
}
|
}
|
||||||
datos.DeQuien, datos.Clave = a.ses.TomarClave(ses.ID)
|
datos.DeQuien, datos.Clave = a.ses.TomarClave(ses.ID)
|
||||||
datos.Aviso = r.URL.Query().Get("a")
|
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
|
||||||
datos.Error = r.URL.Query().Get("e")
|
|
||||||
a.pintar(w, "cuentas.tmpl.html", datos)
|
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")
|
accion := r.PostFormValue("accion")
|
||||||
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
|
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
|
||||||
|
|
||||||
volver := func(params string) { http.Redirect(w, r, "/pedir/cuentas"+params, http.StatusSeeOther) }
|
volver := func(aviso string) { a.avisar(w, r, ses, "/pedir/cuentas", aviso) }
|
||||||
problema := func(err error) { volver("?e=" + url.QueryEscape(primeraMayuscula(err.Error()))) }
|
problema := func(err error) {
|
||||||
|
a.ses.PonerAviso(ses.ID, "", primeraMayuscula(err.Error()))
|
||||||
|
http.Redirect(w, r, "/pedir/cuentas", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
switch accion {
|
switch accion {
|
||||||
case "crear":
|
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"))
|
problema(fmt.Errorf("falta el nombre del colectivo"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rol != RolEnre && rol != RolInterno {
|
if !rol.Valido() {
|
||||||
problema(fmt.Errorf("rol desconocido"))
|
problema(fmt.Errorf("rol desconocido"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -952,7 +985,7 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses
|
||||||
if otra.Activa {
|
if otra.Activa {
|
||||||
hecho = "activada"
|
hecho = "activada"
|
||||||
}
|
}
|
||||||
volver("?a=" + url.QueryEscape(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho)))
|
volver(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
a.fallo(w, r, http.StatusBadRequest, "Acción desconocida.")
|
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)
|
http.Redirect(w, r, sig, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Contraseña cambiada"),
|
a.avisar(w, r, nuevaSesion, "/pedir/mis-fechas", "Contraseña cambiada")
|
||||||
http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------- varios
|
// -------------------------------------------------------------------- varios
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestDestinoSeguro(t *testing.T) {
|
func TestDestinoSeguro(t *testing.T) {
|
||||||
casos := map[string]string{
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
{{- if .Eventos}}
|
{{- if .Eventos}}
|
||||||
<div class="cal-pills">
|
<div class="cal-pills">
|
||||||
{{- range .Eventos}}
|
{{- 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-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-nom">{{.Texto}}{{if .Sigue}} (sigue){{end}}</span>
|
||||||
<span class="cal-ev-quien">{{.Quien}}</span>
|
<span class="cal-ev-quien">{{.Quien}}</span>
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="pantalla" aria-label="Dar de alta un colectivo">
|
<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">
|
<form class="pedir-form" method="post" action="/pedir/cuentas" autocomplete="off">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<input type="hidden" name="accion" value="crear">
|
<input type="hidden" name="accion" value="crear">
|
||||||
|
|
@ -73,14 +73,16 @@
|
||||||
<label for="nuevo-rol">Rol</label>
|
<label for="nuevo-rol">Rol</label>
|
||||||
<select id="nuevo-rol" name="rol">
|
<select id="nuevo-rol" name="rol">
|
||||||
<option value="interno">interno: pide fechas y amadrina</option>
|
<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>
|
<option value="enre">enre: además confirma y rechaza</option>
|
||||||
</select>
|
</select>
|
||||||
</p>
|
</p>
|
||||||
<p><button type="submit">Crear la cuenta</button></p>
|
<p><button type="submit">Crear la cuenta</button></p>
|
||||||
</form>
|
</form>
|
||||||
<p class="pie">
|
<p class="pie">
|
||||||
Los colectivos de fuera no tienen cuenta: sus eventos los pide un colectivo de la casa
|
Los colectivos de fuera no tienen cuenta: sus eventos los apunta la cuenta de externas
|
||||||
que los amadrina. Cuantas menos contraseñas haya guardadas, menos hay que perder.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
{{template "arriba" .}}
|
{{template "arriba" .}}
|
||||||
|
|
||||||
<section class="pantalla" aria-label="Vuestro cupo">
|
<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>
|
<h2>Vuestro cupo</h2>
|
||||||
<p class="pie">
|
<p class="pie">
|
||||||
Os quedan <strong>{{.Restantes}}</strong> de {{.Reglas.CupoDias}} días dentro de los
|
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}}.
|
próximos {{.Reglas.MesesVentana}} meses. Se pueden pedir fechas hasta el {{.Hasta}}.
|
||||||
{{- if .DiasUsados}} Ahora mismo ocupáis {{.DiasUsados}}.{{end}}
|
{{- if .DiasUsados}} Ahora mismo ocupáis {{.DiasUsados}}.{{end}}
|
||||||
</p>
|
</p>
|
||||||
|
{{- end}}
|
||||||
<p><a class="pedir-boton" href="/pedir/nueva">Pedir una fecha</a></p>
|
<p><a class="pedir-boton" href="/pedir/nueva">Pedir una fecha</a></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -35,7 +44,7 @@
|
||||||
{{.Evento.Titulo}}
|
{{.Evento.Titulo}}
|
||||||
{{- if .Evento.Externo}}<br><span class="pista">amadrináis a {{.Evento.Amadrinado}}</span>{{end}}
|
{{- if .Evento.Externo}}<br><span class="pista">amadrináis a {{.Evento.Amadrinado}}</span>{{end}}
|
||||||
</td>
|
</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 .Caduca}}{{.Caduca}}{{else}}—{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{- if .Liberable}}
|
{{- if .Liberable}}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,14 @@
|
||||||
<h2>Pedir una fecha</h2>
|
<h2>Pedir una fecha</h2>
|
||||||
<ul class="pedir-reglas">
|
<ul class="pedir-reglas">
|
||||||
<li>Entre el <strong>{{.Primero.Legible}}</strong> y el <strong>{{.Ultimo.Legible}}</strong>.</li>
|
<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>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>
|
<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>
|
</ul>
|
||||||
|
|
||||||
<form class="pedir-form" method="post" action="/pedir/nueva">
|
<form class="pedir-form" method="post" action="/pedir/nueva">
|
||||||
|
|
@ -31,9 +36,14 @@
|
||||||
<input id="hora" name="hora" type="time" value="{{.Sol.Hora}}">
|
<input id="hora" name="hora" type="time" value="{{.Sol.Hora}}">
|
||||||
</p>
|
</p>
|
||||||
<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>
|
<label for="amadrinado">Colectivo de fuera al que amadrináis</label>
|
||||||
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Sol.Amadrinado}}">
|
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Sol.Amadrinado}}">
|
||||||
<span class="pista">Vacío si el evento es vuestro.</span>
|
<span class="pista">Vacío si el evento es vuestro.</span>
|
||||||
|
{{- end}}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label for="contacto">Contacto de ese colectivo</label>
|
<label for="contacto">Contacto de ese colectivo</label>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
{{template "arriba" .}}
|
{{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">
|
<section class="pantalla" aria-label="Fechas pedidas y confirmadas">
|
||||||
<h2>Qué findes están cogidos</h2>
|
<h2>Qué findes están cogidos</h2>
|
||||||
<p class="pie">
|
<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
|
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
|
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.
|
{{.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>.
|
Los horarios fijos de cada semana están en <a href="/calendario.html">Calendario</a>.
|
||||||
|
|
@ -13,6 +18,7 @@
|
||||||
|
|
||||||
<div class="leyenda-pedir">
|
<div class="leyenda-pedir">
|
||||||
<span class="cal-ev cal-ev-solicitado leyenda-muestra">Solicitado</span>
|
<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="cal-ev cal-ev-confirmado leyenda-muestra">Confirmado</span>
|
||||||
<span class="leyenda-finde cal-tranqui">Finde tranqui</span>
|
<span class="leyenda-finde cal-tranqui">Finde tranqui</span>
|
||||||
<span class="leyenda-finde cal-ruido">Finde con ruido</span>
|
<span class="leyenda-finde cal-ruido">Finde con ruido</span>
|
||||||
|
|
@ -28,16 +34,11 @@
|
||||||
|
|
||||||
{{- if not .Dentro}}
|
{{- if not .Dentro}}
|
||||||
<section class="pantalla" id="entrar" aria-label="Cómo se pide un evento">
|
<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">
|
<p class="pie">
|
||||||
Si tu colectivo <strong>no es de la casa</strong>, la fecha se pide en persona: ven a
|
Solo para los <strong>colectivos de la Enre</strong>, que tienen credenciales, y para
|
||||||
la <strong>asamblea de externas, los lunes a las 19:00</strong>, en la Enredadera,
|
la asamblea de externas. Si sois de fuera, no hay cuenta que pedir: la fecha se
|
||||||
calle de la Coruña nº 5. Allí se habla del evento y un colectivo de la casa lo
|
pide en la <strong>asamblea de externas, los lunes de 19:00 a 20:00</strong>.
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">
|
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<td>
|
<td>
|
||||||
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
|
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<div class="acciones">
|
<div class="acciones">
|
||||||
<a class="pedir-boton-flojo" href="/pedir/editar?id={{.Evento.ID}}">Modificar</a>
|
<a class="pedir-boton-flojo" href="/pedir/editar?id={{.Evento.ID}}">Modificar</a>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ type Reglas struct {
|
||||||
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
|
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxDiasEvento = 31
|
||||||
|
|
||||||
func ReglasPorDefecto() Reglas {
|
func ReglasPorDefecto() Reglas {
|
||||||
return Reglas{
|
return Reglas{
|
||||||
MesesVentana: 3,
|
MesesVentana: 3,
|
||||||
|
|
@ -104,6 +106,9 @@ func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
|
||||||
if len([]rune(s.Titulo)) > 80 {
|
if len([]rune(s.Titulo)) > 80 {
|
||||||
return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres")
|
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) == "" {
|
if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" {
|
||||||
return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo")
|
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)
|
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 {
|
if !libre {
|
||||||
primero, ultimo := rg.Ventana(hoy)
|
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)
|
usados := rg.CupoUsado(s.Solicitante, eventos, hoy, s.Excluir)
|
||||||
if len(usados)+len(dias) > rg.CupoDias {
|
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. "+
|
return fmt.Errorf("te pasáis del cupo: %d días por colectivo en los próximos %d meses. "+
|
||||||
|
|
|
||||||
|
|
@ -248,3 +248,29 @@ func TestModificarNoChocaConsigoMismo(t *testing.T) {
|
||||||
t.Fatalf("mover «a» encima de «b» deberia fallar por solape, dio %v", err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type FilaEvento struct {
|
||||||
Texto string
|
Texto string
|
||||||
Quien string
|
Quien string
|
||||||
Estado Estado
|
Estado Estado
|
||||||
|
Externas bool // la pidió la asamblea de externas
|
||||||
Etiqueta string
|
Etiqueta string
|
||||||
Sigue bool // dia intermedio o final de un evento de varios dias
|
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
|
// Aqui no hay horarios semanales: esos siguen en calendario.html, que se dibuja
|
||||||
// con su propio guion y no se toca.
|
// con su propio guion y no se toca.
|
||||||
type Vista struct {
|
type Vista struct {
|
||||||
|
roles map[string]Rol
|
||||||
reglas Reglas
|
reglas Reglas
|
||||||
exc map[Dia]Turno
|
exc map[Dia]Turno
|
||||||
eventos []Evento
|
eventos []Evento
|
||||||
|
|
@ -58,7 +60,11 @@ func (v Vista) eventosDe(d Dia) []FilaEvento {
|
||||||
if quien == "" {
|
if quien == "" {
|
||||||
quien = e.Solicitante
|
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)
|
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
|
||||||
}
|
}
|
||||||
out = append(out, FilaEvento{
|
out = append(out, FilaEvento{
|
||||||
|
|
@ -66,7 +72,8 @@ func (v Vista) eventosDe(d Dia) []FilaEvento {
|
||||||
Texto: e.Titulo,
|
Texto: e.Titulo,
|
||||||
Quien: quien,
|
Quien: quien,
|
||||||
Estado: e.Estado,
|
Estado: e.Estado,
|
||||||
Etiqueta: e.Estado.Etiqueta(),
|
Externas: externas,
|
||||||
|
Etiqueta: etiquetaEstado(e.Estado, externas),
|
||||||
Sigue: d != e.Desde,
|
Sigue: d != e.Desde,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +150,15 @@ func (v Vista) Proximas() []Evento {
|
||||||
return out
|
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 {
|
func diasDelMes(anio, mes int) int {
|
||||||
return fechaMes(anio, mes).AddDate(0, 1, -1).Day()
|
return fechaMes(anio, mes).AddDate(0, 1, -1).Day()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
index.css
34
index.css
|
|
@ -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; }
|
.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 ==========================
|
/* ========================== 5. LA ENREDADERA ==========================
|
||||||
Del título bajan varias raíces que se abren en columnas. De cada columna
|
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
|
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
|
crecer por el max-width y 15vw sigue subiendo, asi que a 1920 las hojas
|
||||||
se salian de su columna y se pisaban. */
|
se salian de su columna y se pisaban. */
|
||||||
/* Lo que descuenta no es un numero suelto: la bajada ya no mide en vh sino
|
/* 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. */
|
en proporcion al ancho, asi que entra en la cuenta por su nombre.
|
||||||
--fila:clamp(282px,calc(100vh - 286px - var(--bajada)),1100px);
|
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);
|
--hoja-base:clamp(282px,56vh,660px);
|
||||||
--col:calc(var(--rejilla) / 6);
|
--col:calc(var(--rejilla) / 6);
|
||||||
list-style:none; margin:0; padding:0;
|
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-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; }
|
.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 ---- */
|
/* ---- Textos de apoyo ---- */
|
||||||
.pista{ display:block; margin-top:4px; font-size:.85rem; color:var(--tinta-suave); }
|
.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; }
|
.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
|
/* 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. */
|
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{ 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; }
|
.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-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); }
|
.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-solicitado{ background:var(--negro); color:#fff; }
|
||||||
.cal-ev-confirmado{ background:#39ff14; color:#000; box-shadow:inset 0 0 0 1.4px #000; }
|
.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-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-hora{ font-family:var(--titular); font-size:.78rem; letter-spacing:.04em; }
|
||||||
.cal-ev-estado{
|
.cal-ev-estado{
|
||||||
|
|
@ -1420,6 +1449,7 @@ gancio-calendar{
|
||||||
.cal-ev-nom{ display:block; font-weight:700; }
|
.cal-ev-nom{ display:block; font-weight:700; }
|
||||||
.cal-ev-quien{ display:block; font-size:.66rem; color:var(--tinta-suave); }
|
.cal-ev-quien{ display:block; font-size:.66rem; color:var(--tinta-suave); }
|
||||||
.cal-ev-solicitado .cal-ev-quien{ color:#d0d4c8; }
|
.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-ev-confirmado .cal-ev-quien{ color:var(--tinta); }
|
||||||
.cal-pasado{ opacity:.55; }
|
.cal-pasado{ opacity:.55; }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue