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

@ -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