ENRE_WEB/gestor/pedir.go
SITO 7d5dbeec60 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.
2026-09-23 12:15:33 +02:00

1074 lines
32 KiB
Go

package main
import (
"bytes"
"fmt"
"log"
"net"
"net/http"
"net/url"
"path"
"sort"
"strings"
"time"
)
const cookieSesion = "enre_pedir"
func (a *App) rutas() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /pedir/{$}", a.verPortada)
mux.HandleFunc("POST /pedir/entrar", a.entrar)
mux.HandleFunc("POST /pedir/salir", a.salir)
mux.HandleFunc("GET /pedir/mis-fechas", a.conSesion(a.verMisFechas))
mux.HandleFunc("GET /pedir/nueva", a.conSesion(a.verNueva))
mux.HandleFunc("POST /pedir/nueva", a.conSesion(a.hacerNueva))
mux.HandleFunc("POST /pedir/liberar", a.conSesion(a.hacerLiberar))
mux.HandleFunc("GET /pedir/pendientes", a.soloEnre(a.verPendientes))
mux.HandleFunc("POST /pedir/resolver", a.soloEnre(a.hacerResolver))
mux.HandleFunc("GET /pedir/todas", a.soloEnre(a.verTodas))
mux.HandleFunc("GET /pedir/editar", a.soloEnre(a.verEditar))
mux.HandleFunc("POST /pedir/editar", a.soloEnre(a.hacerEditar))
mux.HandleFunc("GET /pedir/findes", a.soloEnre(a.verFindes))
mux.HandleFunc("POST /pedir/findes", a.soloEnre(a.hacerFinde))
mux.HandleFunc("GET /pedir/cuentas", a.soloEnre(a.verCuentas))
mux.HandleFunc("POST /pedir/cuentas", a.soloEnre(a.hacerCuenta))
mux.HandleFunc("GET /pedir/clave", a.conSesion(a.verClave))
mux.HandleFunc("POST /pedir/clave", a.conSesion(a.hacerClave))
if a.cfg.Local && a.cfg.DirEstatico != "" {
// Solo para probar y para pasar el banco de pruebas sin nginx: en el
// servidor las paginas estaticas las sirve nginx y esto no se enciende.
mux.Handle("GET /", http.FileServer(http.Dir(a.cfg.DirEstatico)))
}
return a.conCabeceras(mux)
}
// conCabeceras pone las cabeceras de seguridad y descarta los POST que no
// vengan de nuestro propio formulario.
func (a *App) conCabeceras(siguiente http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
enPedir := strings.HasPrefix(r.URL.Path, "/pedir/")
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
// same-origin y no no-referrer: con no-referrer el navegador manda
// «Origin: null» en los POST y origenValido rechaza el acceso. Así no
// sale nada hacia fuera y dentro de la web el Origen llega entero.
h.Set("Referrer-Policy", "same-origin")
if enPedir {
// Estas paginas no llevan ni una linea de JavaScript ni un estilo
// en linea, asi que el navegador puede tenerlo prohibido del todo.
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; "+
"img-src 'self'; font-src 'self'; form-action 'self'; "+
"frame-ancestors 'none'; base-uri 'none'; script-src 'none'")
h.Set("X-Frame-Options", "DENY")
h.Set("Cache-Control", "no-store")
}
if r.Method == http.MethodPost {
// nginx ya corta en 16k, pero el limite tiene que estar tambien
// aqui: el gestor no depende de que haya un proxy delante.
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
if !a.origenValido(r) {
log.Printf("origen rechazado: %s %s host=%q origin=%q referer=%q",
r.Method, r.URL.Path, r.Host, r.Header.Get("Origin"), r.Header.Get("Referer"))
a.fallo(w, r, http.StatusForbidden,
"El formulario no viene de esta web. Vuelve a entrar desde el principio.")
return
}
}
siguiente.ServeHTTP(w, r)
})
}
// origenValido compara el Origen del formulario con el de la web. Es la barrera
// contra el CSRF que no necesita sesion, y por eso protege tambien el acceso.
func (a *App) origenValido(r *http.Request) bool {
esperado := a.anfitrionEsperado()
comprobar := func(crudo string) (bool, bool) {
if crudo == "" {
return false, false
}
u, err := url.Parse(crudo)
if err != nil || u.Host == "" {
return false, true
}
return strings.EqualFold(u.Host, esperado) || strings.EqualFold(u.Host, r.Host), true
}
if ok, habia := comprobar(r.Header.Get("Origin")); habia {
return ok
}
if ok, habia := comprobar(r.Header.Get("Referer")); habia {
return ok
}
// Sin Origin ni Referer queda el testigo CSRF, que se comprueba aparte.
return true
}
func (a *App) anfitrionEsperado() string {
if u, err := url.Parse(a.cfg.BaseURL); err == nil && u.Host != "" {
return u.Host
}
return a.cfg.Escucha
}
// ipCliente devuelve la IP real. Solo se hace caso de las cabeceras del proxy
// cuando la conexion viene de la propia maquina, que es donde corre nginx.
func ipCliente(r *http.Request) string {
anfitrion, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
anfitrion = r.RemoteAddr
}
if ip := net.ParseIP(anfitrion); ip != nil && ip.IsLoopback() {
if v := r.Header.Get("X-Real-IP"); v != "" {
return v
}
if v := r.Header.Get("X-Forwarded-For"); v != "" {
partes := strings.Split(v, ",")
return strings.TrimSpace(partes[len(partes)-1])
}
}
return anfitrion
}
// ---------------------------------------------------------------- plantillas
// 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
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
// dominio ni de «//host», para que el parámetro sig no sirva de redirección
// abierta.
func destinoSeguro(sig string) string {
u, err := url.Parse(sig)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil ||
!strings.HasPrefix(u.Path, "/pedir/") || strings.ContainsAny(sig, "\\\r\n") ||
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()
}
// conSig añade a una ruta el destino al que hay que volver, si lo hay.
func conSig(ruta, sig string) string {
if sig == "" {
return ruta
}
return ruta + "?sig=" + url.QueryEscape(sig)
}
func (a *App) base(titulo, sub, actual string, c Cuenta, ses *Sesion) Base {
_, ultimo := a.cfg.Reglas.Ventana(DiaDe(time.Now()))
b := Base{
Titulo: titulo, Sub: sub, Actual: actual,
Reglas: a.cfg.Reglas, Hasta: ultimo.Legible(),
}
if ses != nil {
b.Dentro = true
b.Usuario = c.Usuario
b.Nombre = c.Nombre
b.EsEnre = c.Rol == RolEnre
b.EsExternas = c.Rol == RolExternas
b.CSRF = ses.CSRF
}
return b
}
// pintar genera la pagina entera en memoria antes de mandarla, para no dejar a
// medias una respuesta si falla la plantilla.
func (a *App) pintar(w http.ResponseWriter, plantilla string, datos any) {
var buf bytes.Buffer
if err := a.plantillas.ExecuteTemplate(&buf, plantilla, datos); err != nil {
log.Printf("plantilla %s: %v", plantilla, err)
http.Error(w, "Error al montar la página", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(buf.Bytes())
}
func (a *App) fallo(w http.ResponseWriter, r *http.Request, codigo int, mensaje string) {
ses, c, _ := a.sesion(r)
datos := struct {
Base
Mensaje string
}{Base: a.base("No se puede", "", "", c, ses), Mensaje: mensaje}
w.WriteHeader(codigo)
a.pintar(w, "fallo.tmpl.html", datos)
}
// --------------------------------------------------------------- middlewares
func (a *App) sesion(r *http.Request) (*Sesion, Cuenta, bool) {
ck, err := r.Cookie(cookieSesion)
if err != nil {
return nil, Cuenta{}, false
}
ses, ok := a.ses.Buscar(ck.Value)
if !ok {
return nil, Cuenta{}, false
}
c, ok := a.alm.BuscarCuenta(ses.Usuario)
if !ok || !c.Activa {
a.ses.Borrar(ses.ID)
return nil, Cuenta{}, false
}
return ses, c, true
}
type manejador func(http.ResponseWriter, *http.Request, Cuenta, *Sesion)
func (a *App) conSesion(siguiente manejador) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ses, c, ok := a.sesion(r)
// Si alguien pincha una fecha sin haber entrado, se le lleva a la
// entrada y, al entrar, vuelve a donde iba.
sig := ""
if r.Method == http.MethodGet {
sig = destinoSeguro(r.URL.RequestURI())
}
if !ok {
http.Redirect(w, r, conSig("/pedir/", sig)+"#entrar", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost && !a.csrfValido(ses, r) {
a.fallo(w, r, http.StatusForbidden,
"El formulario ha caducado. Recarga la página y vuelve a enviarlo.")
return
}
// Con clave temporal no se puede hacer nada mas que cambiarla.
if c.Temporal && r.URL.Path != "/pedir/clave" {
http.Redirect(w, r, conSig("/pedir/clave", sig), http.StatusSeeOther)
return
}
siguiente(w, r, c, ses)
}
}
func (a *App) soloEnre(siguiente manejador) http.HandlerFunc {
return a.conSesion(func(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
if c.Rol != RolEnre {
a.fallo(w, r, http.StatusForbidden,
"Esto solo lo puede hacer la cuenta de la Enredadera, que es la que decide en asamblea.")
return
}
siguiente(w, r, c, ses)
})
}
func (a *App) csrfValido(ses *Sesion, r *http.Request) bool {
if err := r.ParseForm(); err != nil {
return false
}
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) {
ses, c, dentro := a.sesion(r)
v := a.vista()
datos := struct {
Base
Meses []Mes
Proximas []Evento
Nombres map[string]string
}{
Base: a.base("PEDIR EVENTO", "Qué findes están cogidos y cómo se pide uno", "calendario", c, ses),
Meses: v.Meses(a.cfg.MesesVista),
Proximas: v.Proximas(),
Nombres: v.nombres,
}
sig := destinoSeguro(r.URL.Query().Get("sig"))
if dentro && c.Temporal {
http.Redirect(w, r, conSig("/pedir/clave", sig), http.StatusSeeOther)
return
}
if dentro && sig != "" {
http.Redirect(w, r, sig, http.StatusSeeOther)
return
}
datos.Sig = sig
if sig != "" {
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)
}
func (a *App) entrar(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.fallo(w, r, http.StatusBadRequest, "Formulario ilegible.")
return
}
// Para el acceso se exige Origen conocido: sin sesion todavia no hay testigo
// CSRF que comprobar.
if r.Header.Get("Origin") == "" && r.Header.Get("Referer") == "" {
a.fallo(w, r, http.StatusForbidden,
"Entra desde la página de acceso, no desde un enlace de fuera.")
return
}
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
clave := r.PostFormValue("clave")
ip := ipCliente(r)
// 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 = "?"
}
// 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))
return
}
c, existe := a.alm.BuscarCuenta(usuario)
if !existe || !c.Activa {
// Se gasta el mismo tiempo que con una cuenta real para que no se pueda
// averiguar que usuarios existen.
VerificarClave(hashInutil, clave)
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.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta")
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
return
}
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)
sig := destinoSeguro(r.PostFormValue("sig"))
destino := "/pedir/mis-fechas"
if sig != "" {
destino = sig
}
if c.Temporal {
destino = conSig("/pedir/clave", sig)
}
http.Redirect(w, r, destino, http.StatusSeeOther)
}
func (a *App) errorAcceso(w http.ResponseWriter, r *http.Request, mensaje string) {
v := a.vista()
datos := struct {
Base
Meses []Mes
Proximas []Evento
}{
Base: a.base("PEDIR EVENTO", "Qué findes están cogidos y cómo se pide uno", "calendario", Cuenta{}, nil),
Meses: v.Meses(a.cfg.MesesVista),
Proximas: v.Proximas(),
}
datos.Error = mensaje
datos.Sig = destinoSeguro(r.PostFormValue("sig"))
w.WriteHeader(http.StatusUnauthorized)
a.pintar(w, "portada.tmpl.html", datos)
}
func (a *App) ponerCookie(w http.ResponseWriter, valor string, maxEdad int) {
http.SetCookie(w, &http.Cookie{
Name: cookieSesion,
Value: valor,
Path: "/pedir",
HttpOnly: true,
Secure: a.cfg.CookieSegura,
SameSite: http.SameSiteStrictMode,
MaxAge: maxEdad,
})
}
func (a *App) salir(w http.ResponseWriter, r *http.Request) {
if ses, _, ok := a.sesion(r); ok {
if a.csrfValido(ses, r) {
a.ses.Borrar(ses.ID)
}
}
a.ponerCookie(w, "", -1)
http.Redirect(w, r, "/pedir/", http.StatusSeeOther)
}
// --------------------------------------------------------------- mis fechas
type FilaMia struct {
Evento Evento
Dias int
Legible string
Etiqueta string
Caduca string
Liberable bool
Externas bool // la pidió la asamblea de externas: va en naranja
}
func (a *App) filaMia(e Evento) FilaMia {
f := 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))
if porTiempo < limite {
limite = porTiempo
}
f.Caduca = limite.Legible()
}
return f
}
func (a *App) verMisFechas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
hoy := DiaDe(time.Now())
eventos := a.alm.Eventos()
usados := a.cfg.Reglas.CupoUsado(c.Usuario, eventos, hoy, "")
var mias []FilaMia
for _, e := range eventos {
if e.Solicitante != c.Usuario || !e.Estado.Ocupa() || e.Hasta < hoy {
continue
}
mias = append(mias, a.filaMia(e))
}
datos := struct {
Base
Mias []FilaMia
Usados int
Restantes int
DiasUsados string
}{
Base: a.base("VUESTRAS FECHAS", c.Nombre, "mis-fechas", c, ses),
Mias: mias,
Usados: len(usados),
Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)),
DiasUsados: listaDias(usados),
}
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "mis-fechas.tmpl.html", datos)
}
func rangoLegible(e Evento) string {
if e.Desde == e.Hasta {
return e.Desde.Legible()
}
return fmt.Sprintf("del %s al %s", e.Desde.Legible(), e.Hasta.Legible())
}
func max0(n int) int {
if n < 0 {
return 0
}
return n
}
// -------------------------------------------------------------- pedir fecha
func (a *App) verNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
s := Solicitud{}
// Viene de pinchar un día del calendario.
if f := Dia(r.URL.Query().Get("fecha")); f != "" {
if _, err := f.Time(); err == nil {
s.Desde = f
}
}
a.formularioNueva(w, c, ses, s, "")
}
func (a *App) formularioNueva(w http.ResponseWriter, c Cuenta, ses *Sesion, s Solicitud, fallo string) {
hoy := DiaDe(time.Now())
primero, ultimo := a.cfg.Reglas.Ventana(hoy)
usados := a.cfg.Reglas.CupoUsado(c.Usuario, a.alm.Eventos(), hoy, "")
datos := struct {
Base
Sol Solicitud
Primero Dia
Ultimo Dia
Restantes int
DiasUsados string
}{
Base: a.base("PEDIR UNA FECHA", c.Nombre, "nueva", c, ses),
Sol: s,
Primero: primero,
Ultimo: ultimo,
Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)),
DiasUsados: listaDias(usados),
}
datos.Error = fallo
a.pintar(w, "nueva.tmpl.html", datos)
}
func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
s := Solicitud{
Solicitante: c.Usuario,
Rol: c.Rol,
Titulo: recortar(r.PostFormValue("titulo"), 80),
Amadrinado: recortar(r.PostFormValue("amadrinado"), 80),
Contacto: recortar(r.PostFormValue("contacto"), 120),
Desde: Dia(strings.TrimSpace(r.PostFormValue("desde"))),
Hasta: Dia(strings.TrimSpace(r.PostFormValue("hasta"))),
Hora: strings.TrimSpace(r.PostFormValue("hora")),
Notas: recortar(r.PostFormValue("notas"), 500),
}
if s.Hasta == "" {
s.Hasta = s.Desde
}
a.mu.Lock()
hoy := DiaDe(time.Now())
err := a.cfg.Reglas.Validar(s, a.alm.Eventos(), hoy)
var e Evento
if err == nil {
estado := Solicitado
if c.Rol == RolEnre {
estado = Confirmado // la asamblea de la Enre no se pide permiso a si misma
}
e = Evento{
ID: nuevoID(), Titulo: s.Titulo, Solicitante: c.Usuario,
Amadrinado: s.Amadrinado, Contacto: s.Contacto,
Desde: s.Desde, Hasta: s.Hasta, Hora: s.Hora, Notas: s.Notas,
Estado: estado, Creado: time.Now(),
}
if estado == Confirmado {
e.Resuelto = e.Creado
e.ResueltoPor = c.Usuario
}
err = a.alm.GuardarEvento(c.Usuario, e)
}
a.mu.Unlock()
if err != nil {
a.formularioNueva(w, c, ses, s, primeraMayuscula(err.Error()))
return
}
log.Printf("%s pide %s (%s) como %s", c.Usuario, rangoLegible(e), e.Titulo, e.Estado)
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) {
id := r.PostFormValue("id")
a.mu.Lock()
e, ok := a.alm.BuscarEvento(id)
var err error
switch {
case !ok:
err = fmt.Errorf("esa fecha ya no existe")
case e.Solicitante != c.Usuario && c.Rol != RolEnre:
err = fmt.Errorf("esa fecha no es vuestra")
case !e.Estado.Ocupa():
err = fmt.Errorf("esa fecha ya estaba %s", strings.ToLower(e.Estado.Etiqueta()))
default:
e.Estado = Cancelado
e.Resuelto = time.Now()
e.ResueltoPor = c.Usuario
e.Motivo = "liberada por " + c.Nombre
err = a.alm.GuardarEvento(c.Usuario, e)
}
a.mu.Unlock()
if err != nil {
a.fallo(w, r, http.StatusBadRequest, primeraMayuscula(err.Error()))
return
}
destino := "/pedir/mis-fechas"
if r.PostFormValue("volver") == "todas" && c.Rol == RolEnre {
destino = "/pedir/todas"
}
a.avisar(w, r, ses, destino, "Fecha liberada: "+rangoLegible(e))
}
// ------------------------------------------------------------------- todas
// FilaTodas es un evento en la lista de la asamblea, con quién lo pide.
type FilaTodas struct {
FilaMia
Quien string
}
// verTodas enseña a la asamblea todos los eventos que vienen, de cualquier
// colectivo, para quitarlos o modificarlos. «Vuestras fechas» solo enseña los
// de la propia cuenta.
func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
hoy := DiaDe(time.Now())
nombres := a.nombres()
var filas []FilaTodas
for _, e := range a.alm.Eventos() {
if !e.Estado.Ocupa() || e.Hasta < hoy {
continue
}
quien := nombres[e.Solicitante]
if quien == "" {
quien = e.Solicitante
}
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: fila, Quien: quien})
}
sort.Slice(filas, func(i, j int) bool { return filas[i].Evento.Desde < filas[j].Evento.Desde })
datos := struct {
Base
Filas []FilaTodas
}{
Base: a.base("TODAS LAS FECHAS", "Lo que viene, de todos los colectivos", "todas", c, ses),
Filas: filas,
}
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "todas.tmpl.html", datos)
}
func (a *App) verEditar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
e, ok := a.alm.BuscarEvento(r.URL.Query().Get("id"))
if !ok || !e.Estado.Ocupa() {
a.fallo(w, r, http.StatusNotFound, "Ese evento no existe o ya no ocupa ninguna fecha.")
return
}
a.formularioEditar(w, c, ses, e, "")
}
func (a *App) formularioEditar(w http.ResponseWriter, c Cuenta, ses *Sesion, e Evento, fallo string) {
datos := struct {
Base
Evento Evento
Quien string
}{
Base: a.base("MODIFICAR EVENTO", e.Titulo, "todas", c, ses),
Evento: e,
Quien: a.nombres()[e.Solicitante],
}
datos.Error = fallo
a.pintar(w, "editar.tmpl.html", datos)
}
// hacerEditar cambia un evento de cualquier colectivo. Pasa por Validar como
// la asamblea, que no tiene cupo ni ventana, pero sí respeta que no se pisen
// dos eventos en el mismo día.
func (a *App) hacerEditar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
a.mu.Lock()
e, ok := a.alm.BuscarEvento(r.PostFormValue("id"))
if !ok || !e.Estado.Ocupa() {
a.mu.Unlock()
a.fallo(w, r, http.StatusNotFound, "Ese evento no existe o ya no ocupa ninguna fecha.")
return
}
nuevo := e
nuevo.Titulo = recortar(r.PostFormValue("titulo"), 80)
nuevo.Amadrinado = recortar(r.PostFormValue("amadrinado"), 80)
nuevo.Contacto = recortar(r.PostFormValue("contacto"), 120)
nuevo.Desde = Dia(strings.TrimSpace(r.PostFormValue("desde")))
nuevo.Hasta = Dia(strings.TrimSpace(r.PostFormValue("hasta")))
nuevo.Hora = strings.TrimSpace(r.PostFormValue("hora"))
nuevo.Notas = recortar(r.PostFormValue("notas"), 500)
if nuevo.Hasta == "" {
nuevo.Hasta = nuevo.Desde
}
switch Estado(r.PostFormValue("estado")) {
case Solicitado:
nuevo.Estado = Solicitado
case Confirmado:
nuevo.Estado = Confirmado
}
s := Solicitud{
Solicitante: e.Solicitante, Rol: RolEnre,
Titulo: nuevo.Titulo, Amadrinado: nuevo.Amadrinado, Contacto: nuevo.Contacto,
Desde: nuevo.Desde, Hasta: nuevo.Hasta, Hora: nuevo.Hora, Notas: nuevo.Notas,
Excluir: e.ID,
}
err := a.cfg.Reglas.Validar(s, a.alm.Eventos(), DiaDe(time.Now()))
if err == nil {
if nuevo.Estado != e.Estado {
nuevo.Resuelto = time.Now()
nuevo.ResueltoPor = c.Usuario
}
nuevo.Motivo = "modificado por " + c.Nombre
err = a.alm.GuardarEvento(c.Usuario, nuevo)
}
a.mu.Unlock()
if err != nil {
a.formularioEditar(w, c, ses, nuevo, primeraMayuscula(err.Error()))
return
}
a.avisar(w, r, ses, "/pedir/todas", "Evento modificado: "+nuevo.Titulo)
}
// ---------------------------------------------------------------- pendientes
type FilaPendiente struct {
Evento Evento
Quien string
Legible string
Dias int
Faltan int
Caduca string
Turno string
}
func (a *App) verPendientes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
hoy := DiaDe(time.Now())
nombres := a.nombres()
exc := a.alm.Excepciones()
var filas []FilaPendiente
for _, e := range a.alm.Eventos() {
if e.Estado != Solicitado {
continue
}
quien := nombres[e.Solicitante]
if quien == "" {
quien = e.Solicitante
}
if e.Externo() {
quien = fmt.Sprintf("%s, amadrinado por %s", e.Amadrinado, quien)
}
filas = append(filas, FilaPendiente{
Evento: e,
Quien: quien,
Legible: rangoLegible(e),
Dias: e.NumDias(),
Faltan: int(e.Desde.MustTime().Sub(hoy.MustTime()).Hours() / 24),
Caduca: a.filaMia(e).Caduca,
Turno: a.cfg.Reglas.TurnoDe(e.Desde, exc).Etiqueta(),
})
}
sort.Slice(filas, func(i, j int) bool { return filas[i].Evento.Desde < filas[j].Evento.Desde })
datos := struct {
Base
Filas []FilaPendiente
}{Base: a.base("PENDIENTES", "Lo que espera a la asamblea", "pendientes", c, ses), Filas: filas}
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "pendientes.tmpl.html", datos)
}
func (a *App) hacerResolver(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
id := r.PostFormValue("id")
accion := r.PostFormValue("accion")
motivo := recortar(r.PostFormValue("motivo"), 200)
a.mu.Lock()
e, ok := a.alm.BuscarEvento(id)
var err error
switch {
case !ok:
err = fmt.Errorf("esa solicitud ya no existe")
case e.Estado != Solicitado:
err = fmt.Errorf("esa solicitud ya estaba %s", strings.ToLower(e.Estado.Etiqueta()))
case accion == "confirmar":
e.Estado = Confirmado
case accion == "rechazar":
e.Estado = Rechazado
default:
err = fmt.Errorf("acción desconocida")
}
if err == nil {
e.Resuelto = time.Now()
e.ResueltoPor = c.Usuario
e.Motivo = motivo
err = a.alm.GuardarEvento(c.Usuario, e)
}
a.mu.Unlock()
if err != nil {
a.fallo(w, r, http.StatusBadRequest, primeraMayuscula(err.Error()))
return
}
log.Printf("%s %s %q de %s", c.Usuario, e.Estado, e.Titulo, e.Solicitante)
a.avisar(w, r, ses, "/pedir/pendientes",
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta())))
}
// -------------------------------------------------------------------- findes
type FilaFinde struct {
Viernes Dia
Legible string
Turno Turno
Etiqueta string
AMano bool
}
func (a *App) verFindes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
exc := a.alm.Excepciones()
viernes := DiaDe(time.Now())
for viernes.DiaISO() != 5 {
viernes = viernes.Mas(1)
}
var filas []FilaFinde
for i := 0; i < 16; i++ {
v := viernes.Mas(7 * i)
t := a.cfg.Reglas.TurnoDe(v, exc)
_, aMano := exc[v]
filas = append(filas, FilaFinde{
Viernes: v, Legible: v.Legible(), Turno: t, Etiqueta: t.Etiqueta(), AMano: aMano,
})
}
datos := struct {
Base
Filas []FilaFinde
}{Base: a.base("TURNO DE LOS FINDES", "Tranquilo o con ruido", "findes", c, ses), Filas: filas}
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "findes.tmpl.html", datos)
}
func (a *App) hacerFinde(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
v := Dia(strings.TrimSpace(r.PostFormValue("viernes")))
turno := Turno(r.PostFormValue("turno"))
if _, err := v.Time(); err != nil || v.DiaISO() != 5 {
a.fallo(w, r, http.StatusBadRequest, "Los turnos se marcan sobre el viernes del finde.")
return
}
if turno != Tranqui && turno != Ruido && turno != "" {
a.fallo(w, r, http.StatusBadRequest, "Turno desconocido.")
return
}
if err := a.alm.GuardarExcepcion(c.Usuario, Excepcion{Viernes: v, Turno: turno}); err != nil {
a.fallo(w, r, http.StatusInternalServerError, "No he podido guardarlo.")
return
}
aviso := fmt.Sprintf("El finde del %s queda como %s", v.Legible(), strings.ToLower(turno.Etiqueta()))
if turno == "" {
aviso = fmt.Sprintf("El finde del %s vuelve a la alternancia automática", v.Legible())
}
a.avisar(w, r, ses, "/pedir/findes", aviso)
}
// ------------------------------------------------------------------- cuentas
func (a *App) verCuentas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
datos := struct {
Base
Cuentas []Cuenta
Clave string
DeQuien string
}{
Base: a.base("CUENTAS", "Quién puede pedir fechas", "cuentas", c, ses),
Cuentas: a.alm.Cuentas(),
}
datos.DeQuien, datos.Clave = a.ses.TomarClave(ses.ID)
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
a.pintar(w, "cuentas.tmpl.html", datos)
}
func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
accion := r.PostFormValue("accion")
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
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":
nombre := recortar(r.PostFormValue("nombre"), 60)
rol := Rol(r.PostFormValue("rol"))
if err := ValidarUsuario(usuario); err != nil {
problema(err)
return
}
if nombre == "" {
problema(fmt.Errorf("falta el nombre del colectivo"))
return
}
if !rol.Valido() {
problema(fmt.Errorf("rol desconocido"))
return
}
if _, existe := a.alm.BuscarCuenta(usuario); existe {
problema(fmt.Errorf("ya existe la cuenta %q", usuario))
return
}
clave := ClaveTemporal()
nueva := Cuenta{
Usuario: usuario, Nombre: nombre, Rol: rol,
Hash: HashClave(clave), Temporal: true, Activa: true, Creada: time.Now(),
}
if err := a.alm.GuardarCuenta(c.Usuario, nueva); err != nil {
problema(err)
return
}
a.ses.PonerClave(ses.ID, usuario, clave)
volver("")
case "reset", "activar", "desactivar":
otra, ok := a.alm.BuscarCuenta(usuario)
if !ok {
problema(fmt.Errorf("no existe la cuenta %q", usuario))
return
}
if accion == "reset" {
clave := ClaveTemporal()
otra.Hash = HashClave(clave)
otra.Temporal = true
if err := a.alm.GuardarCuenta(c.Usuario, otra); err != nil {
problema(err)
return
}
a.ses.BorrarDeUsuario(usuario)
a.ses.PonerClave(ses.ID, usuario, clave)
volver("")
return
}
if usuario == c.Usuario {
problema(fmt.Errorf("no te puedes desactivar a ti misma"))
return
}
otra.Activa = accion == "activar"
if err := a.alm.GuardarCuenta(c.Usuario, otra); err != nil {
problema(err)
return
}
if !otra.Activa {
a.ses.BorrarDeUsuario(usuario)
}
hecho := "desactivada"
if otra.Activa {
hecho = "activada"
}
volver(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho))
default:
a.fallo(w, r, http.StatusBadRequest, "Acción desconocida.")
}
}
// --------------------------------------------------------------------- clave
func (a *App) verClave(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
a.formularioClave(w, c, ses, "", http.StatusOK, destinoSeguro(r.URL.Query().Get("sig")))
}
func (a *App) formularioClave(w http.ResponseWriter, c Cuenta, ses *Sesion, fallo string, codigo int, sig string) {
datos := struct {
Base
Temporal bool
}{Base: a.base("CONTRASEÑA", c.Nombre, "clave", c, ses), Temporal: c.Temporal}
datos.Error = fallo
datos.Sig = sig
if codigo != http.StatusOK {
w.WriteHeader(codigo)
}
a.pintar(w, "clave.tmpl.html", datos)
}
func (a *App) hacerClave(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
actual := r.PostFormValue("actual")
nueva := r.PostFormValue("nueva")
repetida := r.PostFormValue("repetida")
sig := destinoSeguro(r.PostFormValue("sig"))
mal := func(msg string) { a.formularioClave(w, c, ses, msg, http.StatusBadRequest, sig) }
if !VerificarClave(c.Hash, actual) {
a.auth.Escribir("AUTH_FAIL", ipCliente(r), c.Usuario, "clave_actual_incorrecta")
mal("La contraseña actual no es correcta.")
return
}
if nueva != repetida {
mal("Las dos contraseñas nuevas no coinciden.")
return
}
if err := ValidarClave(nueva); err != nil {
mal(primeraMayuscula(err.Error()))
return
}
if nueva == actual {
mal("La contraseña nueva tiene que ser distinta de la anterior.")
return
}
c.Hash = HashClave(nueva)
c.Temporal = false
if err := a.alm.GuardarCuenta(c.Usuario, c); err != nil {
mal("No he podido guardarla.")
return
}
// Cambiar la clave cierra las demas sesiones de esa cuenta.
a.ses.BorrarDeUsuario(c.Usuario)
nuevaSesion := a.ses.Crear(c.Usuario)
a.ponerCookie(w, nuevaSesion.ID, 0)
if sig != "" {
http.Redirect(w, r, sig, http.StatusSeeOther)
return
}
a.avisar(w, r, nuevaSesion, "/pedir/mis-fechas", "Contraseña cambiada")
}
// -------------------------------------------------------------------- varios
func recortar(s string, max int) string {
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > max {
return string(r[:max])
}
return s
}
func primeraMayuscula(s string) string {
if s == "" {
return s
}
r := []rune(s)
return strings.ToUpper(string(r[0])) + string(r[1:])
}