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
|
||||
*.bak
|
||||
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
|
||||
)
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. "+
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
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; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue