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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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